All checks were successful
CI / Lint, typecheck, test (push) Successful in 2m55s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m14s
CD / Deploy to Test (push) Successful in 10s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Successful in 10s
CI / Import/export fidelity gate (push) Successful in 45s
CI / Auth e2e pack (push) Successful in 4m50s
The end-to-end proof of the code-block path: packages/plugins/mermaid
bundles the mermaid library (esbuild, ~3.4 MB unpacked — well under the
20 MiB install gate) so diagrams render entirely inside the sandbox; the
frame CSP forbids any network request (pinned by the e2e's off-origin
request assertion).
- Block data is `{ source, svg }`: the source text is the document of
record, `svg` the last successfully rendered snapshot — persisted
together on every good preview, so office/PDF exports can show the
diagram without executing anything (#79).
- Edit mode: source textarea with a debounced live preview and inline
error display; a failing source still persists (typed text never lost),
paired with the last good snapshot.
- Render mode: renders the stored source; if that stops rendering, it
falls back to the stored snapshot with a "stale" note — a bad edit
never breaks render mode.
- mermaid leaves its scratch element (and, on parse errors, an error SVG)
on document.body — the render helper removes both, so the surface only
shows what the plugin inserts.
- e2e mermaid.spec.ts: flowchart renders + survives reload with zero
off-origin requests, inline syntax errors with intact render mode, and
a collaborator sees the diagram appear live. Wired into CI.
- seed.ts now heals a missing owner-admin grant on existing personal
ponds: a dev database shared with the test suites can lose it to a
cleanup, and the seed's contract is "idempotent", not "first run only".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
175 lines
6.9 KiB
TypeScript
175 lines
6.9 KiB
TypeScript
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<void> {
|
|
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<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;
|
|
}
|
|
|
|
function frameBody(page: Page) {
|
|
return page.frameLocator('.plugin-block iframe').locator('body');
|
|
}
|
|
|
|
async function insertDiagram(page: Page): Promise<void> {
|
|
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();
|
|
});
|