dorfteich/apps/web/e2e/editor.spec.ts
Claude Fable 5 3bf9363c34
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 7m18s
CI / Build container images (pull_request) Successful in 4m41s
CI / Auth e2e pack (pull_request) Successful in 10m4s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Successful in 33s
CI / Lint, typecheck, test (push) Has been cancelled
Merge and split table cells (#337)
prosemirror-tables already ships mergeCells/splitCell and the schema
(tableNodes) already carries colspan/rowspan -- only the controls were
missing. Adds the two commands, toolbar buttons whose enabled state
follows the selection (merge needs a multi-cell selection, split a
merged cell), and de+en labels.

Both render paths now carry the spans: docToHtml emits colspan/rowspan
(read mode, exports via the HTML path), and the markdown serializer pads
a colspan with empty cells so every row keeps the table's column count
-- rowspan stays lossy there, GFM cannot express it.

e2e drives merge and split through the toolbar; the cell selection is
made per Shift+Click because a keypress in the same tick as the
preceding click races the editor's post-click rendering (keyboard cell
selection itself works, verified interactively with a settled editor).

Closes #337

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aoPvnakfBP28nAfijgUY9
2026-08-15 21:32:14 +02:00

195 lines
8.5 KiB
TypeScript

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<ReturnType<typeof contextForUser>>,
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();
});