dorfteich/apps/web/e2e/collab.spec.ts
Claude Opus 4.8 9d288b2ad0
All checks were successful
CD / Build and push images (push) Successful in 3m4s
CI / Lint, typecheck, test (push) Successful in 2m27s
CI / Auth e2e pack (push) Successful in 3m6s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 11s
Wire real permissions into collab tokens and revocation (#53)
Live editing now obeys the same rules as REST: the collab-token mode comes
from the shared grant resolution, anonymous visitors can join public pages,
and revoking write access flips a running session to read-only within
seconds.

- Anonymous public tokens: `GET /pages/:id/collab-token` is `@Public()` but
  still permission-guarded, so a logged-out visitor gets an `ro` token where
  a `public` grant makes the page readable (404 otherwise). The token's
  `userId` is nullable (shared schema + collab context) for anonymous
  subjects.
- Prompt revocation: the pond-level NOTIFY (#39) now also fires on label
  tree/assignment changes (LabelsService move/remove/assign/unassign), and
  the collab server closes the *actual* WebSocket instead of only sending an
  application-level close message. Hocuspocus' `closeConnections` leaves the
  socket open so the client only re-checks on its ~30s message timeout;
  `closeDocumentConnections` drops the socket so the client reconnects and
  re-authenticates with a freshly-resolved token at once — the "within
  seconds" downgrade the milestone promises.
- Tests: the #52 fixture matrix gains anonymous cases (public grant → `ro`,
  none → 404); a collab db test proves an editor downgraded to reader goes
  read-only on reconnect (its post-downgrade edits no longer reach a peer);
  a new browser `collab-permissions` pack covers the read-only participant
  and the live downgrade end to end (new plain `fixture-editor` account).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-09 18:48:42 +02:00

127 lines
5.4 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();
});
test('remote carets and the presence strip reflect participants (#37)', async ({ browser }) => {
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
const ownerMe = (await (await owner.request.get('/api/v1/auth/me')).json()) as {
displayName: string;
};
const pond = await personalPond(owner);
const created = await owner.request.post(`/api/v1/ponds/${pond.id}/pages`, {
data: { title: `Collab Presence ${Date.now()}` },
});
const { slug } = await created.json();
const pageA = await openEditor(owner, pond.slug, slug);
const pageB = await openEditor(admin, pond.slug, slug);
// Both participants appear in each presence strip.
await expect(pageA.locator('.presence-avatar')).toHaveCount(2, { timeout: 10000 });
await expect(pageB.locator('.presence-avatar')).toHaveCount(2, { timeout: 10000 });
// The owner's cursor shows up as a named caret in the admin's editor.
await pageA.locator('.ProseMirror').click();
await pageA.keyboard.type('cursor is here');
await expect(pageB.locator('.collab-caret__label')).toHaveText(ownerMe.displayName, {
timeout: 10000,
});
// Disconnecting the owner drops them from the admin's presence strip.
await owner.close();
await expect(pageB.locator('.presence-avatar')).toHaveCount(1, { timeout: 15000 });
await admin.close();
});
// Read-only participants (live changes visible, typing blocked) and the live
// read-write→read-only downgrade need real grants, so they live in the
// `collab-permissions` pack (issue #53) alongside the grant setup they require.