import { expect, test, type BrowserContext } from '@playwright/test'; import { contextForUser } from './helpers'; const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; /** * Comments UI (issue #92): the full two-user lifecycle in the panel, * resolve/unresolve with the collapsed section, the permission variant * (composer hidden with a hint), and the localStorage unread badge. * The pack provisions its own page and grant, so it is repeatable. */ let pondId: string; let pageUrl: string; let pageId: string; let readerUserId: string; async function pondOf(owner: BrowserContext): Promise<{ id: string; slug: string }> { const ponds = (await (await owner.request.get('/api/v1/ponds')).json()) as { id: string; slug: string; type: string; }[]; const shared = ponds.find((p) => p.type === 'shared'); expect(shared).toBeTruthy(); return shared!; } test.beforeAll(async ({ browser }) => { const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); const pond = await pondOf(owner); pondId = pond.id; // Fresh page per run — never depends on fixture pages' trash state. const created = await owner.request.post(`/api/v1/ponds/${pondId}/pages`, { data: { title: `Comment stage ${Date.now()}` }, }); expect(created.ok()).toBe(true); const page = (await created.json()) as { id: string; slug: string }; pageId = page.id; pageUrl = `/p/${pond.slug}/${page.slug}`; // fixture-editor becomes a reader-member of the pond (the second user); // a leftover membership from an aborted run just yields a conflict we ignore. await owner.request.post(`/api/v1/ponds/${pondId}/members`, { data: { usernameOrEmail: 'fixture-editor', role: 'reader' }, }); const members = (await (await owner.request.get(`/api/v1/ponds/${pondId}/members`)).json()) as { members: { id: string; username: string }[]; }; readerUserId = members.members.find((m) => m.username === 'fixture-editor')!.id; // Deterministic starting policy. await owner.request.patch(`/api/v1/ponds/${pondId}`, { data: { commentPolicy: 'readers' } }); await owner.close(); }); test.afterAll(async ({ browser }) => { const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); await owner.request.patch(`/api/v1/ponds/${pondId}`, { data: { commentPolicy: 'readers' } }); if (readerUserId) await owner.request.delete(`/api/v1/ponds/${pondId}/members/${readerUserId}`); if (pageId) await owner.request.delete(`/api/v1/pages/${pageId}`); await owner.close(); }); test('two users run the full comment lifecycle with resolve/unresolve', async ({ browser }) => { // The reader starts the thread. const reader = await contextForUser(browser, BASE_URL, 'fixture-editor'); const readerPage = await reader.newPage(); await readerPage.goto(pageUrl); await readerPage.locator('.editor-shell__comments-toggle').click(); const readerPanel = readerPage.locator('.comments-panel'); await readerPanel.locator('.comments-composer textarea').fill('Is this **final**?'); await readerPanel.locator('.comments-composer button[type="submit"]').click(); await expect(readerPanel.locator('.comment__body strong')).toHaveText('final'); // The owner sees the unread badge, replies, and resolves the thread. const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); const ownerPage = await owner.newPage(); await ownerPage.goto(pageUrl); await expect(ownerPage.locator('.comments-unread-badge')).toBeVisible(); await ownerPage.locator('.editor-shell__comments-toggle').click(); const ownerPanel = ownerPage.locator('.comments-panel'); await ownerPanel .locator('.comments-thread .comments-link-button', { hasText: /reply|antwort/i }) .click(); await ownerPanel.locator('.comments-thread textarea').fill('Yes, shipping it.'); await ownerPanel.locator('.comments-thread button[type="submit"]').click(); await expect(ownerPanel.locator('.comments-thread__replies .comment__body')).toContainText( 'shipping', ); // Resolve collapses the thread into the resolved section. await ownerPanel .locator('.comment__actions .comments-link-button', { hasText: /resolve|erledig/i }) .first() .click(); const resolvedSection = ownerPanel.locator('.comments-panel__resolved'); await expect(resolvedSection).toBeVisible(); await expect(resolvedSection.locator('details, summary').first()).toBeVisible(); // Collapsed: the thread body is hidden until the section is opened. await expect(resolvedSection.locator('.comment__body strong')).toBeHidden(); await resolvedSection.locator('summary').click(); await expect(resolvedSection.locator('.comment__body strong')).toBeVisible(); // Unresolve restores it to the open list. await resolvedSection.locator('.comments-link-button', { hasText: /reopen|öffnen/i }).click(); await expect(ownerPanel.locator('.comments-panel__resolved')).toHaveCount(0); await expect(ownerPanel.locator('.comments-thread .comment__body strong')).toBeVisible(); // Author edits and deletes own reply. const replyItem = ownerPanel.locator('.comments-thread__replies .comment'); await replyItem.locator('.comments-link-button', { hasText: /edit|bearbeit/i }).click(); await replyItem.locator('textarea').fill('Yes — shipped.'); await replyItem.locator('button[type="submit"]').click(); await expect(replyItem.locator('.comment__body')).toContainText('shipped.'); await expect(replyItem.locator('.comment__edited')).toBeVisible(); await replyItem.locator('.comments-link-button', { hasText: /delete|löschen/i }).click(); await expect(ownerPanel.locator('.comments-thread__replies .comment')).toHaveCount(0); // Cleanup: the reader deletes their own (now reply-free) root. await readerPage.reload(); await readerPage.locator('.editor-shell__comments-toggle').click(); await readerPanel.locator('.comments-link-button', { hasText: /delete|löschen/i }).click(); await expect(readerPanel.locator('.comments-panel__empty')).toBeVisible(); await reader.close(); await owner.close(); }); test('the composer hides with a hint when the policy bars readers', async ({ browser }) => { const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); await owner.request.patch(`/api/v1/ponds/${pondId}`, { data: { commentPolicy: 'editors' } }); await owner.close(); const reader = await contextForUser(browser, BASE_URL, 'fixture-editor'); const page = await reader.newPage(); await page.goto(pageUrl); await page.locator('.editor-shell__comments-toggle').click(); const panel = page.locator('.comments-panel'); await expect(panel.locator('.comments-panel__policy-hint')).toBeVisible(); await expect(panel.locator('.comments-composer')).toHaveCount(0); await reader.close(); const ownerAgain = await contextForUser(browser, BASE_URL, 'fixture-user'); await ownerAgain.request.patch(`/api/v1/ponds/${pondId}`, { data: { commentPolicy: 'readers' }, }); await ownerAgain.close(); });