1
2
14(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
15import { act } from "react";
16import { createRoot, type Root } from "react-dom/client";
17import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
18
19vi.mock("next/navigation", () => ({
20 useParams: () => ({ workspace: "w" })
21}));
22
23import { ActivityFeed, type ActivityRow } from "../activity-feed";
24
25let root: Root;
26let host: HTMLElement;
27
28function row(partial: Partial<ActivityRow> & Pick<ActivityRow, "id" | "action" | "resourceType">): ActivityRow {
29 return {
30 actorType: "USER",
31 actorId: "u1",
32 resourceId: "r1",
33 workspaceId: "ws1",
34 pageId: null,
35 createdAt: "2026-10-13T12:00:00.000Z",
36 actorName: "Demo User",
37 ...partial
38 };
39}
40
41function q(sel: string): Element | null {
42 return host.querySelector(sel);
43}
44function all(sel: string): Element[] {
45 return Array.from(host.querySelectorAll(sel));
46}
47
48beforeEach(() => {
49 document.body.innerHTML = "";
50 host = document.createElement("div");
51 document.body.appendChild(host);
52 root = createRoot(host);
53});
54
55afterEach(async () => {
56 await act(async () => root.unmount());
57});
58
59describe("ActivityFeed (T-158)", () => {
60 it("renders rows with actor + verb phrase and deep-links page/database rows", async () => {
61 const fetchPage = vi.fn().mockResolvedValue({
62 data: [
63 row({ id: "a1", action: "page.updated", resourceType: "page", resourceId: "p1", pageId: "p1" }),
64 row({ id: "a2", action: "database.created", resourceType: "database", resourceId: "d1" }),
65 row({ id: "a3", action: "automation.created", resourceType: "automation", resourceId: "auto1" })
66 ],
67 nextCursor: null
68 });
69 const hrefFor = (r: ActivityRow) =>
70 r.pageId ? `/w/${r.workspaceId}/page/${r.pageId}` : r.resourceType === "database" ? `/w/${r.workspaceId}/databases/${r.resourceId}` : null;
71
72 act(() => root.render(<ActivityFeed fetchPage={fetchPage} hrefFor={hrefFor} />));
73 await act(async () => {
74 await new Promise((r) => setTimeout(r, 0));
75 });
76
77 const items = all('[data-testid="activity-item"]');
78 expect(items).toHaveLength(3);
79
80 expect(items[0]?.textContent).toContain("Demo User");
81 expect(items[0]?.textContent).toContain("updated a page");
82 expect(items[1]?.textContent).toContain("created a database");
83
84 expect(items[0]?.getAttribute("href")).toBe("/w/ws1/page/p1");
85 expect(items[1]?.getAttribute("href")).toBe("/w/ws1/databases/d1");
86
87 expect(items[2]?.getAttribute("href")).toBeNull();
88 expect(items[2]?.getAttribute("aria-disabled")).toBe("true");
89
90 expect(q('[data-testid="activity-load-more"]')).toBeNull();
91 });
92
93 it("'Load more' fetches the next cursor page and appends the rows", async () => {
94 const page2 = [row({ id: "b1", action: "comment.created", resourceType: "comment", resourceId: "c1", pageId: "p1" })];
95 const fetchPage = vi
96 .fn()
97 .mockResolvedValueOnce({ data: [row({ id: "a1", action: "page.created", resourceType: "page", resourceId: "p1", pageId: "p1" })], nextCursor: "CUR" })
98 .mockResolvedValueOnce({ data: page2, nextCursor: null });
99
100 act(() => root.render(<ActivityFeed fetchPage={fetchPage} hrefFor={() => null} />));
101 await act(async () => {
102 await new Promise((r) => setTimeout(r, 0));
103 });
104 expect(all('[data-testid="activity-item"]')).toHaveLength(1);
105
106 const more = q('[data-testid="activity-load-more"]') as HTMLButtonElement;
107 expect(more).not.toBeNull();
108 await act(async () => {
109 more.click();
110 await new Promise((r) => setTimeout(r, 0));
111 });
112
113 expect(fetchPage).toHaveBeenLastCalledWith("CUR");
114 expect(all('[data-testid="activity-item"]')).toHaveLength(2);
115
116 expect(q('[data-testid="activity-load-more"]')).toBeNull();
117 });
118
119 it("an empty feed shows the empty message", async () => {
120 const fetchPage = vi.fn().mockResolvedValue({ data: [], nextCursor: null });
121 act(() => root.render(<ActivityFeed fetchPage={fetchPage} hrefFor={() => null} emptyMessage="Nothing yet." />));
122 await act(async () => {
123 await new Promise((r) => setTimeout(r, 0));
124 });
125 expect(host.textContent).toContain("Nothing yet.");
126 expect(q('[data-testid="activity-feed"]')).toBeNull();
127 });
128
129 it("a failed fetch shows the error state (no crash)", async () => {
130 const fetchPage = vi.fn().mockRejectedValue(new Error("boom"));
131 act(() => root.render(<ActivityFeed fetchPage={fetchPage} hrefFor={() => null} />));
132 await act(async () => {
133 await new Promise((r) => setTimeout(r, 0));
134 });
135 expect(q('[role="alert"]')).not.toBeNull();
136 });
137
138 it("SYSTEM actors render the system label, not an initial avatar", async () => {
139 const fetchPage = vi.fn().mockResolvedValue({
140 data: [row({ id: "a1", action: "page.version_restored", resourceType: "page", resourceId: "p1", pageId: "p1", actorType: "SYSTEM", actorName: null })],
141 nextCursor: null
142 });
143 act(() => root.render(<ActivityFeed fetchPage={fetchPage} hrefFor={() => null} />));
144 await act(async () => {
145 await new Promise((r) => setTimeout(r, 0));
146 });
147 expect(q('[data-testid="activity-actor"]')?.textContent).toBe("System");
148 expect(all('[data-testid="activity-actor"]')).toHaveLength(1);
149 });
150});
151