1
2
16(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
17import { act } from "react";
18import { createRoot, type Root } from "react-dom/client";
19import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
20
21const mocks = vi.hoisted(() => ({
22 assistantOpen: false,
23 assistantContext: null as null | { mode: string; pageId?: string },
24 setAssistant: vi.fn(),
25 toast: vi.fn(),
26 extractActions: vi.fn(),
27 generateFaqs: vi.fn(),
28 findDuplicates: vi.fn(),
29 findStale: vi.fn(),
30 suggestMetadata: vi.fn(),
31 applyMetadata: vi.fn(),
32 invalidateQueries: vi.fn()
33}));
34
35vi.mock("@/stores/ui-store", () => ({
36 useUiStore: (sel: (s: Record<string, unknown>) => unknown) => sel(mocks),
37 toast: mocks.toast
38}));
39
40vi.mock("@/hooks/use-workspace", () => ({
41 useWorkspace: () => ({ data: { id: "ws1", slug: "w" } })
42}));
43
44vi.mock("next/navigation", () => ({
45 useParams: () => ({ workspace: "w" }),
46 useRouter: () => ({ push: vi.fn(), replace: vi.fn() })
47}));
48
49vi.mock("@tanstack/react-query", () => ({
50 useQueryClient: () => ({ invalidateQueries: mocks.invalidateQueries })
51}));
52
53vi.mock("@/lib/ai", () => ({
54 assistantApi: {
55 extractActions: mocks.extractActions,
56 generateFaqs: mocks.generateFaqs,
57 findDuplicates: mocks.findDuplicates,
58 findStale: mocks.findStale,
59 suggestMetadata: mocks.suggestMetadata,
60 applyMetadata: mocks.applyMetadata
61 }
62}));
63
64import { AssistantDialog } from "../assistant-dialog";
65import { ApiError } from "@/lib/api";
66
67let root: Root;
68let host: HTMLElement;
69
70function q(sel: string): Element | null {
71 return host.querySelector(sel);
72}
73
74async function type(el: HTMLTextAreaElement, value: string) {
75
76
77 const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")!.set!;
78 setter.call(el, value);
79 el.dispatchEvent(new Event("input", { bubbles: true }));
80}
81
82async function open(mode: string, pageId?: string) {
83
84
85
86 mocks.assistantOpen = true;
87 mocks.assistantContext = pageId ? { mode, pageId } : { mode };
88 await act(async () => {
89 root.render(<AssistantDialog />);
90 });
91}
92
93beforeEach(() => {
94 document.body.innerHTML = "";
95 host = document.createElement("div");
96 document.body.appendChild(host);
97 root = createRoot(host);
98 mocks.assistantOpen = false;
99 mocks.assistantContext = null;
100 vi.clearAllMocks();
101});
102
103afterEach(async () => {
104 await act(async () => root.unmount());
105});
106
107describe("AssistantDialog", () => {
108 it("is closed until the store opens it with a mode", async () => {
109 act(() => root.render(<AssistantDialog />));
110 expect(q('[data-testid="assistant-dialog"]')).toBeNull();
111 });
112
113 it("extract-actions: disabled until text, then runs + lists tasks + toasts", async () => {
114 mocks.extractActions.mockResolvedValue({
115 tasks: [
116 { id: "t1", title: "Ship the whiteboard export" },
117 { id: "t2", title: "Review the Q3 budget", dueDate: "2026-11-01" }
118 ],
119 tokensIn: 10,
120 tokensOut: 20
121 });
122 act(() => root.render(<AssistantDialog />));
123 await open("extract-actions");
124
125 const input = q('[data-testid="assistant-input"]') as HTMLTextAreaElement;
126 const run = q('[data-testid="assistant-run"]') as HTMLButtonElement;
127 expect(input).toBeTruthy();
128 expect(run.disabled).toBe(true);
129
130 await act(async () => {
131 type(input, "- Ship the whiteboard export\n- Review the Q3 budget");
132 });
133 expect(run.disabled).toBe(false);
134
135 await act(async () => {
136 run.click();
137 });
138 expect(mocks.extractActions).toHaveBeenCalledWith("ws1", "- Ship the whiteboard export\n- Review the Q3 budget", expect.anything());
139 expect(mocks.toast).toHaveBeenCalledWith("success", "Created 2 tasks", "From the AI assistant");
140
141 const result = q('[data-testid="assistant-result"]');
142 expect(result?.textContent).toContain("Ship the whiteboard export");
143 expect(result?.textContent).toContain("due 2026-11-01");
144 });
145
146 it("a 422 shows the error banner + Retry (nothing written server-side)", async () => {
147
148
149 mocks.extractActions.mockRejectedValue(
150 new ApiError("VALIDATION_ERROR", 422, "The AI returned an invalid structure.", [{ reason: "ai_invalid_structure" }])
151 );
152 act(() => root.render(<AssistantDialog />));
153 await open("extract-actions");
154
155 const input = q('[data-testid="assistant-input"]') as HTMLTextAreaElement;
156 await act(async () => {
157 type(input, "- one item");
158 });
159 await act(async () => {
160 (q('[data-testid="assistant-run"]') as HTMLButtonElement).click();
161 });
162
163 const banner = q('[data-testid="assistant-error-banner"]');
164 expect(banner?.textContent).toMatch(/invalid result/i);
165
166
167 const retry = [...host.querySelectorAll("button")].find((b) => b.textContent === "Retry");
168 expect(retry).toBeTruthy();
169 mocks.extractActions.mockClear();
170 await act(async () => {
171 (retry as HTMLButtonElement).click();
172 });
173 expect(mocks.extractActions).toHaveBeenCalledTimes(1);
174 });
175
176 it("find-stale: sends the days selector (default 90) and renders page links", async () => {
177 mocks.findStale.mockResolvedValue({
178 pages: [{ pageId: "s1", title: "Old Doc", updatedAt: "2026-01-01T00:00:00Z", reason: "No activity." }],
179 considered: 5,
180 tokensIn: 10,
181 tokensOut: 20
182 });
183 act(() => root.render(<AssistantDialog />));
184 await open("find-stale");
185
186 await act(async () => {
187 (q('[data-testid="assistant-run"]') as HTMLButtonElement).click();
188 });
189 expect(mocks.findStale).toHaveBeenCalledWith("ws1", { days: 90 }, expect.anything());
190 const result = q('[data-testid="assistant-result"]');
191 expect(result?.textContent).toContain("Old Doc");
192 expect(result?.textContent).toContain("Not updated in 90+ days");
193 });
194
195 it("suggest-metadata: renders the suggestion; Apply calls apply-metadata; Dismiss does not", async () => {
196 mocks.suggestMetadata.mockResolvedValue({
197 suggestionId: "asm1",
198 pageId: "p1",
199 current: { title: "Old title", description: null },
200 suggested: { title: "Revised: Old title", description: "A better description." },
201 tokensIn: 10,
202 tokensOut: 20
203 });
204 mocks.applyMetadata.mockResolvedValue({ pageId: "p1", applied: ["title", "description"] });
205 act(() => root.render(<AssistantDialog />));
206 await open("suggest-metadata", "p1");
207
208 await act(async () => {
209 (q('[data-testid="assistant-run"]') as HTMLButtonElement).click();
210 });
211 expect(mocks.suggestMetadata).toHaveBeenCalledWith("ws1", "p1", expect.anything());
212 const result = q('[data-testid="assistant-result"]');
213 expect(result?.textContent).toContain("Revised: Old title");
214 expect(result?.textContent).toContain("A better description.");
215
216
217 await act(async () => {
218 (q('[data-testid="assistant-apply"]') as HTMLButtonElement).click();
219 });
220 expect(mocks.applyMetadata).toHaveBeenCalledWith("ws1", "asm1", { title: "Revised: Old title", description: "A better description." });
221 expect(mocks.toast).toHaveBeenCalledWith("success", "Metadata applied", "Title and/or description updated");
222 });
223
224 it("suggest-metadata: Dismiss closes without calling apply-metadata", async () => {
225 mocks.suggestMetadata.mockResolvedValue({
226 suggestionId: "asm2",
227 pageId: "p1",
228 current: { title: "Old title", description: null },
229 suggested: { title: "New title" },
230 tokensIn: 10,
231 tokensOut: 20
232 });
233 act(() => root.render(<AssistantDialog />));
234 await open("suggest-metadata", "p1");
235
236 await act(async () => {
237 (q('[data-testid="assistant-run"]') as HTMLButtonElement).click();
238 });
239 await act(async () => {
240 (q('[data-testid="assistant-dismiss"]') as HTMLButtonElement).click();
241 });
242 expect(mocks.setAssistant).toHaveBeenCalledWith(false, null);
243 expect(mocks.applyMetadata).not.toHaveBeenCalled();
244 });
245});
246