/** * AI Software Factory — E2E: Comments & Mentions (Phase 8) * * Covers: select text → comment popup, create/reply/resolve, @mention * badge, second-user realtime visibility (SKIPPED — realtime lands in * Phase 7), reactions. * * Selector convention (tests/README.md): * data-testid="comment-popup" popup after text selection * data-testid="comment-input" comment body input * data-testid="comment-submit" submit button * data-testid="comment-thread-{id}" a comment thread in the sidebar * data-testid="comment-{id}" a comment * data-testid="comment-replies-{id}" replies section of a thread * data-testid="comment-reply-input" reply input * data-testid="comment-resolve-{id}" resolve button * data-testid="mention-badge-{userId}" @mention badge chip * data-testid="comment-reaction-{emoji}" a reaction chip */ import { test, expect } from "../fixtures"; import { createComment, replyToComment, resolveComment } from "../utils/api"; test.describe("Comments — Phase 8", () => { test("select text → comment popup → create comment", async ({ authPage, api, freshWorkspace, }) => { const ws = await freshWorkspace("Comment Space"); const pgRes = await api.post(`/api/v1/workspaces/${ws.id}/pages`, { data: { title: "Commentable" }, }); const pageId = ((await pgRes.json()) as { page: { id: string } }).page.id; // Give the page a paragraph to select from. await api.post(`/api/v1/pages/${pageId}/blocks`, { data: { type: "PARAGRAPH", content: { text: "Select me for a comment" } }, }); await authPage.goto(`/w/${ws.id}/page/${pageId}`); await expect(authPage.getByTestId("editor-root")).toBeVisible({ timeout: 20_000 }); // Select the paragraph text and open the comment popup. const block = authPage.locator('[data-testid^="block-"]').first(); await block.dblclick(); // select the block // (The selection handles: double-click selects a word; we extend to the // full line via shift+End for a stable selection range.) await authPage.keyboard.press("Shift+End"); await authPage.getByTestId("comment-popup").waitFor({ timeout: 10_000 }); await authPage.getByTestId("comment-input").fill("Does this need a source?"); await authPage.getByTestId("comment-submit").click(); // The thread appears in the page's comment sidebar. const thread = authPage.getByTestId("comment-thread").first(); await expect(thread).toBeVisible({ timeout: 10_000 }); await expect(thread).toContainText("Does this need a source?"); }); test("reply to a comment", async ({ authPage, api, freshWorkspace }) => { const ws = await freshWorkspace("Reply Space"); const pgRes = await api.post(`/api/v1/workspaces/${ws.id}/pages`, { data: { title: "Reply Page" }, }); const pageId = ((await pgRes.json()) as { page: { id: string } }).page.id; // Create the thread via the API (deterministic ids), then drive the UI. const comment = await createComment(authPage, pageId, "Top-level thought"); await authPage.goto(`/w/${ws.id}/page/${pageId}`); await expect(authPage.getByTestId("editor-root")).toBeVisible({ timeout: 20_000 }); // Open the thread and reply. const thread = authPage.getByTestId(`comment-thread-${comment.id}`); await thread.click(); await authPage.getByTestId("comment-reply-input").fill("Agreed — adding it."); await authPage.getByTestId("comment-reply-submit").click(); await expect( authPage.getByTestId(`comment-replies-${comment.id}`), ).toContainText("Agreed — adding it.", { timeout: 10_000 }); }); test("resolve a comment thread", async ({ authPage, api, freshWorkspace }) => { const ws = await freshWorkspace("Resolve Space"); const pgRes = await api.post(`/api/v1/workspaces/${ws.id}/pages`, { data: { title: "Resolve Page" }, }); const pageId = ((await pgRes.json()) as { page: { id: string } }).page.id; const comment = await createComment(authPage, pageId, "Resolved soon"); await authPage.goto(`/w/${ws.id}/page/${pageId}`); await expect(authPage.getByTestId("editor-root")).toBeVisible({ timeout: 20_000 }); const thread = authPage.getByTestId(`comment-thread-${comment.id}`); await thread.click(); await authPage.getByTestId(`comment-resolve-${comment.id}`).click(); // Thread moves to the resolved section (or is hidden from the open list). await expect .poll(async () => { const resolved = await authPage .getByTestId("comments-resolved-section") .getByText("Resolved soon") .count() .catch(() => 0); const open = await authPage .getByTestId("comment-thread") .filter({ hasText: "Resolved soon" }) .count() .catch(() => 0); return resolved > 0 ? "resolved" : open === 0 ? "hidden" : "still-open"; }, { timeout: 10_000 }) .not.toBe("still-open"); // Server state: resolvedAt set. const after = await resolveComment(authPage, comment.id).catch(() => null); if (after) expect(after.resolvedAt).toBeTruthy(); }); test("@mention shows a badge and notifies the user", async ({ authPage, api, secondUser, freshWorkspace, }) => { const ws = await freshWorkspace("Mention Space"); const { user: mentioned } = await secondUser(); const pgRes = await api.post(`/api/v1/workspaces/${ws.id}/pages`, { data: { title: "Mention Page" }, }); const pageId = ((await pgRes.json()) as { page: { id: string } }).page.id; // Look up the mentioned user's id (via a members list after inviting, or // /users search). For determinism, invite them to the workspace first. const inviteRes = await api.post(`/api/v1/workspaces/${ws.id}/invitations`, { data: { email: mentioned.email, role: "MEMBER" }, }); const invite = (await inviteRes.json()) as { invitation: { token: string } }; const { api: secondApi } = await secondUser(); await secondApi.post(`/api/v1/invitations/${invite.invitation.token}/accept`, { data: {}, }); // Comment mentioning them: the editor's mention picker (@) creates the // mention and stores the user id in mentionIds. await authPage.goto(`/w/${ws.id}/page/${pageId}`); await expect(authPage.getByTestId("editor-root")).toBeVisible({ timeout: 20_000 }); const block = authPage.locator('[data-testid^="block-"]').first(); await block.dblclick(); await authPage.keyboard.press("Shift+End"); await authPage.getByTestId("comment-popup").waitFor({ timeout: 10_000 }); const input = authPage.getByTestId("comment-input"); await input.type("@"); // Mention picker lists workspace members; pick the second user. const mentionPicker = authPage.getByTestId("mention-picker"); await expect(mentionPicker).toBeVisible({ timeout: 5_000 }); await mentionPicker.getByText(mentioned.email).first().click(); await input.type(" — ping!"); await authPage.getByTestId("comment-submit").click(); // The comment body shows a badge chip, not raw text. const thread = authPage.getByTestId("comment-thread").first(); await expect(thread.getByTestId("mention-badge").first()).toBeVisible({ timeout: 10_000, }); }); test("second user sees the comment in real time (skip: realtime lands in Phase 7)", async ({ authPage, api, secondUser, freshWorkspace, }) => { // Realtime comment delivery over WebSocket (presence + Yjs/presence // channels) is Phase 7. Until then comments sync on refetch (polling / // page load), which the other tests in this file cover. test.skip(true, "realtime comment sync lands in Phase 7"); // When unskipped: user B opens the page, user A creates a comment, B's // sidebar shows the thread within ~2s WITHOUT a reload. }); test("reactions on a comment", async ({ authPage, api, freshWorkspace }) => { const ws = await freshWorkspace("React Space"); const pgRes = await api.post(`/api/v1/workspaces/${ws.id}/pages`, { data: { title: "React Page" }, }); const pageId = ((await pgRes.json()) as { page: { id: string } }).page.id; const comment = await createComment(authPage, pageId, "React to me"); await authPage.goto(`/w/${ws.id}/page/${pageId}`); await expect(authPage.getByTestId("editor-root")).toBeVisible({ timeout: 20_000 }); const thread = authPage.getByTestId(`comment-thread-${comment.id}`); await thread.click(); // Hover the comment → reaction bar → click 👍. const commentEl = authPage.getByTestId(`comment-${comment.id}`); await commentEl.hover(); await authPage.getByTestId("reaction-bar").getByTestId("reaction-add").click(); await authPage.getByTestId("reaction-picker").getByTestId("reaction-emoji-like").click(); // The 👍 chip is visible inside the comment. await expect(commentEl.getByText("👍").first()).toBeVisible({ timeout: 5_000 }); // Reacting again removes it (toggle). await commentEl.hover(); await authPage.getByTestId("reaction-bar").getByTestId("reaction-add").click(); await authPage.getByTestId("reaction-picker").getByTestId("reaction-emoji-like").click(); await expect(commentEl.getByText("👍")).toHaveCount(0); }); });