dorfteich/apps/web/e2e/graph.spec.ts
Claude Fable 5 d23e5dc10c Pond graph: size the canvas to the viewport, not the width ratio (#131)
The SVG scaled to container width with height following the fixed
800×560 viewBox ratio — on wide windows the graph grew taller than the
viewport, the page got a scrollbar, and wheel-zoom scrolled along.
The graph page is now a flex column filling the main column; the canvas
takes the remaining height (flex: 1, min-height: 0), a ResizeObserver
feeds its measured size to ForceGraph as width/height, and the SVG
fills it exactly. LocalGraphPanel keeps its fixed defaults. Scope
deliberately layout-only (issue comment 1187): with no scrollbar there
is nothing for the wheel to scroll, so no non-passive listener needed.
The graph pack now asserts the main column does not overflow
vertically on the graph route.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fb2VzvcoBPHkjh8bZ6PzQn
2026-07-16 12:02:56 +02:00

207 lines
8.7 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();
// #131: the page fits the viewport — wheel-zoom must never scroll along,
// so the main column may not grow a vertical scrollbar on this route.
expect(
await page.evaluate(() => {
const main = document.querySelector('.main')!;
return main.scrollHeight - main.clientHeight;
}),
).toBeLessThanOrEqual(0);
// #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();
});