import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { expect, test } from '@playwright/test'; import type { BrowserContext, Page } from '@playwright/test'; import { contextForUser } from './helpers'; /** * Mermaid reference plugin end to end (issue #78): the code-block proof. * Installs the real package (built into packages/plugins/mermaid/dist) and * drives the acceptance criteria — a flowchart renders and survives reload * and collaboration, syntax errors stay inline without breaking render mode, * and rendering triggers no request leaving the origin (the bundled library * is the only executable code; the frame CSP forbids the rest). */ const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; function mermaidZip(): Buffer { const here = dirname(fileURLToPath(import.meta.url)); return readFileSync(join(here, '../../../packages/plugins/mermaid/dist/mermaid-1.0.0.zip')); } async function installAsRequired(admin: BrowserContext): Promise { await admin.request.patch('/api/v1/admin/plugins/mermaid/mode', { data: { mode: 'disabled' } }); await admin.request.delete('/api/v1/admin/plugins/mermaid'); const installed = await admin.request.post('/api/v1/admin/plugins', { multipart: { file: { name: 'mermaid.zip', mimeType: 'application/zip', buffer: mermaidZip() }, }, }); expect(installed.status(), await installed.text()).toBe(201); const mode = await admin.request.patch('/api/v1/admin/plugins/mermaid/mode', { data: { mode: 'required' }, }); expect(mode.status(), await mode.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 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; } function frameBody(page: Page) { return page.frameLocator('.plugin-block iframe').locator('body'); } async function insertDiagram(page: Page): Promise { await page.locator('.editor-toolbar__block-select').selectOption('mermaid/diagram'); await expect(page.locator('.plugin-block .plugin-block__surface')).toHaveAttribute( 'data-state', 'ready', { timeout: 15000 }, ); } test('a flowchart renders, survives reload, and makes no off-origin request', async ({ browser, }) => { const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); await installAsRequired(admin); const pond = await personalPond(admin); const created = await ( await admin.request.post(`/api/v1/ponds/${pond.id}/pages`, { data: { title: `E2E Mermaid ${Date.now()}` }, }) ).json(); const page = await openEditor(admin, pond.slug, created.slug); const origin = new URL(BASE_URL).origin; const offOrigin: string[] = []; page.on('request', (request) => { const url = request.url(); if (!url.startsWith(origin) && !url.startsWith('data:') && !url.startsWith('blob:')) { offOrigin.push(url); } }); await insertDiagram(page); await expect(frameBody(page).locator('p')).toBeVisible({ timeout: 10000 }); // empty hint // Enter edit mode, type a flowchart, watch the live preview render. await page.locator('.plugin-block__bar button').click(); const textarea = frameBody(page).locator('textarea'); await textarea.fill('graph TD; Start-->End;'); await expect(frameBody(page).locator('.dt-mermaid__preview svg')).toBeVisible({ timeout: 10000, }); // Back to render mode: the diagram SVG is the surface. await page.locator('.plugin-block__bar button').click(); await expect(frameBody(page).locator('svg')).toBeVisible({ timeout: 10000 }); // Persisted through the document: a fresh load renders the diagram again. await page.reload(); await expect(frameBody(page).locator('svg')).toBeVisible({ timeout: 15000 }); expect(offOrigin, `off-origin requests: ${offOrigin.join(', ')}`).toEqual([]); await admin.close(); }); test('syntax errors show inline in edit mode and never break render mode', async ({ browser }) => { const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); await installAsRequired(admin); const pond = await personalPond(admin); const created = await ( await admin.request.post(`/api/v1/ponds/${pond.id}/pages`, { data: { title: `E2E Mermaid Err ${Date.now()}` }, }) ).json(); const page = await openEditor(admin, pond.slug, created.slug); await insertDiagram(page); await page.locator('.plugin-block__bar button').click(); // A valid diagram first, so a good snapshot exists. const textarea = frameBody(page).locator('textarea'); await textarea.fill('graph TD; A-->B;'); await expect(frameBody(page).locator('.dt-mermaid__preview svg')).toBeVisible({ timeout: 10000, }); // Break the source: the error shows inline, the last preview stays. await textarea.fill('this is not mermaid at all'); await expect(frameBody(page).locator('.dt-mermaid__error')).toBeVisible({ timeout: 10000 }); await expect(frameBody(page).locator('.dt-mermaid__preview svg')).toBeVisible(); // Render mode falls back to the stored snapshot instead of breaking. await page.locator('.plugin-block__bar button').click(); await expect(frameBody(page).locator('svg')).toBeVisible({ timeout: 10000 }); await admin.close(); }); test('a collaborator sees the diagram appear 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 ( await owner.request.post(`/api/v1/ponds/${pond.id}/pages`, { data: { title: `E2E Mermaid Collab ${Date.now()}` }, }) ).json(); const pageA = await openEditor(owner, pond.slug, created.slug); const pageB = await openEditor(admin, pond.slug, created.slug); await insertDiagram(pageA); await pageA.locator('.plugin-block__bar button').click(); await frameBody(pageA).locator('textarea').fill('graph TD; Live-->Sync;'); await expect(frameBody(pageA).locator('.dt-mermaid__preview svg')).toBeVisible({ timeout: 10000, }); // The block data replicates through Yjs; the collaborator's frame renders. await expect(frameBody(pageB).locator('svg')).toBeVisible({ timeout: 15000 }); await owner.close(); await admin.close(); });