// @vitest-environment jsdom /** * ActivityFeed (T-158) — behavior pins for the shared activity feed (user * feed /activity + space feed /w/:ws/activity share this component): * * - renders rows with the resolved actor + a human verb phrase + a deep * link (page rows → /page/:id, database rows → /databases/:id, and * pageless resource types render a non-linkable row); * - the "Load more" button follows the keyset cursor (a second fetch with * the first page's nextCursor, rows appended); * - an empty feed shows the empty message; a failed fetch shows the error * state (never a crash). */ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("next/navigation", () => ({ useParams: () => ({ workspace: "w" }) })); import { ActivityFeed, type ActivityRow } from "../activity-feed"; let root: Root; let host: HTMLElement; function row(partial: Partial & Pick): ActivityRow { return { actorType: "USER", actorId: "u1", resourceId: "r1", workspaceId: "ws1", pageId: null, createdAt: "2026-10-13T12:00:00.000Z", actorName: "Demo User", ...partial }; } function q(sel: string): Element | null { return host.querySelector(sel); } function all(sel: string): Element[] { return Array.from(host.querySelectorAll(sel)); } beforeEach(() => { document.body.innerHTML = ""; host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host); }); afterEach(async () => { await act(async () => root.unmount()); }); describe("ActivityFeed (T-158)", () => { it("renders rows with actor + verb phrase and deep-links page/database rows", async () => { const fetchPage = vi.fn().mockResolvedValue({ data: [ row({ id: "a1", action: "page.updated", resourceType: "page", resourceId: "p1", pageId: "p1" }), row({ id: "a2", action: "database.created", resourceType: "database", resourceId: "d1" }), row({ id: "a3", action: "automation.created", resourceType: "automation", resourceId: "auto1" }) ], nextCursor: null }); const hrefFor = (r: ActivityRow) => r.pageId ? `/w/${r.workspaceId}/page/${r.pageId}` : r.resourceType === "database" ? `/w/${r.workspaceId}/databases/${r.resourceId}` : null; act(() => root.render()); await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); const items = all('[data-testid="activity-item"]'); expect(items).toHaveLength(3); // Actor + human verb phrase (not the machine action name). expect(items[0]?.textContent).toContain("Demo User"); expect(items[0]?.textContent).toContain("updated a page"); expect(items[1]?.textContent).toContain("created a database"); // Deep links. expect(items[0]?.getAttribute("href")).toBe("/w/ws1/page/p1"); expect(items[1]?.getAttribute("href")).toBe("/w/ws1/databases/d1"); // A pageless resource type is NOT linkable (no href, aria-disabled). expect(items[2]?.getAttribute("href")).toBeNull(); expect(items[2]?.getAttribute("aria-disabled")).toBe("true"); // No cursor → no Load more button. expect(q('[data-testid="activity-load-more"]')).toBeNull(); }); it("'Load more' fetches the next cursor page and appends the rows", async () => { const page2 = [row({ id: "b1", action: "comment.created", resourceType: "comment", resourceId: "c1", pageId: "p1" })]; const fetchPage = vi .fn() .mockResolvedValueOnce({ data: [row({ id: "a1", action: "page.created", resourceType: "page", resourceId: "p1", pageId: "p1" })], nextCursor: "CUR" }) .mockResolvedValueOnce({ data: page2, nextCursor: null }); act(() => root.render( null} />)); await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); expect(all('[data-testid="activity-item"]')).toHaveLength(1); const more = q('[data-testid="activity-load-more"]') as HTMLButtonElement; expect(more).not.toBeNull(); await act(async () => { more.click(); await new Promise((r) => setTimeout(r, 0)); }); // The second fetch used the cursor from the first page. expect(fetchPage).toHaveBeenLastCalledWith("CUR"); expect(all('[data-testid="activity-item"]')).toHaveLength(2); // Consumed cursor → the button is gone. expect(q('[data-testid="activity-load-more"]')).toBeNull(); }); it("an empty feed shows the empty message", async () => { const fetchPage = vi.fn().mockResolvedValue({ data: [], nextCursor: null }); act(() => root.render( null} emptyMessage="Nothing yet." />)); await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); expect(host.textContent).toContain("Nothing yet."); expect(q('[data-testid="activity-feed"]')).toBeNull(); }); it("a failed fetch shows the error state (no crash)", async () => { const fetchPage = vi.fn().mockRejectedValue(new Error("boom")); act(() => root.render( null} />)); await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); expect(q('[role="alert"]')).not.toBeNull(); }); it("SYSTEM actors render the system label, not an initial avatar", async () => { const fetchPage = vi.fn().mockResolvedValue({ data: [row({ id: "a1", action: "page.version_restored", resourceType: "page", resourceId: "p1", pageId: "p1", actorType: "SYSTEM", actorName: null })], nextCursor: null }); act(() => root.render( null} />)); await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); expect(q('[data-testid="activity-actor"]')?.textContent).toBe("System"); expect(all('[data-testid="activity-actor"]')).toHaveLength(1); }); });