import { describe, it, expect, vi } from "vitest"; import { renderToIR } from "@copilotkit/channels-ui"; import { FileIssueModal, fileIssueSubmit, issueFromValues, FILE_ISSUE_CALLBACK, } from "../file-issue.js"; import type { BotNode } from "@copilotkit/channels-ui"; function tags(node: BotNode | unknown, acc: string[] = []): string[] { if (!node || typeof node !== "object") return acc; const n = node as BotNode; if (typeof n.type === "string") acc.push(n.type); for (const c of (n.props?.children as BotNode[] | undefined) ?? []) { tags(c, acc); } return acc; } describe("FileIssueModal", () => { it("rich variant (Slack) includes selects and radios", () => { const ir = renderToIR(FileIssueModal({ rich: true })); const root = ir[0]!; const t = tags(root); expect(root.type).toBe("modal"); expect(root.props.callbackId).toBe(FILE_ISSUE_CALLBACK); expect(t).toContain("modal_text_input"); expect(t).toContain("modal_select"); expect(t).toContain("modal_radio"); }); it("text-only variant (Discord) drops selects/radios, ≤5 inputs", () => { const ir = renderToIR(FileIssueModal({ rich: false })); const root = ir[0]!; const t = tags(root); expect(t).not.toContain("modal_select"); expect(t).not.toContain("modal_radio"); expect(t.filter((x) => x === "modal_text_input").length).toBe(2); }); }); describe("issueFromValues", () => { it("reads submitted values", () => { expect( issueFromValues({ title: "Login broken", description: "500 on submit", type: "bug", priority: "High", }), ).toEqual({ title: "Login broken", description: "500 on submit", type: "bug", priority: "High", }); }); it("applies defaults when controls were absent (Discord text-only)", () => { expect(issueFromValues({ title: "X", description: "Y" })).toEqual({ title: "X", description: "Y", type: "bug", priority: "Medium", }); }); }); describe("fileIssueSubmit", () => { it("returns a title error and does not run the agent on a blank title", async () => { const thread = { runAgent: vi.fn(() => new Promise(() => {})) }; const result = await fileIssueSubmit({ values: { title: "" }, thread, user: { id: "U1" }, } as never); expect(result).toEqual({ errors: { title: expect.any(String) } }); expect(thread.runAgent).not.toHaveBeenCalled(); }); it("treats a whitespace-only title as blank", async () => { const thread = { runAgent: vi.fn(() => new Promise(() => {})) }; const result = await fileIssueSubmit({ values: { title: " ", description: "x" }, thread, user: { id: "U1" }, } as never); expect(result).toEqual({ errors: { title: expect.any(String) } }); expect(thread.runAgent).not.toHaveBeenCalled(); }); it("resolves immediately even though runAgent never settles (fire-and-forget)", async () => { // A never-resolving runAgent: if the handler awaited it, this would hang and // the test would time out — that is the regression guard for the ~3s ack bug. const thread = { runAgent: vi.fn(() => new Promise(() => {})) }; await expect( fileIssueSubmit({ values: { title: "T", description: "D", type: "bug", priority: "High" }, thread, user: { id: "U1" }, } as never), ).resolves.toBeUndefined(); expect(thread.runAgent).toHaveBeenCalledTimes(1); }); it("acks without running the agent when there is no thread", async () => { await expect( fileIssueSubmit({ values: { title: "T", description: "D" }, thread: undefined, user: { id: "U1" }, } as never), ).resolves.toBeUndefined(); }); it("posts a failure message to the thread when runAgent rejects", async () => { const post = vi.fn().mockResolvedValue({ id: "m1" }); const thread = { runAgent: vi.fn(() => Promise.reject(new Error("LLM timeout"))), post, }; await fileIssueSubmit({ values: { title: "T", description: "D", type: "bug", priority: "High" }, thread, user: { id: "U1" }, } as never); // Let the fire-and-forget .catch() run before asserting. await new Promise((r) => setTimeout(r, 0)); expect(post).toHaveBeenCalledWith( expect.stringMatching(/couldn.t file|try again/i), ); }); });