Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 7m39s
CI / Build container images (pull_request) Successful in 4m32s
CI / Auth e2e pack (pull_request) Successful in 10m11s
CI / Import/export fidelity gate (pull_request) Successful in 57s
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 24s
CD / Smoke tests against Test (push) Has been cancelled
CD / Promote to Int (push) Has been skipped
CD / Deploy to Test (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
Tab used to fall through to the browser's focus navigation everywhere. Inside tables it now moves cell-wise (Shift-Tab backwards) and appends a new row from the last cell, Word-style. Outside tables every branch returns false, so Tab keeps leaving the editor. Capturing Tab inside tables needs a documented way out (WCAG 2.1.2): Escape places the cursor after the table -- unlike the arrow keys, which reach the gap cursor (#335) only from the table's edge cells, it works from every cell, including from a cell selection. When no textblock follows the table it falls back to the gap cursor position. The mechanism is announced to assistive tech via an aria-describedby hint on the editor surface (visually hidden, de+en). e2e: cell round trip per Tab/Shift-Tab with typed markers, row append from the last cell, and the full keyboard-only exit (Escape, then Tab leaves the editor). The table specs now settle briefly after the insert -- right after it the collab sync can swallow a click's selection update, which had the markers landing in stale selections. Closes #338 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aoPvnakfBP28nAfijgUY9
263 lines
12 KiB
TypeScript
263 lines
12 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);
|
|
// Right after the insert the collab sync can still swallow a click's
|
|
// selection update; interact only against a settled editor (established
|
|
// pattern, see a11y.spec.ts). The typed markers below verify each click
|
|
// really placed the cursor where the locator points.
|
|
await page.waitForTimeout(500);
|
|
|
|
// 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.type('in');
|
|
await expect(content.locator('th').first()).toHaveText('in');
|
|
await page.keyboard.press('ArrowUp');
|
|
await expect(page.locator('.ProseMirror-gapcursor')).toHaveCount(1);
|
|
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.type('z');
|
|
await expect(content.locator('td').last()).toHaveText('z');
|
|
await page.keyboard.press('ArrowDown');
|
|
await expect(page.locator('.ProseMirror-gapcursor')).toHaveCount(1);
|
|
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();
|
|
// Settle before clicking into cells — see the gap cursor test.
|
|
await page.waitForTimeout(500);
|
|
|
|
// 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('Tab navigates table cells, extends the table, and never traps focus (issue #338)', async ({
|
|
browser,
|
|
}) => {
|
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
|
const { pondSlug, pageSlug } = await createPage(context, `E2E TableTab ${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();
|
|
const rows = content.locator('tr');
|
|
await expect(rows).toHaveCount(3);
|
|
// Settle before clicking into cells — see the gap cursor test.
|
|
await page.waitForTimeout(500);
|
|
|
|
// Tab moves to the next cell, Shift+Tab back. Typed markers prove where
|
|
// the cursor really is (the typing assertions also settle the editor
|
|
// between keypresses — see the merge test on click/key races).
|
|
await content.locator('th').first().click();
|
|
await page.keyboard.type('one');
|
|
await expect(content.locator('th').first()).toHaveText('one');
|
|
await page.keyboard.press('Tab');
|
|
await page.keyboard.type('two');
|
|
await expect(content.locator('th').nth(1)).toHaveText('two');
|
|
await page.keyboard.press('Shift+Tab');
|
|
await page.keyboard.type('back');
|
|
await expect(content.locator('th').first()).toContainText('back');
|
|
|
|
// Tab in the last cell appends a row and moves into it (Word behavior).
|
|
const lastCell = rows.nth(2).locator('td').nth(2);
|
|
await lastCell.click();
|
|
await page.keyboard.type('z');
|
|
await expect(lastCell).toHaveText('z');
|
|
await page.keyboard.press('Tab');
|
|
await expect(rows).toHaveCount(4);
|
|
await page.keyboard.type('new');
|
|
await expect(rows.nth(3).locator('td').first()).toHaveText('new');
|
|
|
|
// No keyboard trap (WCAG 2.1.2): Escape works from EVERY cell (the gap
|
|
// cursor is only reachable per arrow key from edge cells) and places the
|
|
// cursor after the table; once outside, Tab leaves the editor entirely.
|
|
// The mechanism is announced via the editor's aria-describedby hint.
|
|
await page.keyboard.press('Escape');
|
|
await expect(page.locator('.ProseMirror-gapcursor')).toHaveCount(1);
|
|
await page.keyboard.press('Tab');
|
|
await expect(content).not.toBeFocused();
|
|
|
|
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();
|
|
});
|