dorfteich/apps/web/e2e/plugin-blocks.spec.ts
Claude Fable 5 db0e563f95
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m39s
CI / Build container images (pull_request) Successful in 1m29s
CI / Auth e2e pack (pull_request) Successful in 7m22s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 18s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 11s
Release / Build release images and notes (push) Successful in 1m9s
CI / Lint, typecheck, test (push) Successful in 4m48s
CI / Build container images (push) Has been skipped
Release / Release-candidate operations QA (push) Successful in 52s
Prod deploy / Deploy the released images to Prod (push) Successful in 18s
CI / Auth e2e pack (push) Successful in 7m2s
CI / Import/export fidelity gate (push) Successful in 54s
#160: Plugin-Block — Bearbeiten-Knopf nach Moduswechsel wieder da
Die NodeView las editor.isEditable nur beim Mount. Die Seite mountet
immer im Lesemodus, und der Moduswechsel läuft über setEditable() —
das emittiert in TipTap nur ein update-Event, aber keine Transaction,
weshalb React-NodeViews nie neu rendern (geprüft in @tiptap/react
3.27.1: updateProps feuert nur bei Node-Änderung und Selektions-
Wechsel). Folge: die Block-Leiste blieb ohne Bearbeiten-Knopf, für
alle Block-Plugins (ChordPro, Mermaid, Excalidraw, draw.io).

Fix: useEditorEditable abonniert das update-Event und liest
isEditable reaktiv; verliert die Seite die Editierbarkeit, während
die Editier-UI des Plugins offen ist, fällt der Block auf render
zurück (der Lesemodus blendet die Leiste aus, es gäbe sonst keinen
Weg mehr heraus). Damit stimmt auch die setData-Schreibrecht-Prüfung
(editableRef) wieder.

Regressionstest im plugin-blocks-Pack: Block existiert bereits,
Seite lädt im Lesemodus, Wechsel in den Edit-Modus zeigt den Knopf
(fiel ohne Fix reproduzierbar durch); Rückweg Lesemodus→render
mitgeprüft. Die bisherigen Tests fügten Blöcke immer erst nach dem
Moduswechsel ein und konnten den Fall nicht sehen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:54:50 +02:00

306 lines
13 KiB
TypeScript

