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( context: BrowserContext, method: 'post' | 'patch', url: string, data: unknown, ): Promise { const response = await context.request[method](url, { data }); if (!response.ok()) throw new Error(`${method} ${url} → ${response.status()}`); return response.json() as Promise; } 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 { 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(); });