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 { 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 { 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 { 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 { 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('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(); });