dorfteich/apps/web/e2e/collab.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

97 lines
4.2 KiB
TypeScript

import { expect, test } from '@playwright/test';
import type { BrowserContext, Page } from '@playwright/test';
import { contextForUser } from './helpers';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
async function personalPond(context: BrowserContext): 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 };
}
/** Opens the page in edit mode and waits for the live connection to be up. */
async function openEditor(context: BrowserContext, pondSlug: string, slug: string): Promise<Page> {
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${slug}`);
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true');
await expect(page.locator('.editor-connection')).toHaveAttribute('data-status', 'connected', {
timeout: 15000,
});
return page;
}
test('two browsers editing one page converge (the milestone headline)', async ({ browser }) => {
// fixture-user owns the pond; fixture-admin is a site admin and may modify it,
// so both receive a read-write collab token under the interim access model.
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
const pond = await personalPond(owner);
const created = await owner.request.post(`/api/v1/ponds/${pond.id}/pages`, {
data: { title: `Collab Converge ${Date.now()}` },
});
const { slug } = await created.json();
const pageA = await openEditor(owner, pond.slug, slug);
const pageB = await openEditor(admin, pond.slug, slug);
const editorA = pageA.locator('.ProseMirror');
const editorB = pageB.locator('.ProseMirror');
await editorA.click();
await pageA.keyboard.type('AAA from owner ');
await expect(editorB).toContainText('AAA from owner', { timeout: 10000 });
await editorB.click();
await pageB.keyboard.type('BBB from admin ');
await expect(editorA).toContainText('BBB from admin', { timeout: 10000 });
await owner.close();
await admin.close();
});
test('offline edits continue locally and sync on reconnect', async ({ browser }) => {
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
const pond = await personalPond(owner);
const created = await owner.request.post(`/api/v1/ponds/${pond.id}/pages`, {
data: { title: `Collab Offline ${Date.now()}` },
});
const { slug } = await created.json();
const pageA = await openEditor(owner, pond.slug, slug);
const pageB = await openEditor(admin, pond.slug, slug);
const editorA = pageA.locator('.ProseMirror');
const editorB = pageB.locator('.ProseMirror');
// Admin drops offline: the indicator reflects it and editing stays local.
await admin.setOffline(true);
await expect(pageB.locator('.editor-connection')).toHaveAttribute('data-status', 'offline', {
timeout: 10000,
});
await editorB.click();
await pageB.keyboard.type('written while offline ');
await expect(editorB).toContainText('written while offline');
// The owner, still online, has not received the offline edit.
await expect(editorA).not.toContainText('written while offline');
// Back online: the provider reconnects (re-fetching a fresh token) and the
// offline edit converges to the other participant.
await admin.setOffline(false);
await expect(pageB.locator('.editor-connection')).toHaveAttribute('data-status', 'connected', {
timeout: 20000,
});
await expect(editorA).toContainText('written while offline', { timeout: 20000 });
await owner.close();
await admin.close();
});
// Read-only participants (live changes visible, typing blocked, reason shown)
// need a real read-only grant to obtain a `ro` collab token. Under the interim
// access model seeing and modifying coincide, so no user is issued a `ro` token
// yet — the `ro` UI is implemented but only becomes reachable with #53, where
// this live assertion belongs.
test.fixme('read-only participants see changes but cannot type (needs #53)', () => {});