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
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>
306 lines
13 KiB
TypeScript
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();
|
|
});
|