import { expect, test } from '@playwright/test'; import { contextForUser } from './helpers'; /** * TipTap editor pack (issue #25, modernized for live collaboration after * #36/#38 — the REST autosave and its "saved" indicator are retired; the * editor syncs over the collab server and reports a connection status). * Runs against the local dev stack (api + web + collab); no Mailpit needed. * Creates its own page per test via the api and navigates straight to * `/p/:pondSlug/:pageSlug`. Status assertions use the language-neutral * `data-status` attribute — the UI language follows the user's locale. */ const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; async function createPage( context: Awaited>, title: string, ): Promise<{ pondSlug: string; pageSlug: string }> { const ponds = await context.request.get('/api/v1/ponds'); const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal'); const created = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, { data: { title }, }); const page = await created.json(); return { pondSlug: pond.slug, pageSlug: page.slug }; } test('typing persists across reload and undo/redo work', async ({ browser }) => { const context = await contextForUser(browser, BASE_URL, 'fixture-user'); const { pondSlug, pageSlug } = await createPage(context, `E2E Editor ${Date.now()}`); const page = await context.newPage(); await page.goto(`/p/${pondSlug}/${pageSlug}`); await page.getByRole('button', { name: /edit|bearbeiten/i }).click(); const status = page.locator('.editor-connection'); await expect(status).toHaveAttribute('data-status', 'connected', { timeout: 10000 }); const content = page.locator('.ProseMirror'); await expect(content).toHaveAttribute('contenteditable', 'true'); await content.click(); await page.keyboard.type('Hello editor'); await expect(content).toContainText('Hello editor'); await page.reload(); await page.getByRole('button', { name: /edit|bearbeiten/i }).click(); await expect(content).toHaveAttribute('contenteditable', 'true'); await expect(content).toContainText('Hello editor'); await content.click(); await page.keyboard.type(' more'); await expect(content).toContainText('Hello editor more'); await page.keyboard.press('ControlOrMeta+z'); await expect(content).toContainText('Hello editor'); await expect(content).not.toContainText('Hello editor more'); await page.keyboard.press('ControlOrMeta+y'); await expect(content).toContainText('Hello editor more'); await context.close(); }); test('gap cursor reaches positions before and after a lone table (issue #335)', async ({ browser, }) => { const context = await contextForUser(browser, BASE_URL, 'fixture-user'); const { pondSlug, pageSlug } = await createPage(context, `E2E Gapcursor ${Date.now()}`); const page = await context.newPage(); await page.goto(`/p/${pondSlug}/${pageSlug}`); await page.getByRole('button', { name: /edit|bearbeiten/i }).click(); const status = page.locator('.editor-connection'); await expect(status).toHaveAttribute('data-status', 'connected', { timeout: 10000 }); const content = page.locator('.ProseMirror'); await content.click(); await page.getByRole('button', { name: /insert table|tabelle einfügen/i }).click(); await expect(content.locator('table')).toBeVisible(); // Inserting into the empty page replaces the placeholder paragraph — the // table really is the only block, which is the situation of issue #335. await expect(content.locator(':scope > p')).toHaveCount(0); // Keyboard only: ArrowUp from the first cell lands on the gap cursor // before the table; typing there materializes a paragraph. await content.locator('th').first().click(); await page.keyboard.press('ArrowUp'); await page.keyboard.type('above'); await expect(content.locator(':scope > :first-child')).toHaveText('above'); // Same for the position after the table. await content.locator('td').last().click(); await page.keyboard.press('ArrowDown'); await page.keyboard.type('below'); await expect(content.locator(':scope > :last-child')).toHaveText('below'); await context.close(); }); test('cells can be merged and split from the toolbar (issue #337)', async ({ browser }) => { const context = await contextForUser(browser, BASE_URL, 'fixture-user'); const { pondSlug, pageSlug } = await createPage(context, `E2E MergeSplit ${Date.now()}`); const page = await context.newPage(); await page.goto(`/p/${pondSlug}/${pageSlug}`); await page.getByRole('button', { name: /edit|bearbeiten/i }).click(); const status = page.locator('.editor-connection'); await expect(status).toHaveAttribute('data-status', 'connected', { timeout: 10000 }); const content = page.locator('.ProseMirror'); await content.click(); await page.getByRole('button', { name: /insert table|tabelle einfügen/i }).click(); await expect(content.locator('table')).toBeVisible(); const mergeButton = page.getByRole('button', { name: /merge cells|zellen verbinden/i }); const splitButton = page.getByRole('button', { name: /split cell|zelle teilen/i }); await expect(mergeButton).toBeDisabled(); await expect(splitButton).toBeDisabled(); // Extending the selection across the cell border turns it into a cell // selection (prosemirror-tables), which is what merge operates on. // Shift+Click, not Shift+ArrowRight: a keypress fired in the same tick as // the preceding click races the editor's post-click rendering and gets // dropped — no human types that fast (works fine interactively). await content.locator('td').first().click(); await content .locator('td') .nth(1) .click({ modifiers: ['Shift'] }); await expect(content.locator('.selectedCell')).toHaveCount(2); await expect(mergeButton).toBeEnabled(); await mergeButton.click(); await expect(content.locator('td[colspan="2"]')).toHaveCount(1); // Splitting the merged cell restores the row's full cell count. await content.locator('td[colspan="2"]').click(); await expect(splitButton).toBeEnabled(); await splitButton.click(); await expect(content.locator('td[colspan="2"]')).toHaveCount(0); await expect(content.locator('tr').nth(1).locator('td')).toHaveCount(3); await context.close(); }); test('edit mode hides the sidebar; leaving edit mode restores it', async ({ browser }) => { const context = await contextForUser(browser, BASE_URL, 'fixture-user'); const { pondSlug, pageSlug } = await createPage(context, `E2E Sidebar ${Date.now()}`); const page = await context.newPage(); await page.goto(`/p/${pondSlug}/${pageSlug}`); // CSS selector, not getByRole: aria-hidden removes the element from the // accessibility tree, which would make a role-based locator "disappear" // exactly when we need to assert that attribute. const sidebar = page.locator('nav.sidebar'); await expect(sidebar).toHaveAttribute('aria-hidden', 'false'); await page.getByRole('button', { name: /edit|bearbeiten/i }).click(); await expect(sidebar).toHaveAttribute('aria-hidden', 'true'); await page.getByRole('button', { name: /read|lesen/i }).click(); await expect(sidebar).toHaveAttribute('aria-hidden', 'false'); await context.close(); }); test('going offline is reported honestly and edits sync after reconnect', async ({ browser }) => { const context = await contextForUser(browser, BASE_URL, 'fixture-user'); const { pondSlug, pageSlug } = await createPage(context, `E2E Offline ${Date.now()}`); const page = await context.newPage(); await page.goto(`/p/${pondSlug}/${pageSlug}`); await page.getByRole('button', { name: /edit|bearbeiten/i }).click(); const status = page.locator('.editor-connection'); await expect(status).toHaveAttribute('data-status', 'connected', { timeout: 10000 }); const content = page.locator('.ProseMirror'); await content.click(); // Editing keeps working offline; the status tells the truth about it. await context.setOffline(true); await page.keyboard.type('offline text'); await expect(content).toContainText('offline text'); await expect(status).toHaveAttribute('data-status', 'offline', { timeout: 10000 }); // Reconnect: the provider resumes and the offline edit reaches the server — // proven by a reload, which resolves the page against the live document. await context.setOffline(false); await expect(status).toHaveAttribute('data-status', 'connected', { timeout: 20000 }); await page.reload(); await page.getByRole('button', { name: /edit|bearbeiten/i }).click(); await expect(content).toContainText('offline text'); await context.close(); });