Spaces:
Paused
Paused
| // @vitest-environment jsdom | |
| import { act } from "react"; | |
| import type { ReactNode } from "react"; | |
| import { createRoot } from "react-dom/client"; | |
| import { MemoryRouter } from "react-router-dom"; | |
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| import { IssueChatThread, resolveAssistantMessageFoldedState } from "./IssueChatThread"; | |
| vi.mock("@assistant-ui/react", () => ({ | |
| AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>, | |
| ThreadPrimitive: { | |
| Root: ({ children, className }: { children: ReactNode; className?: string }) => ( | |
| <div data-testid="thread-root" className={className}>{children}</div> | |
| ), | |
| Viewport: ({ children, className }: { children: ReactNode; className?: string }) => ( | |
| <div data-testid="thread-viewport" className={className}>{children}</div> | |
| ), | |
| Empty: ({ children }: { children: ReactNode }) => <div>{children}</div>, | |
| Messages: () => <div data-testid="thread-messages" />, | |
| }, | |
| MessagePrimitive: { | |
| Root: ({ children }: { children: ReactNode }) => <div>{children}</div>, | |
| Content: () => null, | |
| Parts: () => null, | |
| }, | |
| useAui: () => ({ thread: () => ({ append: vi.fn() }) }), | |
| useAuiState: () => false, | |
| useMessage: () => ({ | |
| id: "message", | |
| role: "assistant", | |
| createdAt: new Date("2026-04-06T12:00:00.000Z"), | |
| content: [], | |
| metadata: { custom: {} }, | |
| status: { type: "complete" }, | |
| }), | |
| })); | |
| vi.mock("./transcript/useLiveRunTranscripts", () => ({ | |
| useLiveRunTranscripts: () => ({ | |
| transcriptByRun: new Map(), | |
| hasOutputForRun: () => false, | |
| }), | |
| })); | |
| vi.mock("./MarkdownBody", () => ({ | |
| MarkdownBody: ({ children }: { children: ReactNode }) => <div>{children}</div>, | |
| })); | |
| vi.mock("./MarkdownEditor", () => ({ | |
| MarkdownEditor: ({ | |
| value = "", | |
| onChange, | |
| placeholder, | |
| }: { | |
| value?: string; | |
| onChange?: (value: string) => void; | |
| placeholder?: string; | |
| }) => ( | |
| <textarea | |
| aria-label="Issue chat editor" | |
| placeholder={placeholder} | |
| value={value} | |
| onChange={(event) => onChange?.(event.target.value)} | |
| /> | |
| ), | |
| })); | |
| vi.mock("./InlineEntitySelector", () => ({ | |
| InlineEntitySelector: () => null, | |
| })); | |
| vi.mock("./Identity", () => ({ | |
| Identity: ({ name }: { name: string }) => <span>{name}</span>, | |
| })); | |
| vi.mock("./OutputFeedbackButtons", () => ({ | |
| OutputFeedbackButtons: () => null, | |
| })); | |
| vi.mock("./AgentIconPicker", () => ({ | |
| AgentIcon: () => null, | |
| })); | |
| vi.mock("./StatusBadge", () => ({ | |
| StatusBadge: ({ status }: { status: string }) => <span>{status}</span>, | |
| })); | |
| vi.mock("../hooks/usePaperclipIssueRuntime", () => ({ | |
| usePaperclipIssueRuntime: () => ({}), | |
| })); | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; | |
| describe("IssueChatThread", () => { | |
| let container: HTMLDivElement; | |
| beforeEach(() => { | |
| container = document.createElement("div"); | |
| document.body.appendChild(container); | |
| localStorage.clear(); | |
| }); | |
| afterEach(() => { | |
| container.remove(); | |
| vi.useRealTimers(); | |
| }); | |
| it("drops the count heading and does not use an internal scrollbox", () => { | |
| const root = createRoot(container); | |
| act(() => { | |
| root.render( | |
| <MemoryRouter> | |
| <IssueChatThread | |
| comments={[]} | |
| linkedRuns={[]} | |
| timelineEvents={[]} | |
| liveRuns={[]} | |
| onAdd={async () => {}} | |
| showComposer={false} | |
| enableLiveTranscriptPolling={false} | |
| /> | |
| </MemoryRouter>, | |
| ); | |
| }); | |
| expect(container.textContent).toContain("Jump to latest"); | |
| expect(container.textContent).not.toContain("Chat ("); | |
| const viewport = container.querySelector('[data-testid="thread-viewport"]') as HTMLDivElement | null; | |
| expect(viewport).not.toBeNull(); | |
| expect(viewport?.className).not.toContain("overflow-y-auto"); | |
| expect(viewport?.className).not.toContain("max-h-[70vh]"); | |
| act(() => { | |
| root.unmount(); | |
| }); | |
| }); | |
| it("supports the embedded read-only variant without the jump control", () => { | |
| const root = createRoot(container); | |
| act(() => { | |
| root.render( | |
| <MemoryRouter> | |
| <IssueChatThread | |
| comments={[]} | |
| linkedRuns={[]} | |
| timelineEvents={[]} | |
| liveRuns={[]} | |
| onAdd={async () => {}} | |
| showComposer={false} | |
| showJumpToLatest={false} | |
| variant="embedded" | |
| emptyMessage="No run output captured." | |
| enableLiveTranscriptPolling={false} | |
| /> | |
| </MemoryRouter>, | |
| ); | |
| }); | |
| expect(container.textContent).toContain("No run output captured."); | |
| expect(container.textContent).not.toContain("Jump to latest"); | |
| const viewport = container.querySelector('[data-testid="thread-viewport"]') as HTMLDivElement | null; | |
| expect(viewport?.className).toContain("space-y-3"); | |
| act(() => { | |
| root.unmount(); | |
| }); | |
| }); | |
| it("stores and restores the composer draft per issue key", () => { | |
| vi.useFakeTimers(); | |
| const root = createRoot(container); | |
| act(() => { | |
| root.render( | |
| <MemoryRouter> | |
| <IssueChatThread | |
| comments={[]} | |
| linkedRuns={[]} | |
| timelineEvents={[]} | |
| liveRuns={[]} | |
| onAdd={async () => {}} | |
| draftKey="issue-chat-draft:test-1" | |
| enableLiveTranscriptPolling={false} | |
| /> | |
| </MemoryRouter>, | |
| ); | |
| }); | |
| const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null; | |
| expect(editor).not.toBeNull(); | |
| expect(editor?.placeholder).toBe("Reply"); | |
| act(() => { | |
| const valueSetter = Object.getOwnPropertyDescriptor( | |
| window.HTMLTextAreaElement.prototype, | |
| "value", | |
| )?.set; | |
| valueSetter?.call(editor, "Draft survives refresh"); | |
| editor?.dispatchEvent(new Event("input", { bubbles: true })); | |
| }); | |
| act(() => { | |
| vi.advanceTimersByTime(900); | |
| }); | |
| expect(localStorage.getItem("issue-chat-draft:test-1")).toBe("Draft survives refresh"); | |
| act(() => { | |
| root.unmount(); | |
| }); | |
| const remount = createRoot(container); | |
| act(() => { | |
| remount.render( | |
| <MemoryRouter> | |
| <IssueChatThread | |
| comments={[]} | |
| linkedRuns={[]} | |
| timelineEvents={[]} | |
| liveRuns={[]} | |
| onAdd={async () => {}} | |
| draftKey="issue-chat-draft:test-1" | |
| enableLiveTranscriptPolling={false} | |
| /> | |
| </MemoryRouter>, | |
| ); | |
| }); | |
| const restoredEditor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null; | |
| expect(restoredEditor?.value).toBe("Draft survives refresh"); | |
| act(() => { | |
| remount.unmount(); | |
| }); | |
| }); | |
| it("folds chain-of-thought when the same message transitions from running to complete", () => { | |
| expect(resolveAssistantMessageFoldedState({ | |
| messageId: "message-1", | |
| currentFolded: false, | |
| isFoldable: true, | |
| previousMessageId: "message-1", | |
| previousIsFoldable: false, | |
| })).toBe(true); | |
| }); | |
| it("preserves a manually opened completed message across rerenders", () => { | |
| expect(resolveAssistantMessageFoldedState({ | |
| messageId: "message-1", | |
| currentFolded: false, | |
| isFoldable: true, | |
| previousMessageId: "message-1", | |
| previousIsFoldable: true, | |
| })).toBe(false); | |
| }); | |
| }); | |