From 12035e223135cde9f2964448b2e50e3e67e7b015 Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Wed, 8 Jul 2026 13:14:48 +0200 Subject: [PATCH] Add M2 fixtures and content regression pack (#32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seed script extends the fixture matrix with a shared "Content Fixtures" pond (owned by fixture-user): an "Every Element" page covering every editor schema node and mark (#24), and a "Fixture Image" page with one real, servable uploaded image. "Every Element" loads a checked-in Yjs snapshot (prisma/fixtures/content-page.yjs) generated from a human-readable Markdown source (content-page.md) via a deterministic regeneration script (pinned Y.Doc clientID; refuses to write a snapshot that isn't a fixed point of the Markdown round-trip). New apps/web/e2e/content.spec.ts consolidates the M2 content regression pack: page lifecycle, editor basics, image paste, trash, and — the pack's actual regression pin — a byte-for-byte comparison of the fixture page's exported Markdown against the checked-in fixture. Verified this catches regressions: temporarily mutated docToMarkdown's heading serializer, rebuilt, re-seeded, confirmed the comparison failed, then reverted. This pack now runs in CI (a second step in the existing auth-e2e job, reusing its already-built-and-seeded stack) alongside the existing local-only feature packs. Closes #32 --- .gitea/workflows/ci.yml | 9 + .prettierignore | 3 + apps/api/package.json | 3 +- apps/api/prisma/fixtures/content-page.md | 36 ++++ apps/api/prisma/fixtures/content-page.yjs | Bin 0 -> 1712 bytes apps/api/prisma/fixtures/regenerate.ts | 60 +++++++ apps/api/prisma/seed.ts | 205 +++++++++++++++++++++- apps/web/e2e/README.md | 53 +++++- apps/web/e2e/content.spec.ts | 184 +++++++++++++++++++ 9 files changed, 543 insertions(+), 10 deletions(-) create mode 100644 apps/api/prisma/fixtures/content-page.md create mode 100644 apps/api/prisma/fixtures/content-page.yjs create mode 100644 apps/api/prisma/fixtures/regenerate.ts create mode 100644 apps/web/e2e/content.spec.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 8ce322a..48ead75 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -59,6 +59,10 @@ jobs: - name: Translation key parity (de/en) run: pnpm i18n:check + # Job id/display name kept stable (branch-protection/status-check + # matching uses the reported "CI / Auth e2e pack" context) even though + # it now also runs the content pack (issue #32) against the same + # already-built-and-seeded stack, instead of spinning up a second one. auth-e2e: name: Auth e2e pack runs-on: ubuntu-latest @@ -119,6 +123,11 @@ jobs: E2E_BASE_URL=http://localhost:5173 E2E_MAILPIT_URL=http://mailpit:8025 \ pnpm --filter @dorfteich/web exec playwright test e2e/auth.spec.ts + - name: Run content pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/content.spec.ts + - name: Dump server logs on failure if: failure() run: tail -50 /tmp/api.log /tmp/web.log || true diff --git a/.prettierignore b/.prettierignore index b06a07d..6ae2ada 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,3 +3,6 @@ node_modules/ coverage/ pnpm-lock.yaml .pnpm-store/ +# Must stay byte-identical to docToMarkdown's own output (issue #32) — +# Prettier's Markdown table/list opinions would break that fixed point. +apps/api/prisma/fixtures/content-page.md diff --git a/apps/api/package.json b/apps/api/package.json index 3cc57b8..5858c13 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,7 +11,8 @@ "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests", "db:migrate:dev": "prisma migrate dev", - "db:seed": "tsx prisma/seed.ts" + "db:seed": "tsx prisma/seed.ts", + "fixtures:regenerate": "tsx prisma/fixtures/regenerate.ts" }, "dependencies": { "@dorfteich/shared": "workspace:*", diff --git a/apps/api/prisma/fixtures/content-page.md b/apps/api/prisma/fixtures/content-page.md new file mode 100644 index 0000000..deb37c3 --- /dev/null +++ b/apps/api/prisma/fixtures/content-page.md @@ -0,0 +1,36 @@ +# Fixture Heading 1 + +## Fixture Heading 2 + +### Fixture Heading 3 + +#### Fixture Heading 4 + +A paragraph with **bold**, *italic*, `inline code`, ~~strikethrough~~, and a [link](https://dorfteich.example/docs). + +A line that breaks here,\ +and continues on the next line. + +> A blockquote for good measure. + +``` +a fenced code block +spanning two lines +``` + +--- + +- bullet one +- bullet two + +1. ordered one +2. ordered two + +- [ ] unchecked task +- [x] checked task + +| Header A | Header B | +| --- | --- | +| cell 1 | cell 2 | + +![fixture alt text](fixture-image-placeholder) \ No newline at end of file diff --git a/apps/api/prisma/fixtures/content-page.yjs b/apps/api/prisma/fixtures/content-page.yjs new file mode 100644 index 0000000000000000000000000000000000000000..7e91ab98920b4465a68dfb8eccc5d9d95603f5d1 GIT binary patch literal 1712 zcmZXVOK;RL5P)~$Z5OuXB^3!SVD*5CR6smVRXLOcf*bsTl{cB)gmsgxtcwJCXsX@)dmPn*#H6Mi+gzZ`d zD#O(Zz>br_yiOGIQ`rJPJV8rC63?TA1xTS%WX24$a|3gPn6Uxlq%c-wCJeKG19Oy^ zi2=h>m?$#ShB>~0IY!L%fT^Z1%{F(15^W{AKJDm?q0a7@;j5Fp7igdNp;>K4(sM2? zEP`u?eQ4Xi)i9BA5qxKovd#?$TFAh)d7V7y!(2YfbCu22RX6g`W+o#+D1#8Wnd!r2 zuuFON6|m`7VN0x4&_V61C~5l^Tr5rY;d*H%mn^}iNiIT<3+nO~RH<5L|D|zXVQYM9 z+}FArPaQhsMXIGSaQ%ZgFJ`+EX~vDmY0tMaqz`wpL9Sf}IGDnj^@q3u<`nP2t{HAO zrG24!Q=#a@+((6zDJ`Zu)g{itNIFiX82kjZ$*l?qsZg4C}O&-WFoL`&bwjL$n@8e|6rfsEeu^W zop_j8>Ty?uAzgc}dy(xWP6=S!{73~CsbL`-Dv_ANeTR)S=}Mq($RO6_ec-UoX4nWc z&Qko4;??^_@gv8a*JG!iVYF+~n4}`~yac_l(92bb+Dc8O(MWl8#@?X#o08{k$@9*7 z-a8{XmsK}e#p)kQ)JIEwTBE2u{j%OTLsnyd_NKBHac9uUw?UjA!}pT&hjspRwkDzL zquZhLH#lj(JGFcUE~FIY-rV!6Io$x3n2O1-Y@{SIUGtalfsq|TR1HSXFPD{R=T+qZhTRDt4R1rn?`J}DWWTH~|a zXkPLsB_qW}qdD*5VZLB&D3}#(B=jX4ZUqugd(hk6%Kot&uxkgpQn={TBaF)CF(Ebd Ow&zKlBChG7sQd@AVe(S| literal 0 HcmV?d00001 diff --git a/apps/api/prisma/fixtures/regenerate.ts b/apps/api/prisma/fixtures/regenerate.ts new file mode 100644 index 0000000..b803e12 --- /dev/null +++ b/apps/api/prisma/fixtures/regenerate.ts @@ -0,0 +1,60 @@ +/** + * Regenerates `content-page.yjs` from `content-page.md` (issue #32). + * + * `content-page.md` is the source of truth — a Markdown document + * deliberately covering every node type and mark in the editor schema + * (packages/shared/src/editor-schema/schema.ts, issue #24) and already a + * fixed point of the Markdown round-trip (`docToMarkdown(markdownToDoc(x)) + * === x`); the seed script (`../seed.ts`) loads the checked-in `.yjs` + * binary directly rather than re-parsing Markdown on every run. + * + * Deterministic: `Y.Doc`'s `clientID` is normally randomized per instance, + * which would make the encoded update differ on every regeneration even + * for identical content. Pinning it to a fixed value makes the output a + * pure function of `content-page.md`, so re-running this without editing + * the Markdown produces a byte-identical `.yjs` (verified — re-run and + * `git diff` should show no changes). + * + * Run after editing `content-page.md`: + * pnpm --filter @dorfteich/api fixtures:regenerate + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { docToMarkdown, editorSchema, markdownToDoc } from '@dorfteich/shared'; +import { prosemirrorJSONToYXmlFragment } from 'y-prosemirror'; +import * as Y from 'yjs'; + +const FIXTURE_DIR = join(__dirname); +const FIXED_CLIENT_ID = 1; + +function main(): void { + const markdown = readFileSync(join(FIXTURE_DIR, 'content-page.md'), 'utf8'); + const doc = markdownToDoc(markdown); + + // Round-trip sanity check: if this ever fails, the schema or serializer + // changed in a way that makes content-page.md no longer a fixed point — + // fix the schema/serializer or update content-page.md, don't silently + // check in a snapshot that doesn't match its own source. + const roundTripped = docToMarkdown(doc); + if (roundTripped !== markdown) { + console.error('content-page.md is not a fixed point of the Markdown round-trip.'); + console.error('--- expected (content-page.md) ---'); + console.error(markdown); + console.error('--- got (docToMarkdown(markdownToDoc(content-page.md))) ---'); + console.error(roundTripped); + process.exit(1); + } + + const ydoc = new Y.Doc(); + ydoc.clientID = FIXED_CLIENT_ID; + const fragment = ydoc.getXmlFragment('default'); + prosemirrorJSONToYXmlFragment(editorSchema, doc.toJSON(), fragment); + const state = Y.encodeStateAsUpdate(ydoc); + ydoc.destroy(); + + writeFileSync(join(FIXTURE_DIR, 'content-page.yjs'), state); + console.log(`fixtures: wrote content-page.yjs (${state.length} bytes)`); +} + +main(); diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 3ad5493..9893705 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -11,9 +11,31 @@ * dev machines and disposable CI/Test databases. On shared stages * (test/int), set FIXTURE_ADMIN_PASSWORD / FIXTURE_USER_PASSWORD to give * those two accounts non-public passwords. + * + * Content fixtures (issue #32): a shared pond owned by fixture-user with + * two pages — "Every Element" (every editor schema node/mark, #24, loaded + * from the checked-in `fixtures/content-page.yjs`; regenerate via + * `pnpm --filter @dorfteich/api fixtures:regenerate` after editing + * `fixtures/content-page.md`) and "Fixture Image" (one real, servable + * uploaded image) — for the content regression pack and manual QA. */ -import { slugify } from '@dorfteich/shared'; -import { PrismaClient, UserStatus } from '@prisma/client'; +import { readFileSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { + docToHtml, + docToMarkdown, + docToPlainText, + editorSchema, + extractOutline, + slugify, +} from '@dorfteich/shared'; +import { Prisma, PrismaClient, UserStatus } from '@prisma/client'; +import { generateKeyBetween } from 'fractional-indexing'; +import { prosemirrorJSONToYXmlFragment, yXmlFragmentToProseMirrorRootNode } from 'y-prosemirror'; +import * as Y from 'yjs'; +import { Node as ProseMirrorNode } from 'prosemirror-model'; import { hashPassword } from '../src/users/password'; @@ -44,7 +66,7 @@ const FIXTURES: FixtureUser[] = [ }, ]; -async function upsertFixtureUser(fixture: FixtureUser): Promise { +async function upsertFixtureUser(fixture: FixtureUser): Promise { const email = `${fixture.username}@dorfteich.test`; const user = await prisma.user.upsert({ where: { username: fixture.username }, @@ -110,15 +132,190 @@ async function upsertFixtureUser(fixture: FixtureUser): Promise { update: { value: 100 }, }); } + return user.id; +} + +interface DerivedContent { + plainText: string; + markdown: string; + html: string; + outline: Prisma.InputJsonValue; +} + +function deriveContentOf(doc: ProseMirrorNode): DerivedContent { + return { + plainText: docToPlainText(doc), + markdown: docToMarkdown(doc), + html: docToHtml(doc), + outline: extractOutline(doc) as unknown as Prisma.InputJsonValue, + }; +} + +function docFromYjsState(state: Uint8Array): ProseMirrorNode { + const ydoc = new Y.Doc(); + Y.applyUpdate(ydoc, state); + const doc = yXmlFragmentToProseMirrorRootNode(ydoc.getXmlFragment('default'), editorSchema); + ydoc.destroy(); + return doc; +} + +async function upsertFixturePage( + pondId: string, + slug: string, + title: string, + ownerId: string, + state: Uint8Array, + content: DerivedContent, +): Promise { + const existing = await prisma.page.findFirst({ where: { pondId, slug }, select: { id: true } }); + if (existing) { + await prisma.page.update({ + where: { id: existing.id }, + data: { + ydocState: state, + contentCache: { upsert: { create: content, update: content } }, + }, + }); + return existing.id; + } + const last = await prisma.page.findFirst({ + where: { pondId }, + orderBy: { sortKey: 'desc' }, + select: { sortKey: true }, + }); + const page = await prisma.page.create({ + data: { + pondId, + title, + slug, + sortKey: generateKeyBetween(last?.sortKey ?? null, null), + ydocState: state, + createdBy: ownerId, + contentCache: { create: content }, + }, + }); + return page.id; +} + +const FIXTURE_POND_SLUG = 'content-fixtures'; +// 1x1 transparent PNG — only the magic bytes matter for a real, servable +// (if visually trivial) fixture image. +const FIXTURE_IMAGE_PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; +const FIXTURE_IMAGE_ID = '00000000-0000-4000-8000-000000000001'; + +/** Shared content pond + fixture pages (issue #32). */ +async function seedContentFixtures(ownerId: string): Promise { + let pond = await prisma.pond.findUnique({ where: { slug: FIXTURE_POND_SLUG } }); + if (!pond) { + pond = await prisma.pond.create({ + data: { + slug: FIXTURE_POND_SLUG, + name: 'Content Fixtures', + type: 'SHARED', + ownerId, + }, + }); + } + await prisma.quotaOverride.upsert({ + where: { + subjectType_subjectId_quotaKey: { + subjectType: 'POND', + subjectId: pond.id, + quotaKey: 'storage_bytes', + }, + }, + create: { + subjectType: 'POND', + subjectId: pond.id, + quotaKey: 'storage_bytes', + value: 100 * 1024 * 1024, + }, + update: { value: 100 * 1024 * 1024 }, + }); + + // "Every Element": the checked-in Yjs snapshot regenerated from + // fixtures/content-page.md — covers every editor schema node/mark (#24). + const everyElementState = readFileSync(join(__dirname, 'fixtures/content-page.yjs')); + const everyElementDoc = docFromYjsState(everyElementState); + await upsertFixturePage( + pond.id, + 'every-element', + 'Every Element', + ownerId, + new Uint8Array(everyElementState), + deriveContentOf(everyElementDoc), + ); + + // "Fixture Image": one real, servable uploaded image (the Markdown + // fixture above only carries a placeholder fileId for round-trip + // testing — this is the one that actually resolves via /media/:fileId). + const uploadsDir = process.env.UPLOADS_DIR ?? './data/uploads'; + const imageBytes = Buffer.from(FIXTURE_IMAGE_PNG_BASE64, 'base64'); + await mkdir(join(uploadsDir, pond.id), { recursive: true }); + await writeFile(join(uploadsDir, pond.id, FIXTURE_IMAGE_ID), imageBytes); + await prisma.attachment.upsert({ + where: { id: FIXTURE_IMAGE_ID }, + create: { + id: FIXTURE_IMAGE_ID, + pondId: pond.id, + fileName: 'fixture.png', + mimeType: 'image/png', + sizeBytes: imageBytes.length, + storagePath: `${pond.id}/${FIXTURE_IMAGE_ID}`, + uploadedBy: ownerId, + }, + update: {}, + }); + await prisma.pondUsage.upsert({ + where: { pondId: pond.id }, + create: { pondId: pond.id, storageBytesUsed: imageBytes.length }, + update: {}, + }); + + const imageDoc = editorSchema.node('doc', null, [ + editorSchema.node('heading', { level: 1 }, [editorSchema.text('Fixture Image')]), + editorSchema.node('paragraph', null, [ + editorSchema.node('image', { + fileId: FIXTURE_IMAGE_ID, + alt: 'A tiny fixture image', + width: null, + }), + ]), + ]); + const imageYdoc = new Y.Doc(); + prosemirrorJSONToYXmlFragment( + editorSchema, + imageDoc.toJSON(), + imageYdoc.getXmlFragment('default'), + ); + const imageState = new Uint8Array(Y.encodeStateAsUpdate(imageYdoc)); + imageYdoc.destroy(); + + const imagePageId = await upsertFixturePage( + pond.id, + 'fixture-image', + 'Fixture Image', + ownerId, + imageState, + deriveContentOf(imageDoc), + ); + await prisma.attachment.update({ + where: { id: FIXTURE_IMAGE_ID }, + data: { pageId: imagePageId }, + }); } async function main(): Promise { // Fresh rate-limit budget for e2e runs — seed targets are always // disposable dev/CI databases, never production. await prisma.rateLimit.deleteMany({}); + let contentOwnerId: string | undefined; for (const fixture of FIXTURES) { - await upsertFixtureUser(fixture); + const userId = await upsertFixtureUser(fixture); + if (fixture.username === 'fixture-user') contentOwnerId = userId; } + if (contentOwnerId) await seedContentFixtures(contentOwnerId); await prisma.instanceSetting.upsert({ where: { key: 'seed.marker' }, create: { key: 'seed.marker', value: { seededAt: new Date().toISOString() } }, diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md index 1f4d92d..e8230c6 100644 --- a/apps/web/e2e/README.md +++ b/apps/web/e2e/README.md @@ -1,11 +1,24 @@ # End-to-end tests -Two Playwright suites with different targets: +Playwright suites, most local-only — three run in CI/CD: -| Suite | Target | Where it runs | -| --------------- | --------------------------------- | --------------------------------------------------------------------- | -| `smoke.spec.ts` | any deployed stage | CD pipeline against `https://test.dorfteich.cloud` after every deploy | -| `auth.spec.ts` | full local stack **with Mailpit** | CI job `auth-e2e` on every PR/push; locally against the dev stack | +| Suite | Target | Where it runs | +| ----------------- | --------------------------------- | --------------------------------------------------------------------------- | +| `smoke.spec.ts` | any deployed stage | CD pipeline against `https://test.dorfteich.cloud` after every deploy | +| `auth.spec.ts` | full local stack **with Mailpit** | CI job `auth-e2e` on every PR/push; locally against the dev stack | +| `content.spec.ts` | full local stack | same CI job `auth-e2e` (a second step), right after the auth pack | +| everything else | full local stack | locally only — `editor`/`sidebar`/`image`/`link`/`markdown`/`trash`.spec.ts | + +`content.spec.ts` is the M2 content regression pack (issue #32): page +lifecycle, editor basics, image paste, Markdown round-trip, and trash — +enough to catch a regression across the whole content model without +re-running every edge case the feature-specific packs above already cover. +Its Markdown round-trip test is a real regression pin, not just a smoke +check: it compares the seeded "Every Element" fixture page's exported +Markdown byte-for-byte against the checked-in `content-page.md` (see +"Content fixtures" below) — any schema/serializer change that alters how a +node round-trips fails it, once the seed has re-run against the changed +code (build → migrate → seed → test, exactly CI's order). ## Running locally @@ -35,6 +48,36 @@ only on dev machines and disposable CI/Test databases. | `fixture-user` | active | regular journeys, settings, sessions | | `fixture-pending` | e-mail not verified | unverified-login cases | +## Content fixtures + +`db:seed` also creates a **shared** pond `content-fixtures` (owned by +`fixture-user`) with two pages, for the content regression pack and manual +QA: + +- **Every Element** (`every-element`) — every editor schema node and mark + (issue #24: headings 1–4, all list types, table, blockquote, code block, + horizontal rule, hard break, and all five marks). Loaded from the + checked-in `apps/api/prisma/fixtures/content-page.yjs`, a Yjs snapshot + generated from the human-readable `content-page.md` next to it — + `content-page.md` is the thing to read or edit; the `.yjs` file is a + build artifact of it, not source. +- **Fixture Image** (`fixture-image`) — one real, servable uploaded image + (the placeholder `fileId` inside the Markdown fixture above is not a + real attachment; this page's image is). + +Regenerating after editing `content-page.md`: + +```sh +pnpm --filter @dorfteich/api fixtures:regenerate +``` + +This is deterministic — re-running without editing the Markdown produces a +byte-identical `.yjs` file (the script pins the Yjs document's `clientID`, +which is otherwise randomized per `Y.Doc` instance) — and it refuses to +write a snapshot that isn't a fixed point of the Markdown round-trip +(`docToMarkdown(markdownToDoc(x)) === x`), so a stale fixture can't get +checked in silently. + ## Conventions - New feature packs get their own `.spec.ts` next to these and diff --git a/apps/web/e2e/content.spec.ts b/apps/web/e2e/content.spec.ts new file mode 100644 index 0000000..2f06e04 --- /dev/null +++ b/apps/web/e2e/content.spec.ts @@ -0,0 +1,184 @@ +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.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); + const content = page.locator('.ProseMirror'); + await content.click(); + await page.keyboard.type('Hello content pack'); + await expect(page.getByRole('status')).toHaveText(/saved|gespeichert/i, { timeout: 10000 }); + + 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('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); + await page.getByRole('button', { 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(); +});