dorfteich/apps/web/e2e/graph.spec.ts
Claude Fable 5 1781f12f6e
All checks were successful
CD / Build and push images (push) Successful in 4m2s
CD / Deploy to Test (push) Successful in 9s
CI / Lint, typecheck, test (push) Successful in 4m28s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Successful in 6m42s
CI / Import/export fidelity gate (push) Successful in 49s
Release / Build release images and notes (push) Successful in 1m7s
Release / Release-candidate operations QA (push) Successful in 42s
Prod deploy / Deploy the released images to Prod (push) Successful in 21s
Knowledge graph: live Obsidian-like simulation with tunable physics
The force layout used to run once (tick(250)) and freeze; dragging
moved a single node with no reaction from its neighbors. The simulation
now stays alive: React renders the SVG structure (testids, edge/ring
classes — the e2e contract is unchanged) while each tick writes
positions imperatively into the element refs, and it settles to rest
via alpha decay, which also keeps Playwright's stability wait happy.
Dragging pins the node (fx/fy) and reheats the physics, so the
neighborhood gets pulled along; a plain click still just opens the
page. Surviving nodes keep their positions across data refreshes, e.g.
when a phantom becomes a real page.

The graph page gains four sliders — attraction, repulsion, node size,
font size — persisted per pond (ui.graph.settings.<pondId>) with a
reset; the inner view is keyed by pond id because usePersistentState
reads its key only on mount (#108 trap). The local panel adopts those
settings (no second set of sliders) and swaps the two fixed hop
buttons for a 1–5 depth slider.

Fixes #123

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fb2VzvcoBPHkjh8bZ6PzQn
2026-07-15 11:29:03 +02:00

198 lines
8.3 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();
// #123: the physics/rendering sliders are there and take effect — the
// font-size slider writes straight into the SVG labels.
const controls = page.locator('.graph-controls');
await expect(controls.locator('input[type="range"]')).toHaveCount(4);
await controls.locator('input[type="range"]').nth(3).fill('20');
await expect(page.locator('.force-graph__label').first()).toHaveCSS('font-size', '20px');
// 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);
// #123: the hop depth is a 15 slider now.
await panel.locator('.local-graph__hops input').fill('2');
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();
});