dorfteich/apps/web/e2e/comments.spec.ts
Claude Fable 5 e54aaf76f9
Some checks failed
CI / Lint, typecheck, test (push) Successful in 3m21s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m43s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 10s
CI / Auth e2e pack (push) Failing after 3m1s
CI / Import/export fidelity gate (push) Has been skipped
Add the comments panel, unread badge, and comment-policy setting (#92)
New comments panel on the page (toggle next to attachments, unread badge
counting comments newer than the last localStorage-recorded visit):
threaded display with relative times and author names, a Markdown
composer with hints, edit/delete for authors, resolve moving threads
into a collapsed resolved <details> section with reopen, and a
permission-aware composer — hidden with a hint when the pond's policy
bars the viewer (readers always see the discussion). The pond settings
page gains the "who may comment" select. Fixes PondsService.update
silently dropping commentPolicy from the settings merge (found by the
new two-user Playwright pack; the DB test now exercises the real pond
PATCH). New comments i18n namespace (de+en); the pack runs as its own
CI step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 22:07:16 +02:00

151 lines
6.8 KiB
TypeScript

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();
});