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'; /** * pageTool plugins end to end (issue #77): installs the real `toc` and * `page-index` reference packages (built by `pnpm build` into * packages/plugins//dist/-.zip) and drives the acceptance * criteria — live outline updates after the persistence debounce, scroll on * heading click, permission-filtered page index, navigation via `ui.openPage`, * and both surfaces working as panel tools and embedded blocks. */ const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; /** Generous ceiling over collab persistence debounce (2 s) + the toc's 5 s poll. */ const OUTLINE_TIMEOUT = 20000; function referenceZip(id: string): Buffer { const here = dirname(fileURLToPath(import.meta.url)); return readFileSync(join(here, `../../../packages/plugins/${id}/dist/${id}-1.0.0.zip`)); } async function installAsRequired(admin: BrowserContext, id: string): Promise { await admin.request.patch(`/api/v1/admin/plugins/${id}/mode`, { data: { mode: 'disabled' } }); await admin.request.delete(`/api/v1/admin/plugins/${id}`); const installed = await admin.request.post('/api/v1/admin/plugins', { multipart: { file: { name: `${id}.zip`, mimeType: 'application/zip', buffer: referenceZip(id) }, }, }); expect(installed.status(), await installed.text()).toBe(201); const mode = await admin.request.patch(`/api/v1/admin/plugins/${id}/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 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(); } 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; } /** Opens the page-tools panel and expands one tool, returning its frame body. */ async function openTool(page: Page, toolKey: string) { await page.locator('.editor-shell__page-tools-toggle').click(); await page.locator(`.page-tools__tool[data-tool="${toolKey}"] .page-tools__toggle`).click(); const host = page.locator(`.page-tools__tool[data-tool="${toolKey}"] .plugin-frame-host`); await expect(host).toHaveAttribute('data-state', 'ready', { timeout: 10000 }); return page.frameLocator(`.page-tools__tool[data-tool="${toolKey}"] iframe`).locator('body'); } test('toc panel: outline appears, follows live edits, and click scrolls', async ({ browser }) => { const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); await installAsRequired(admin, 'toc'); const pond = await personalPond(admin); const created = await createPage(admin, pond.id, `E2E Toc ${Date.now()}`); const page = await openEditor(admin, pond.slug, created.slug); const editor = page.locator('.ProseMirror'); await editor.click(); await page.keyboard.type('# Alpha heading'); await page.keyboard.press('Enter'); // Enough body text that the second heading sits below the fold. for (let i = 0; i < 40; i += 1) { await page.keyboard.type(`filler paragraph ${i}`); await page.keyboard.press('Enter'); } await page.keyboard.type('## Omega heading'); const toc = await openTool(page, 'toc/toc'); await expect(toc).toContainText('Alpha heading', { timeout: OUTLINE_TIMEOUT }); await expect(toc).toContainText('Omega heading', { timeout: OUTLINE_TIMEOUT }); // Live heading edits arrive after the persistence debounce + poll. await editor.click(); await page.keyboard.press('ControlOrMeta+End'); await page.keyboard.press('Enter'); await page.keyboard.type('## Freshly added'); await expect(toc).toContainText('Freshly added', { timeout: OUTLINE_TIMEOUT }); // Clicking an entry scrolls the host content to that heading. Typing left // the view at the bottom — first jump to the top heading, then to Omega. const alpha = editor.locator('h1', { hasText: 'Alpha heading' }); const omega = editor.locator('h2', { hasText: 'Omega heading' }); await toc.locator('a', { hasText: 'Alpha heading' }).click(); await expect(alpha).toBeInViewport({ timeout: 5000 }); await expect(omega).not.toBeInViewport(); await toc.locator('a', { hasText: 'Omega heading' }).click(); await expect(omega).toBeInViewport({ timeout: 5000 }); // The same surface also works embedded as a plugin_block (issue #77 AC). await editor.click(); await page.locator('.editor-toolbar__block-select').selectOption('toc/toc'); await expect(page.locator('.plugin-block .plugin-block__surface')).toHaveAttribute( 'data-state', 'ready', { timeout: 10000 }, ); await expect(page.frameLocator('.plugin-block iframe').locator('body')).toContainText( 'Alpha heading', { timeout: OUTLINE_TIMEOUT }, ); await admin.close(); }); test('page-index as embedded block lists pages and navigates on click', async ({ browser }) => { const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); await installAsRequired(admin, 'page-index'); const pond = await personalPond(admin); const stamp = Date.now(); const target = await createPage(admin, pond.id, `E2E Index Target ${stamp}`); const home = await createPage(admin, pond.id, `E2E Index Home ${stamp}`); const page = await openEditor(admin, pond.slug, home.slug); // Embed the pageTool as a plugin_block via the insert menu (issue #77 AC). await page.locator('.editor-toolbar__block-select').selectOption('page-index/page-index'); const host = page.locator('.plugin-block .plugin-block__surface'); await expect(host).toHaveAttribute('data-state', 'ready', { timeout: 10000 }); const frame = page.frameLocator('.plugin-block iframe').locator('body'); await expect(frame.locator('a', { hasText: `E2E Index Target ${stamp}` })).toBeVisible({ timeout: 10000, }); // `ui.openPage` navigates the host to the clicked page. await frame.locator('a', { hasText: `E2E Index Target ${stamp}` }).click(); await page.waitForURL(`**/p/${pond.slug}/${target.slug}`, { timeout: 10000 }); await admin.close(); }); test('page-index respects the viewer permissions (label-restricted reader)', async ({ browser, }) => { const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); const viewer = await contextForUser(browser, BASE_URL, 'fixture-viewer'); await installAsRequired(admin, 'page-index'); // The owner's pond: an open page, a secret-labeled page, and a reader who // may read the pond but is denied the secret label (permissions.md). const pond = await personalPond(owner); const stamp = Date.now(); const open = await createPage(owner, pond.id, `E2E Perm Open ${stamp}`); const secret = await createPage(owner, pond.id, `E2E Perm Secret ${stamp}`); const label = await ( await owner.request.post(`/api/v1/ponds/${pond.id}/labels`, { data: { name: `e2e-secret-${stamp}` }, }) ).json(); await owner.request.post(`/api/v1/pages/${secret.id}/labels`, { data: { labelId: label.id }, }); const viewerId = ((await (await viewer.request.get('/api/v1/auth/me')).json()) as { id: string }) .id; const allow = await owner.request.post(`/api/v1/ponds/${pond.id}/grants`, { data: { subjectType: 'user', subjectId: viewerId, role: 'reader', scopeType: 'pond', effect: 'allow', }, }); // 409 = the pond-wide reader grant survived an earlier run — same effect. expect([201, 409], await allow.text()).toContain(allow.status()); const deny = await owner.request.post(`/api/v1/ponds/${pond.id}/grants`, { data: { subjectType: 'user', subjectId: viewerId, role: 'reader', scopeType: 'label', scopeId: label.id, effect: 'deny', }, }); expect(deny.status(), await deny.text()).toBe(201); // The restricted reader's page index shows the open page, never the secret. const page = await viewer.newPage(); await page.goto(`/p/${pond.slug}/${open.slug}`); await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 15000 }); const index = await openTool(page, 'page-index/page-index'); await expect(index.locator('a', { hasText: `E2E Perm Open ${stamp}` })).toBeVisible({ timeout: 10000, }); await expect(index.locator('a', { hasText: `E2E Perm Secret ${stamp}` })).toHaveCount(0); // The owner sees both, and can narrow by the label filter chip. const ownerPage = await owner.newPage(); await ownerPage.goto(`/p/${pond.slug}/${open.slug}`); await expect(ownerPage.locator('.ProseMirror')).toBeVisible({ timeout: 15000 }); const ownerIndex = await openTool(ownerPage, 'page-index/page-index'); await expect(ownerIndex.locator('a', { hasText: `E2E Perm Secret ${stamp}` })).toBeVisible({ timeout: 10000, }); await ownerIndex.locator(`button[data-filter="e2e-secret-${stamp}"]`).click(); await expect(ownerIndex.locator('a', { hasText: `E2E Perm Open ${stamp}` })).toHaveCount(0); await expect(ownerIndex.locator('a', { hasText: `E2E Perm Secret ${stamp}` })).toBeVisible(); await owner.close(); await admin.close(); await viewer.close(); });