dorfteich/apps/web/e2e/collab.spec.ts
Claude Opus 4.8 63fe6af6b0
All checks were successful
CD / Build and push images (push) Successful in 2m54s
CI / Lint, typecheck, test (push) Successful in 1m58s
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 1m15s
CD / Promote to Int (push) Successful in 11s
Add remote cursors and a presence strip (#37)
Seeing other participants live (ADR 0003/0004, realtime-collaboration.md
§Awareness):

- The collaboration-caret extension renders remote carets and selections
  with a name flag and a per-user colour. Colours come from a small,
  hand-picked palette hashed by user id (FNV-1a), so they are stable across
  sessions; a unit test asserts each palette colour clears WCAG AA contrast
  (4.5:1) against the white label text.
- A presence strip at the top of the page shows an avatar (initials) per
  connected participant, deduplicated by user id, with an overflow count.
  Read-only participants appear in the strip (with a marker) but broadcast
  no caret — the caret render suppresses read-only users — so the same
  awareness feed drives both cursors and presence. Own identity (id +
  display name) comes from the auth context into the awareness `user` field.
- Presence updates on every awareness change, so a disconnect drops the
  participant within seconds.

The collab e2e pack gains a test: two browsers see each other in the
presence strip, one participant's named caret appears in the other's editor,
and disconnecting removes them. Validated locally against the full stack.
de + en strings and cursor/presence styles added.

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

130 lines
5.6 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, 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)', () => {});