All checks were successful
CD / Promote to Int (push) Successful in 9s
CI / Auth e2e pack (push) Successful in 3m56s
CI / Import/export fidelity gate (push) Successful in 43s
CI / Lint, typecheck, test (push) Successful in 2m53s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m9s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Deploy to Test (push) Successful in 12s
The powerful end of the plugin spectrum (ADR 0008 extension point `block`): - Shared schema: the reserved `plugin_block` node — a block atom carrying pluginId, blockType, and the block data as a JSON object. Its DOM shape round-trips the full state in data attributes (clipboard-safe), markdown maps to a reserved fence (```dorfteich-plugin <plugin>/<type> + data JSON body, fence-escalated when the payload contains backticks), and the content-cache HTML renders a data-carrying neutral placeholder until the export fallbacks land (#79). - Editor: a React NodeView hosts the #73 sandbox — render lifecycle on mount, an edit affordance switching the frame to the plugin's edit mode, and the blockData capability persisting through node attrs (a normal editor transaction, so Yjs replicates it; writes are refused on read-only editors, and the plugin's own attr echo is suppressed so its edit UI never resets mid-typing). Collaborator changes re-invoke the current lifecycle, keeping frames live. The page surface (ids, openPage) flows through a React context like the wikilink pattern; the toolbar gets an insert picker fed from the active code plugins' block extension points. - Fallback: GET /plugins/:id/fallback resolves the manifest fallback from the stored snapshot — it survives uninstall as a tombstone, image fallbacks degrade to neutral once assets are gone. Signed-in only. - e2e plugin-blocks.spec.ts covers all four acceptance criteria: insert → edit → reload round-trip, live two-user collab, disable → fallback → re-enable without document mutation, and copy/paste within and across pages (the markdown clipboard carries the reserved fence). getBlock (cross-page block embedding) stays deferred as in #74: the schema has no per-block ids yet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
265 lines
11 KiB
TypeScript
265 lines
11 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('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();
|
|
});
|