import { expect, test, type BrowserContext, type Page } from '@playwright/test'; import { contextForUser } from './helpers'; /** * Knowledge-graph pack (issues #111–#113): the pond graph view (nodes, * edges, phantom-create flow, legend), the local neighborhood panel with * its hop toggle, and the permission slice — a label-denied reader must * not see the hidden page anywhere in the graph. Wikilinks are typed in * the editor; a reload flushes the collab store into `page_links` (same * ordering constraint as the backlinks pack). */ const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; async function json( context: BrowserContext, method: 'post' | 'get', url: string, data?: unknown, ): Promise { const response = await context.request[method](url, data !== undefined ? { data } : {}); if (!response.ok()) throw new Error(`${method} ${url} → ${response.status()}`); return response.json() as Promise; } interface PageRef { id: string; slug: string; } async function createPage( context: BrowserContext, pondId: string, title: string, ): Promise { return json(context, 'post', `/api/v1/ponds/${pondId}/pages`, { title }); } /** Types `[[query` in the open editor and accepts the first suggestion. */ async function insertWikilink(page: Page, query: string): Promise { const body = page.locator('.editor-content .ProseMirror'); await body.click(); await page.keyboard.type(`[[${query}`); await expect(page.locator('.wikilink-suggest')).toBeVisible(); await page.keyboard.press('Enter'); } test('pond graph renders the link structure and creates phantom pages', async ({ browser }) => { const context = await contextForUser(browser, BASE_URL, 'fixture-user'); const ts = Date.now(); const pond = await json<{ id: string; slug: string }>(context, 'post', '/api/v1/ponds', { name: `Graph Pack ${ts}`, }); const source = await createPage(context, pond.id, `GP Source ${ts}`); const target = await createPage(context, pond.id, `GP Target ${ts}`); const page = await context.newPage(); await page.goto(`/p/${pond.slug}/${source.slug}`); await page.locator('.editor-page__mode-toggle').click(); await insertWikilink(page, `GP Target ${ts}`); await page.keyboard.type(' '); // No page matches → the suggestion creates a phantom link. await insertWikilink(page, `gp-ghost-${ts}`); await page.reload(); // flush the collab store → page_links rows // The sidebar links every member to the graph route. await page.locator('.sidebar__graph-link').click(); await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/graph$`)); await expect(page.getByTestId(`graph-node-${source.slug}`)).toBeVisible(); await expect(page.getByTestId(`graph-node-${target.slug}`)).toBeVisible(); await expect(page.getByTestId(`graph-phantom-gp-ghost-${ts}`)).toBeVisible(); expect(await page.locator('.force-graph__edge').count()).toBeGreaterThanOrEqual(2); await expect(page.locator('.graph-legend')).toBeVisible(); // A node click opens the page. await page.getByTestId(`graph-node-${target.slug}`).click(); await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/${target.slug}$`)); // A phantom click creates the page (confirm) and resolves the links. page.on('dialog', (dialog) => void dialog.accept()); await page.goto(`/p/${pond.slug}/graph`); await page.getByTestId(`graph-phantom-gp-ghost-${ts}`).click(); await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/gp-ghost-${ts}$`)); await page.goto(`/p/${pond.slug}/graph`); await expect(page.getByTestId(`graph-node-gp-ghost-${ts}`)).toBeVisible(); await expect(page.getByTestId(`graph-phantom-gp-ghost-${ts}`)).toHaveCount(0); await context.close(); }); test('local graph panel shows the neighborhood per hop depth and navigates', async ({ browser, }) => { const context = await contextForUser(browser, BASE_URL, 'fixture-user'); const ts = Date.now(); const pond = await json<{ id: string; slug: string }>(context, 'post', '/api/v1/ponds', { name: `Local Graph ${ts}`, }); const a = await createPage(context, pond.id, `LG A ${ts}`); const b = await createPage(context, pond.id, `LG B ${ts}`); const c = await createPage(context, pond.id, `LG C ${ts}`); const lonely = await createPage(context, pond.id, `LG Lonely ${ts}`); // Chain A → B → C so the hop depth matters when viewed from A. const page = await context.newPage(); await page.goto(`/p/${pond.slug}/${a.slug}`); await page.locator('.editor-page__mode-toggle').click(); await insertWikilink(page, `LG B ${ts}`); await page.goto(`/p/${pond.slug}/${b.slug}`); await page.locator('.editor-page__mode-toggle').click(); await insertWikilink(page, `LG C ${ts}`); await page.reload(); await page.goto(`/p/${pond.slug}/${a.slug}`); const panel = page.locator('.local-graph'); await expect(panel).toBeVisible(); await expect(page.getByTestId(`local-graph-node-${b.slug}`)).toBeVisible(); await expect(page.getByTestId(`local-graph-node-${c.slug}`)).toHaveCount(0); // The current page carries the highlight ring. await expect( page.getByTestId(`local-graph-node-${a.slug}`).locator('.force-graph__ring'), ).toHaveCount(1); await panel.getByRole('button', { name: /two hops|zwei ebenen/i }).click(); await expect(page.getByTestId(`local-graph-node-${c.slug}`)).toBeVisible(); await page.getByTestId(`local-graph-node-${b.slug}`).click(); await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/${b.slug}$`)); // Pages without links show no panel at all. await page.goto(`/p/${pond.slug}/${lonely.slug}`); await expect(page.locator('.editor-content .ProseMirror')).toBeVisible(); await expect(page.locator('.local-graph')).toHaveCount(0); await context.close(); }); test('a label-denied reader sees no trace of the hidden page in the graph', async ({ browser }) => { const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); const viewer = await contextForUser(browser, BASE_URL, 'fixture-viewer'); const viewerId = (await json<{ id: string }>(viewer, 'get', '/api/v1/auth/me')).id; const ts = Date.now(); const pond = await json<{ id: string; slug: string }>(owner, 'post', '/api/v1/ponds', { name: `Sliced Graph ${ts}`, }); // Grants BEFORE content: pond-wide read for the viewer, minus the label. const label = await json<{ id: string }>(owner, 'post', `/api/v1/ponds/${pond.id}/labels`, { name: 'secret', }); await json(owner, 'post', `/api/v1/ponds/${pond.id}/grants`, { subjectType: 'user', subjectId: viewerId, role: 'reader', scopeType: 'pond', effect: 'allow', }); await json(owner, 'post', `/api/v1/ponds/${pond.id}/grants`, { subjectType: 'user', subjectId: viewerId, role: 'reader', scopeType: 'label', scopeId: label.id, effect: 'deny', }); const open = await createPage(owner, pond.id, `SG Open ${ts}`); const secret = await createPage(owner, pond.id, `SG Secret ${ts}`); await json(owner, 'post', `/api/v1/pages/${secret.id}/labels`, { labelId: label.id }); // Owner links the open page to the secret one. const ownerPage = await owner.newPage(); await ownerPage.goto(`/p/${pond.slug}/${open.slug}`); await ownerPage.locator('.editor-page__mode-toggle').click(); await insertWikilink(ownerPage, `SG Secret ${ts}`); await ownerPage.reload(); await ownerPage.goto(`/p/${pond.slug}/graph`); await expect(ownerPage.getByTestId(`graph-node-${secret.slug}`)).toBeVisible(); expect(await ownerPage.locator('.force-graph__edge').count()).toBe(1); // The viewer gets the sliced graph: no secret node, no edge into it. const viewerPage = await viewer.newPage(); await viewerPage.goto(`/p/${pond.slug}/graph`); await expect(viewerPage.getByTestId(`graph-node-${open.slug}`)).toBeVisible(); await expect(viewerPage.getByTestId(`graph-node-${secret.slug}`)).toHaveCount(0); expect(await viewerPage.locator('.force-graph__edge').count()).toBe(0); await owner.close(); await viewer.close(); });