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
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>
190 lines
7.9 KiB
TypeScript
190 lines
7.9 KiB
TypeScript
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();
|
||
});
|