dorfteich/apps/web/e2e/page-tree.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';
/**
* Page-tree pack (issues #106#109): create-as-child from the open page,
* folder/label sidebar views with the pond default and the local override,
* the "Move to…" dialog, the per-case delete decision (promote vs subtree),
* and the restore re-attachment to the root once the parent is gone.
* Runs in its own shared pond — fixture ponds stay untouched, and the pond
* default flip cannot leak into other packs. Language-independent selectors
* (CSS classes, testids) plus |-alternation for the two locales elsewhere.
*/
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
async function json<T>(
context: BrowserContext,
method: 'post' | 'patch',
url: string,
data: unknown,
): Promise<T> {
const response = await context.request[method](url, { 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 provisionPond(
context: BrowserContext,
name: string,
): Promise<{ id: string; slug: string }> {
return json(context, 'post', '/api/v1/ponds', { name });
}
async function createPage(
context: BrowserContext,
pondId: string,
title: string,
parentId?: string,
): Promise<PageRef> {
return json(context, 'post', `/api/v1/ponds/${pondId}/pages`, {
title,
...(parentId ? { parentId } : {}),
});
}
function sidebarPage(page: Page, title: string) {
return page.locator(`.sidebar__page:text-is("${title}")`);
}
test('folder view nests new pages under the open page; views toggle and persist', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const ts = Date.now();
const pond = await provisionPond(context, `Tree Pack ${ts}`);
const parent = await createPage(context, pond.id, `TP Parent ${ts}`);
const page = await context.newPage();
// Create from the sidebar while the parent is open → nests under it.
await page.goto(`/p/${pond.slug}/${parent.slug}`);
await page.locator('.sidebar__new-page').click();
await expect(page.locator('.sidebar__new-page-parent')).toContainText(`TP Parent ${ts}`);
await page.locator('.sidebar__new-page-form input').fill(`TP Child ${ts}`);
await page.locator('.sidebar__new-page-form button[type="submit"]').click();
await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/tp-child-`));
const parentItem = page
.locator('.sidebar__page-item')
.filter({ has: page.locator(`.sidebar__page:text-is("TP Parent ${ts}")`) })
.first();
await expect(
parentItem.locator('.sidebar__tree-children .sidebar__page', { hasText: `TP Child ${ts}` }),
).toBeVisible();
// Collapse hides the child and survives a reload (persisted per pond).
await parentItem.locator('.sidebar__caret').first().click();
await expect(sidebarPage(page, `TP Child ${ts}`)).toHaveCount(0);
await page.reload();
await expect(sidebarPage(page, `TP Child ${ts}`)).toHaveCount(0);
await page
.locator('.sidebar__page-item')
.filter({ has: page.locator(`.sidebar__page:text-is("TP Parent ${ts}")`) })
.first()
.locator('.sidebar__caret')
.first()
.click();
await expect(sidebarPage(page, `TP Child ${ts}`)).toBeVisible();
// The label view groups pages; ours land in the "unlabeled" group.
await page.locator('.sidebar__view-btn').nth(1).click();
await expect(
page.locator('.sidebar__label-group--unlabeled .sidebar__page').first(),
).toBeVisible();
// The local choice survives a reload (localStorage override).
await page.reload();
await expect(page.locator('.sidebar__label-view')).toBeVisible();
await page.locator('.sidebar__view-btn').first().click();
await expect(page.locator('.sidebar__pages--tree')).toBeVisible();
// The owner-set pond default applies to a context without an override.
await json(context, 'patch', `/api/v1/ponds/${pond.id}`, { sidebarView: 'labels' });
const fresh = await contextForUser(browser, BASE_URL, 'fixture-user');
const freshPage = await fresh.newPage();
await freshPage.goto(`/p/${pond.slug}/${parent.slug}`);
await expect(freshPage.locator('.sidebar__label-view')).toBeVisible();
await fresh.close();
await context.close();
});
test('the Move-to dialog reparents and blocks the own subtree', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const ts = Date.now();
const pond = await provisionPond(context, `Move Pack ${ts}`);
const alpha = await createPage(context, pond.id, `MP Alpha ${ts}`);
await createPage(context, pond.id, `MP Beta ${ts}`);
await createPage(context, pond.id, `MP Child ${ts}`, alpha.id);
const page = await context.newPage();
await page.goto(`/p/${pond.slug}/${alpha.slug}`);
await page.getByRole('button', { name: /more actions|weitere aktionen/i }).click();
await page.locator('.page-actions__move').click();
const dialog = page.locator('.move-dialog');
await expect(dialog).toBeVisible();
// The page itself and its child are disabled targets (cycle).
await expect(dialog.locator('li', { hasText: `MP Alpha ${ts}` }).locator('input')).toBeDisabled();
await expect(dialog.locator('li', { hasText: `MP Child ${ts}` }).locator('input')).toBeDisabled();
await dialog
.locator('li', { hasText: `MP Beta ${ts}` })
.locator('input')
.check();
await dialog.getByRole('button', { name: /^(move|verschieben)$/i }).click();
await expect(dialog).toHaveCount(0);
const betaItem = page
.locator('.sidebar__page-item')
.filter({ has: page.locator(`.sidebar__page:text-is("MP Beta ${ts}")`) })
.first();
await expect(
betaItem.locator('.sidebar__tree-children .sidebar__page', { hasText: `MP Alpha ${ts}` }),
).toBeVisible();
await context.close();
});
test('delete decides per case; a restored orphan lands at the root', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const ts = Date.now();
const pond = await provisionPond(context, `Del Pack ${ts}`);
const promoteParent = await createPage(context, pond.id, `DP Promote ${ts}`);
await createPage(context, pond.id, `DP Kid ${ts}`, promoteParent.id);
const subtreeParent = await createPage(context, pond.id, `DP Subtree ${ts}`);
await createPage(context, pond.id, `DP Deep ${ts}`, subtreeParent.id);
const page = await context.newPage();
// Promote: only the parent goes; the child moves up to the root.
await page.goto(`/p/${pond.slug}/${promoteParent.slug}`);
await page.getByRole('button', { name: /more actions|weitere aktionen/i }).click();
await page.locator('.page-actions__menu-danger').click();
const decision = page.locator('.delete-page-dialog');
await expect(decision).toBeVisible();
await decision.locator('.delete-page-dialog__promote').click();
await expect(sidebarPage(page, `DP Kid ${ts}`)).toBeVisible();
await expect(sidebarPage(page, `DP Promote ${ts}`)).toHaveCount(0);
// Subtree: parent and descendant go together.
await page.goto(`/p/${pond.slug}/${subtreeParent.slug}`);
await page.getByRole('button', { name: /more actions|weitere aktionen/i }).click();
await page.locator('.page-actions__menu-danger').click();
await page.locator('.delete-page-dialog__subtree').click();
await expect(sidebarPage(page, `DP Subtree ${ts}`)).toHaveCount(0);
await expect(sidebarPage(page, `DP Deep ${ts}`)).toHaveCount(0);
// Restoring the deep child while its parent stays trashed → root level.
await page.goto(`/p/${pond.slug}/trash`);
const item = page.locator('.trash-page__item').filter({ hasText: `DP Deep ${ts}` });
await item.getByRole('button', { name: /restore|wiederherstellen/i }).click();
const rootDeep = page
.locator('.sidebar__pages--tree > .sidebar__page-item')
.filter({ has: page.locator(`.sidebar__page:text-is("DP Deep ${ts}")`) });
await expect(rootDeep).toHaveCount(1);
await context.close();
});