import { strToU8, zipSync } from 'fflate';
import { expect, test } from '@playwright/test';
import type { BrowserContext, Page } from '@playwright/test';
import { contextForUser } from './helpers';
import { BLOCK_PLUGIN_SOURCE, blockManifest } from './plugin-fixtures';
/**
* Block plugins end to end (issue #76): the fixture block plugin (see
* plugin-fixtures.ts) is installed as `required`, then driven through the
* acceptance criteria — insert → edit → reload round-trip, live collab
* updates, disable→fallback→re-enable without document mutation, and
* copy/paste within and across pages. Selectors are language-neutral
* (classes and iframe content, not labels).
*/
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
const PLUGIN_ID = 'e2e-block';
const PLUGIN_NAME = 'E2E Block';
function pluginZip(): Buffer {
return Buffer.from(
zipSync({
'manifest.json': strToU8(JSON.stringify(blockManifest(PLUGIN_ID, PLUGIN_NAME))),
'plugin.js': strToU8(BLOCK_PLUGIN_SOURCE),
}),
);
}
async function installAsRequired(admin: BrowserContext): Promise<void> {
await admin.request.patch(`/api/v1/admin/plugins/${PLUGIN_ID}/mode`, {
data: { mode: 'disabled' },
});
await admin.request.delete(`/api/v1/admin/plugins/${PLUGIN_ID}`);
const installed = await admin.request.post('/api/v1/admin/plugins', {
multipart: {
file: { name: `${PLUGIN_ID}.zip`, mimeType: 'application/zip', buffer: pluginZip() },
},
});
expect(installed.status(), await installed.text()).toBe(201);
const mode = await admin.request.patch(`/api/v1/admin/plugins/${PLUGIN_ID}/mode`, {
data: { mode: 'required' },
});
expect(mode.status(), await mode.text()).toBe(200);
}
async function setMode(admin: BrowserContext, mode: string): Promise<void> {
const res = await admin.request.patch(`/api/v1/admin/plugins/${PLUGIN_ID}/mode`, {
data: { mode },
});
expect(res.status(), await res.text()).toBe(200);
}
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 };
}
async function createPage(
context: BrowserContext,
pondId: string,
title: string,
): Promise<{ id: string; slug: string }> {
const created = await context.request.post(`/api/v1/ponds/${pondId}/pages`, {
data: { title },
});
return created.json();
}
/** Opens the page in edit mode and waits for the live connection. */
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;
}
/** The rendered body of the block plugin's sandbox frame on `page`. */
function frameBody(page: Page, nth = 0) {
return page.frameLocator(`.plugin-block iframe >> nth=${nth}`).locator('body');
}
async function insertBlock(page: Page): Promise<void> {
await page.locator('.editor-toolbar__block-select').selectOption(`${PLUGIN_ID}/note`);
await expect(page.locator('.plugin-block__surface')).toHaveAttribute('data-state', 'ready', {
timeout: 10000,
});
}
test('a block round-trips: insert → edit data → reload → render', async ({ browser }) => {
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
await installAsRequired(admin);
const pond = await personalPond(admin);
const created = await createPage(admin, pond.id, `E2E Block Roundtrip ${Date.now()}`);
const page = await openEditor(admin, pond.slug, created.slug);
await insertBlock(page);
await expect(frameBody(page)).toHaveText('block:empty', { timeout: 10000 });
// The edit affordance switches the frame to edit mode; the fixture persists
// "+e" through blockData.setData (→ node attrs → Yjs document).
await page.locator('.plugin-block__bar button').click();
await expect(frameBody(page)).toHaveText('editing:+e');
await page.locator('.plugin-block__bar button').click();
await expect(frameBody(page)).toHaveText('block:+e');
// Persisted: a fresh load (read mode) renders the stored data.
await page.reload();
await expect(frameBody(page)).toHaveText('block:+e', { timeout: 15000 });
await admin.close();
});
test('the edit affordance appears when edit mode starts after load (issue #160)', async ({
browser,
}) => {
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
await installAsRequired(admin);
const pond = await personalPond(admin);
const created = await createPage(admin, pond.id, `E2E Block Stale Editable ${Date.now()}`);
// Seed a page that already carries a block (the edit round-trip persists
// "+e" into the document before the reload below).
const seeded = await openEditor(admin, pond.slug, created.slug);
await insertBlock(seeded);
await seeded.locator('.plugin-block__bar button').click();
await expect(frameBody(seeded)).toHaveText('editing:+e');
await seeded.locator('.plugin-block__bar button').click();
await expect(frameBody(seeded)).toHaveText('block:+e');
// A fresh load mounts the node view in read mode. Entering edit mode goes
// through `setEditable`, which dispatches no transaction — the bug (#160)
// was a stale `editable` from mount keeping the edit button hidden.
await seeded.reload();
await expect(frameBody(seeded)).toHaveText('block:+e', { timeout: 15000 });
await expect(seeded.locator('.plugin-block__surface')).toHaveAttribute('data-state', 'ready');
await seeded.locator('.editor-page__mode-toggle').click();
await expect(seeded.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true');
const editButton = seeded.locator('.plugin-block__bar button');
await expect(editButton).toBeVisible();
// The button is functional, not just painted.
await editButton.click();
await expect(frameBody(seeded)).toHaveText('editing:+e+e');
// Leaving page edit mode while the plugin's edit UI is open must drop the
// frame back to render — read mode has no bar to leave edit mode with.
await seeded.locator('.editor-page__mode-toggle').click();
await expect(frameBody(seeded)).toHaveText('block:+e+e');
await admin.close();
});
test('two collaborating users see block-data changes live', async ({ browser }) => {
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
await installAsRequired(admin);
const pond = await personalPond(owner);
const created = await createPage(owner, pond.id, `E2E Block Collab ${Date.now()}`);
const pageA = await openEditor(owner, pond.slug, created.slug);
const pageB = await openEditor(admin, pond.slug, created.slug);
await insertBlock(pageA);
// The block arrives at the collaborator and renders through their sandbox.
await expect(frameBody(pageB)).toHaveText('block:empty', { timeout: 15000 });
// A data change by one user re-renders the other user's frame live.
await pageA.locator('.plugin-block__bar button').click();
await expect(frameBody(pageA)).toHaveText('editing:+e');
await expect(frameBody(pageB)).toHaveText('block:+e', { timeout: 10000 });
await owner.close();
await admin.close();
});
test('disabling renders the fallback without document mutation; re-enabling restores', async ({
browser,
}) => {
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
await installAsRequired(admin);
const pond = await personalPond(admin);
const created = await createPage(admin, pond.id, `E2E Block Fallback ${Date.now()}`);
const page = await openEditor(admin, pond.slug, created.slug);
await insertBlock(page);
await page.locator('.plugin-block__bar button').click();
await expect(frameBody(page)).toHaveText('editing:+e');
// Give the collab autosave a moment to persist before reloading around the
// mode switch (the export below reads the server-side cache).
await page.locator('.plugin-block__bar button').click();
await expect(frameBody(page)).toHaveText('block:+e');
await setMode(admin, 'disabled');
await page.reload();
const fallback = page.locator('.plugin-block__fallback');
await expect(fallback).toContainText(`[${PLUGIN_NAME}]`, { timeout: 15000 });
// The document itself still carries the block with its data (no mutation):
// the markdown export keeps the reserved fence and the edited payload.
await expect
.poll(
async () => {
const md = await admin.request.get(`/api/v1/pages/${created.id}/export/markdown`);
return md.text();
},
{ timeout: 15000 },
)
.toContain('dorfteich-plugin');
const markdown = await (
await admin.request.get(`/api/v1/pages/${created.id}/export/markdown`)
).text();
expect(markdown).toContain('"text":"+e"');
// Re-enabling brings the live surface back.
await setMode(admin, 'required');
await page.reload();
await expect(frameBody(page)).toHaveText('block:+e', { timeout: 15000 });
await admin.close();
});
test('copy/paste preserves block data within and across pages', async ({ browser }) => {
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
await admin.grantPermissions(['clipboard-read', 'clipboard-write']);
await installAsRequired(admin);
const pond = await personalPond(admin);
const pageOne = await createPage(admin, pond.id, `E2E Block Copy A ${Date.now()}`);
const pageTwo = await createPage(admin, pond.id, `E2E Block Copy B ${Date.now()}`);
const page = await openEditor(admin, pond.slug, pageOne.slug);
// A text anchor above the block gives the later paste a reliable cursor
// home (clicking an atom node only ever yields a node selection).
await page.locator('.ProseMirror').click();
await page.keyboard.type('anchor');
await insertBlock(page);
await page.locator('.plugin-block__bar button').click();
await expect(frameBody(page)).toHaveText('editing:+e');
await page.locator('.plugin-block__bar button').click();
await expect(frameBody(page)).toHaveText('block:+e');
// Select the block node (clicks inside the sandbox iframe never reach the
// editor — the bar is host DOM) and copy it: the markdown clipboard
// serializer puts the reserved fence with the data JSON on text/plain.
await page.locator('.plugin-block__bar').click();
await expect(page.locator('.plugin-block')).toHaveClass(/plugin-block--selected/);
// Synthetic copy event: ProseMirror serializes the node selection into the
// event's DataTransfer (native clipboard keys need editor focus, which the
// non-editable bar click does not grant in headless Chromium).
const clipboardText = await page.evaluate(() => {
const dataTransfer = new DataTransfer();
document.querySelector('.ProseMirror')!.dispatchEvent(
new ClipboardEvent('copy', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true,
}),
);
return dataTransfer.getData('text/plain');
});
expect(clipboardText).toContain('dorfteich-plugin e2e-block/note');
expect(clipboardText).toContain('"text":"+e"');
// Paste within the same page (synthetic event — headless Chromium does not
// feed the real clipboard into a keyboard paste). ArrowRight first: pasting
// onto the still-selected node would replace it instead of adding one.
const pasteMarkdown = async () => {
await page.evaluate((text) => {
const dataTransfer = new DataTransfer();
dataTransfer.setData('text/plain', text);
document.querySelector('.ProseMirror')!.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true,
}),
);
}, clipboardText);
};
// Park the cursor in the anchor text (a real text click focuses the editor
// and clears the node selection — pasting onto the selected node would
// replace it instead of adding one), then paste.
await page.locator('.ProseMirror p', { hasText: 'anchor' }).click();
await page.keyboard.press('End');
await pasteMarkdown();
await expect(page.locator('.plugin-block')).toHaveCount(2);
await expect(frameBody(page, 1)).toHaveText('block:+e', { timeout: 10000 });
// Paste across pages of the same pond.
await page.goto(`/p/${pond.slug}/${pageTwo.slug}`);
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
const editor = page.locator('.ProseMirror');
await expect(editor).toHaveAttribute('contenteditable', 'true');
await editor.click();
await pasteMarkdown();
await expect(frameBody(page)).toHaveText('block:+e', { timeout: 10000 });
await admin.close();
});