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
207 lines
8.7 KiB
TypeScript
207 lines
8.7 KiB
TypeScript
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 1–5 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();
|
||
});
|