dorfteich/apps/web/e2e/content.spec.ts
Claude Opus 4.8 7d04c0b594
All checks were successful
CD / Build and push images (push) Successful in 2m59s
CI / Lint, typecheck, test (push) Successful in 2m0s
CI / Auth e2e pack (push) Successful in 2m10s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
Switch the editor to live collaboration (#36)
The editor now edits over the collaboration server instead of REST — the
moment Dorfteich becomes collaborative (ADR 0003, realtime-collaboration.md).

Web:
- New `useCollabProvider` hook binds a page's Y.Doc to a HocuspocusProvider.
  The document loads and persists through the collab server (#35); there is
  no REST autosave and no REST seed (a REST seed would fork the doc lineage
  and duplicate content). The collab token is fetched lazily on every
  (re)connect via an async token function, so an expired token is replaced
  transparently and a permission change takes effect on the next reconnect.
- Connection-state UI replaces the save indicator: connecting / connected
  ("Live") / reconnecting / offline, driven by provider status + navigator
  online state. Read-only (`ro`) tokens make the editor non-editable with a
  reason; an oversize-document stateless error (#35) surfaces a banner.
- Removed `use-page-autosave.ts` and `yjs-base64.ts` (no longer used).

API:
- `PUT /pages/:id/state` is retired and returns 410 `rest_state_write_retired`
  (the criterion deferred here from #35). Collab is the sole writer of page
  state; the read paths remain. Removed the now-dead `saveState` service.

e2e / CI:
- The e2e static server proxies the `/collab` WebSocket upgrade (mirrors
  Caddy); vite dev gains a `/collab` ws proxy. The auth-e2e CI job starts the
  collab server and runs a new collab pack.
- New `collab.spec.ts`: two browsers converge on one page (the milestone
  headline), and offline edits continue locally and sync on reconnect. The
  read-only live assertion is a `test.fixme` until real read-only grants
  exist — under interim access seeing and modifying coincide, so no `ro`
  token is issued yet (that arrives with #53). Reworked the api/trash tests
  and the content editor-basics test off the retired REST write path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-08 18:00:19 +02:00

190 lines
7.9 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.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('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();
});