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 { 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. test('a public-API content replacement converges an open editor (#104)', 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: `API Replace ${Date.now()}` }, }); const { slug } = await created.json(); // Expose the surface: instance switch (Site Admin) + pond opt-in (owner), // then mint a write token for the owner. await admin.request.patch('/api/v1/admin/settings', { data: { 'api.enabled': true } }); await owner.request.patch(`/api/v1/ponds/${pond.id}`, { data: { apiEnabled: true } }); const minted = await owner.request.post('/api/v1/users/me/api-tokens', { data: { name: `collab-e2e-${Date.now()}`, scope: 'write' }, }); const { token } = await minted.json(); const pageA = await openEditor(owner, pond.slug, slug); const editorA = pageA.locator('.ProseMirror'); await editorA.click(); await pageA.keyboard.type('typed live before the API replace'); await expect(editorA).toContainText('typed live before the API replace'); // Replace the whole content through the public API: the change travels // over the collab-owned restore path, so the open editor converges. const replaced = await owner.request.patch(`/api/public/v1/ponds/${pond.slug}/pages/${slug}`, { headers: { Authorization: `Bearer ${token}` }, data: { markdown: 'Replaced through the public API.' }, }); expect(replaced.ok()).toBeTruthy(); await expect(editorA).toContainText('Replaced through the public API.', { timeout: 15000 }); await expect(editorA).not.toContainText('typed live before the API replace'); await owner.close(); await admin.close(); });