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