All checks were successful
CI / Auth e2e pack (push) Successful in 4m37s
CI / Import/export fidelity gate (push) Successful in 43s
CI / Lint, typecheck, test (push) Successful in 2m54s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m14s
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m8s
CD / Promote to Int (push) Successful in 9s
The read-only widget surface over page/pond data (ADR 0008 extension point `pageTool`): - Host: PageToolsPanel lists the pond's active pageTool surfaces behind disclosures — each sandbox iframe mounts lazily on first open and tears down on close. The same surfaces are insertable as plugin_block embeds (#76's insert picker now offers pageTool points too; the sandbox drives both through the same render lifecycle). - New `ui.scrollToHeading(headingId)` capability: outline ids are derived from the doc and never stamped into the DOM, so the host resolves the id to its heading position via the shared extractOutline and scrolls the matching rendered heading. - `readPond.listPages` now carries label *names* per summary (PagesService.pluginPageSummaries) — the page-index filter chips work on data the viewer could resolve anyway; per-page permission filtering stays in the service as before. - Reference plugins packages/plugins/toc and packages/plugins/page-index: real SDK consumers (createPlugin + windowTransport), bundled with esbuild into the package ZIP; i18n de/en is inlined at build time — the sandbox CSP forbids runtime fetches, the i18n/ files stay the single source. The toc re-fetches its outline on a slow poll, so live heading edits appear once the collab server has re-derived the content cache. - e2e page-tools.spec.ts covers the acceptance criteria: live outline updates after the persistence debounce, heading click scrolls, embedded page-index navigates via ui.openPage, and a label-restricted reader never sees the denied page in the index. - CI: the auth-e2e job now runs the section-styles (missed in #75), plugin-blocks, and page-tools packs, with login-rate-limit resets. - plugins.e2e.db.test clears the plugin registry up front: a local dev DB is shared with the e2e stack, whose installed real `toc` would otherwise collide with the fixture of the same id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
234 lines
9.9 KiB
TypeScript
234 lines
9.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';
|
|
|
|
/**
|
|
* pageTool plugins end to end (issue #77): installs the real `toc` and
|
|
* `page-index` reference packages (built by `pnpm build` into
|
|
* packages/plugins/<id>/dist/<id>-<version>.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<void> {
|
|
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<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;
|
|
}
|
|
|
|
/** 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();
|
|
});
|