dorfteich/apps/web/e2e/graph.spec.ts
Claude Fable 5 14711a18c2
All checks were successful
CD / Build and push images (push) Successful in 1m9s
CD / Deploy to Test (push) Successful in 10s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 4m18s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 5m59s
CI / Import/export fidelity gate (push) Successful in 47s
QA: page-tree and graph e2e packs in CI, manuals updated (#114)
Two new CI-wired Playwright packs, each in its own shared pond so the
fixture ponds stay untouched:

- page-tree.spec.ts — create-as-child with the form hint, collapsible
  folder view (collapse state survives reload), label view grouping,
  the local view override vs the owner-set pond default (fresh context
  without localStorage sees the new default), the Move-to dialog with
  the own subtree disabled, promote vs subtree delete, and a restored
  orphan re-attaching at the root.
- graph.spec.ts — pond graph nodes/edges/legend, node click-through,
  the phantom-create flow (dashed node turns solid), the local panel
  with hop toggle and highlight ring, and the permission slice: a
  label-denied reader sees neither the hidden node nor its edge.

Both packs 3× flake-free locally. Manuals: user guide (page tree,
moving/deleting with subpages, knowledge graph + local graph), pond
admin guide (sidebar view default), features.md (knowledge graph
bullet) — with the docs/de mirrors updated (English authoritative).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:10:14 +02:00

190 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<T>(
context: BrowserContext,
method: 'post' | 'get',
url: string,
data?: unknown,
): Promise<T> {
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<T>;
}
interface PageRef {
id: string;
slug: string;
}
async function createPage(
context: BrowserContext,
pondId: string,
title: string,
): Promise<PageRef> {
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<void> {
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();
});