mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-25 01:01:18 +00:00
Revert to pre-AI-SDK state (commit67e568f6b) This commit reverts the codebase to the state before AI SDK migration work began. Target commit:67e568f6b- refactor: replace fetch_instructions with skill tool and built-in skills (#10913) Date: January 29, 2026 This removes approximately 152 commits of AI SDK migration work. A follow-up PR will add back bug fixes and features that are unrelated to AI SDK. Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1154 lines
30 KiB
TypeScript
1154 lines
30 KiB
TypeScript
// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/ChatView.spec.tsx
|
|
|
|
import React from "react"
|
|
import { render, waitFor, act, fireEvent } from "@/utils/test-utils"
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
|
|
|
import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext"
|
|
import { vscode } from "@src/utils/vscode"
|
|
|
|
import ChatView, { ChatViewProps } from "../ChatView"
|
|
|
|
// Define minimal types needed for testing
|
|
interface ClineMessage {
|
|
type: "say" | "ask"
|
|
say?: string
|
|
ask?: string
|
|
ts: number
|
|
text?: string
|
|
partial?: boolean
|
|
}
|
|
|
|
interface ExtensionState {
|
|
version: string
|
|
clineMessages: ClineMessage[]
|
|
taskHistory: any[]
|
|
shouldShowAnnouncement: boolean
|
|
allowedCommands: string[]
|
|
alwaysAllowExecute: boolean
|
|
[key: string]: any
|
|
}
|
|
|
|
// Mock vscode API
|
|
vi.mock("@src/utils/vscode", () => ({
|
|
vscode: {
|
|
postMessage: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
// Mock use-sound hook
|
|
const mockPlayFunction = vi.fn()
|
|
vi.mock("use-sound", () => ({
|
|
default: vi.fn().mockImplementation(() => {
|
|
return [mockPlayFunction]
|
|
}),
|
|
}))
|
|
|
|
// Mock components that use ESM dependencies
|
|
vi.mock("../BrowserSessionRow", () => ({
|
|
default: function MockBrowserSessionRow({ messages }: { messages: ClineMessage[] }) {
|
|
return <div data-testid="browser-session">{JSON.stringify(messages)}</div>
|
|
},
|
|
}))
|
|
|
|
vi.mock("../ChatRow", () => ({
|
|
default: function MockChatRow({ message }: { message: ClineMessage }) {
|
|
return <div data-testid="chat-row">{JSON.stringify(message)}</div>
|
|
},
|
|
}))
|
|
|
|
vi.mock("../AutoApproveMenu", () => ({
|
|
default: () => null,
|
|
}))
|
|
|
|
// Mock react-virtuoso to render items directly without virtualization
|
|
// This allows tests to verify items rendered in the chat list
|
|
vi.mock("react-virtuoso", () => ({
|
|
Virtuoso: function MockVirtuoso({
|
|
data,
|
|
itemContent,
|
|
}: {
|
|
data: ClineMessage[]
|
|
itemContent: (index: number, item: ClineMessage) => React.ReactNode
|
|
}) {
|
|
return (
|
|
<div data-testid="virtuoso-item-list">
|
|
{data.map((item, index) => (
|
|
<div key={item.ts} data-testid={`virtuoso-item-${index}`}>
|
|
{itemContent(index, item)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
},
|
|
}))
|
|
|
|
// Mock VersionIndicator - returns null by default to prevent rendering in tests
|
|
vi.mock("../../common/VersionIndicator", () => ({
|
|
default: vi.fn(() => null),
|
|
}))
|
|
|
|
// Get the mock function after the module is mocked
|
|
const mockVersionIndicator = vi.mocked((await import("../../common/VersionIndicator")).default)
|
|
|
|
vi.mock("../Announcement", () => ({
|
|
default: function MockAnnouncement({ hideAnnouncement }: { hideAnnouncement: () => void }) {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const React = require("react")
|
|
return React.createElement(
|
|
"div",
|
|
{ "data-testid": "announcement-modal" },
|
|
React.createElement("div", null, "What's New"),
|
|
React.createElement("button", { onClick: hideAnnouncement }, "Close"),
|
|
)
|
|
},
|
|
}))
|
|
|
|
// Mock DismissibleUpsell component
|
|
vi.mock("@/components/common/DismissibleUpsell", () => ({
|
|
default: function MockDismissibleUpsell({ children }: { children: React.ReactNode }) {
|
|
return <div data-testid="dismissible-upsell">{children}</div>
|
|
},
|
|
}))
|
|
|
|
// Mock QueuedMessages component
|
|
vi.mock("../QueuedMessages", () => ({
|
|
QueuedMessages: function MockQueuedMessages({
|
|
queue = [],
|
|
onRemove,
|
|
}: {
|
|
queue?: Array<{ id: string; text: string; images?: string[] }>
|
|
onRemove?: (index: number) => void
|
|
onUpdate?: (index: number, newText: string) => void
|
|
}) {
|
|
if (!queue || queue.length === 0) {
|
|
return null
|
|
}
|
|
return (
|
|
<div data-testid="queued-messages">
|
|
{queue.map((msg, index) => (
|
|
<div key={msg.id}>
|
|
<span>{msg.text}</span>
|
|
<button aria-label="Remove message" onClick={() => onRemove?.(index)}>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
},
|
|
}))
|
|
|
|
// Mock RooTips component
|
|
vi.mock("@src/components/welcome/RooTips", () => ({
|
|
default: function MockRooTips() {
|
|
return <div data-testid="roo-tips">Tips content</div>
|
|
},
|
|
}))
|
|
|
|
// Mock RooHero component
|
|
vi.mock("@src/components/welcome/RooHero", () => ({
|
|
default: function MockRooHero() {
|
|
return <div data-testid="roo-hero">Hero content</div>
|
|
},
|
|
}))
|
|
|
|
// Mock TelemetryBanner component
|
|
vi.mock("../common/TelemetryBanner", () => ({
|
|
default: function MockTelemetryBanner() {
|
|
return null // Don't render anything to avoid interference
|
|
},
|
|
}))
|
|
|
|
// Mock i18n
|
|
vi.mock("react-i18next", () => ({
|
|
useTranslation: () => ({
|
|
t: (key: string, options?: any) => {
|
|
if (key === "chat:versionIndicator.ariaLabel" && options?.version) {
|
|
return `Version ${options.version}`
|
|
}
|
|
return key
|
|
},
|
|
}),
|
|
initReactI18next: {
|
|
type: "3rdParty",
|
|
init: () => {},
|
|
},
|
|
Trans: ({ i18nKey, children }: { i18nKey: string; children?: React.ReactNode }) => {
|
|
return <>{children || i18nKey}</>
|
|
},
|
|
}))
|
|
|
|
interface ChatTextAreaProps {
|
|
onSend: () => void
|
|
inputValue?: string
|
|
setInputValue?: (value: string) => void
|
|
sendingDisabled?: boolean
|
|
placeholderText?: string
|
|
selectedImages?: string[]
|
|
shouldDisableImages?: boolean
|
|
}
|
|
|
|
const mockInputRef = React.createRef<HTMLInputElement>()
|
|
const mockFocus = vi.fn()
|
|
|
|
vi.mock("../ChatTextArea", () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const mockReact = require("react")
|
|
|
|
const ChatTextAreaComponent = mockReact.forwardRef(function MockChatTextArea(
|
|
props: ChatTextAreaProps,
|
|
ref: React.ForwardedRef<{ focus: () => void }>,
|
|
) {
|
|
// Use useImperativeHandle to expose the mock focus method
|
|
mockReact.useImperativeHandle(ref, () => ({
|
|
focus: mockFocus,
|
|
}))
|
|
|
|
return (
|
|
<div data-testid="chat-textarea">
|
|
<input
|
|
ref={mockInputRef}
|
|
type="text"
|
|
value={props.inputValue || ""}
|
|
onChange={(e) => {
|
|
// Use parent's setInputValue if available
|
|
if (props.setInputValue) {
|
|
props.setInputValue(e.target.value)
|
|
}
|
|
}}
|
|
onKeyDown={(e) => {
|
|
// Only call onSend when Enter is pressed (simulating real behavior)
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault()
|
|
props.onSend()
|
|
}
|
|
}}
|
|
data-sending-disabled={props.sendingDisabled}
|
|
/>
|
|
</div>
|
|
)
|
|
})
|
|
|
|
return {
|
|
default: ChatTextAreaComponent,
|
|
ChatTextArea: ChatTextAreaComponent, // Export as named export too
|
|
}
|
|
})
|
|
|
|
// Mock VSCode components
|
|
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
|
|
VSCodeButton: function MockVSCodeButton({
|
|
children,
|
|
onClick,
|
|
appearance,
|
|
}: {
|
|
children: React.ReactNode
|
|
onClick?: () => void
|
|
appearance?: string
|
|
}) {
|
|
return (
|
|
<button onClick={onClick} data-appearance={appearance}>
|
|
{children}
|
|
</button>
|
|
)
|
|
},
|
|
VSCodeTextField: function MockVSCodeTextField({
|
|
value,
|
|
onInput,
|
|
placeholder,
|
|
}: {
|
|
value?: string
|
|
onInput?: (e: { target: { value: string } }) => void
|
|
placeholder?: string
|
|
}) {
|
|
return (
|
|
<input
|
|
type="text"
|
|
value={value}
|
|
onChange={(e) => onInput?.({ target: { value: e.target.value } })}
|
|
placeholder={placeholder}
|
|
/>
|
|
)
|
|
},
|
|
VSCodeLink: function MockVSCodeLink({ children, href }: { children: React.ReactNode; href?: string }) {
|
|
return <a href={href}>{children}</a>
|
|
},
|
|
}))
|
|
|
|
// Mock window.postMessage to trigger state hydration
|
|
const mockPostMessage = (state: Partial<ExtensionState>) => {
|
|
window.postMessage(
|
|
{
|
|
type: "state",
|
|
state: {
|
|
version: "1.0.0",
|
|
clineMessages: [],
|
|
taskHistory: [],
|
|
shouldShowAnnouncement: false,
|
|
allowedCommands: [],
|
|
alwaysAllowExecute: false,
|
|
cloudIsAuthenticated: false,
|
|
telemetrySetting: "enabled",
|
|
...state,
|
|
},
|
|
},
|
|
"*",
|
|
)
|
|
}
|
|
|
|
const defaultProps: ChatViewProps = {
|
|
isHidden: false,
|
|
showAnnouncement: false,
|
|
hideAnnouncement: () => {},
|
|
}
|
|
|
|
const queryClient = new QueryClient()
|
|
|
|
const renderChatView = (props: Partial<ChatViewProps> = {}) => {
|
|
return render(
|
|
<ExtensionStateContextProvider>
|
|
<QueryClientProvider client={queryClient}>
|
|
<ChatView {...defaultProps} {...props} />
|
|
</QueryClientProvider>
|
|
</ExtensionStateContextProvider>,
|
|
)
|
|
}
|
|
|
|
describe("ChatView - Sound Playing Tests", () => {
|
|
beforeEach(() => vi.clearAllMocks())
|
|
|
|
it("plays celebration sound for completion results", async () => {
|
|
renderChatView()
|
|
|
|
// First hydrate state with initial task
|
|
mockPostMessage({
|
|
soundEnabled: true, // Enable sound
|
|
clineMessages: [
|
|
{
|
|
type: "say",
|
|
say: "task",
|
|
ts: Date.now() - 2000,
|
|
text: "Initial task",
|
|
},
|
|
],
|
|
})
|
|
|
|
// Clear any initial calls
|
|
mockPlayFunction.mockClear()
|
|
|
|
// Add completion result
|
|
mockPostMessage({
|
|
soundEnabled: true, // Enable sound
|
|
clineMessages: [
|
|
{
|
|
type: "say",
|
|
say: "task",
|
|
ts: Date.now() - 2000,
|
|
text: "Initial task",
|
|
},
|
|
{
|
|
type: "ask",
|
|
ask: "completion_result",
|
|
ts: Date.now(),
|
|
text: "Task completed successfully",
|
|
partial: false, // Ensure it's not partial
|
|
},
|
|
],
|
|
})
|
|
|
|
// Wait for sound to be played
|
|
await waitFor(() => {
|
|
expect(mockPlayFunction).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
it("plays progress_loop sound for api failures", async () => {
|
|
renderChatView()
|
|
|
|
// First hydrate state with initial task
|
|
mockPostMessage({
|
|
soundEnabled: true, // Enable sound
|
|
clineMessages: [
|
|
{
|
|
type: "say",
|
|
say: "task",
|
|
ts: Date.now() - 2000,
|
|
text: "Initial task",
|
|
},
|
|
],
|
|
})
|
|
|
|
// Clear any initial calls
|
|
mockPlayFunction.mockClear()
|
|
|
|
// Add API failure
|
|
mockPostMessage({
|
|
soundEnabled: true, // Enable sound
|
|
clineMessages: [
|
|
{
|
|
type: "say",
|
|
say: "task",
|
|
ts: Date.now() - 2000,
|
|
text: "Initial task",
|
|
},
|
|
{
|
|
type: "ask",
|
|
ask: "api_req_failed",
|
|
ts: Date.now(),
|
|
text: "API request failed",
|
|
partial: false, // Ensure it's not partial
|
|
},
|
|
],
|
|
})
|
|
|
|
// Wait for sound to be played
|
|
await waitFor(() => {
|
|
expect(mockPlayFunction).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
it("does not play sound when resuming a task from history", () => {
|
|
renderChatView()
|
|
|
|
// Clear any initial calls
|
|
mockPlayFunction.mockClear()
|
|
|
|
// Hydrate state with a task that has a resumeTaskId (indicating it's resumed from history)
|
|
mockPostMessage({
|
|
resumeTaskId: "task-123",
|
|
clineMessages: [
|
|
{
|
|
type: "say",
|
|
say: "task",
|
|
ts: Date.now() - 2000,
|
|
text: "Resumed task",
|
|
},
|
|
{
|
|
type: "ask",
|
|
ask: "tool",
|
|
ts: Date.now(),
|
|
text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
|
|
},
|
|
],
|
|
})
|
|
|
|
// Should not play sound when resuming from history
|
|
expect(mockPlayFunction).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it("does not play sound when resuming a completed task from history", () => {
|
|
renderChatView()
|
|
|
|
// Clear any initial calls
|
|
mockPlayFunction.mockClear()
|
|
|
|
// Hydrate state with a completed task that has a resumeTaskId
|
|
mockPostMessage({
|
|
resumeTaskId: "task-123",
|
|
clineMessages: [
|
|
{
|
|
type: "say",
|
|
say: "task",
|
|
ts: Date.now() - 2000,
|
|
text: "Resumed task",
|
|
},
|
|
{
|
|
type: "ask",
|
|
ask: "completion_result",
|
|
ts: Date.now(),
|
|
text: "Task completed",
|
|
},
|
|
],
|
|
})
|
|
|
|
// Should not play sound for completion when resuming from history
|
|
expect(mockPlayFunction).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe("ChatView - Focus Grabbing Tests", () => {
|
|
beforeEach(() => vi.clearAllMocks())
|
|
|
|
it("does not grab focus when follow-up question presented", async () => {
|
|
const { getByTestId } = renderChatView()
|
|
|
|
// First hydrate state with initial task
|
|
mockPostMessage({
|
|
clineMessages: [
|
|
{
|
|
type: "say",
|
|
say: "task",
|
|
ts: Date.now() - 2000,
|
|
text: "Initial task",
|
|
},
|
|
],
|
|
})
|
|
|
|
// Wait for the component to fully render and settle before clearing mocks
|
|
await waitFor(() => {
|
|
expect(getByTestId("chat-textarea")).toBeInTheDocument()
|
|
})
|
|
|
|
// Wait for the debounced focus effect to fire (50ms debounce + buffer for CI variability)
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
})
|
|
|
|
// Clear any initial calls after state has settled
|
|
mockFocus.mockClear()
|
|
|
|
// Add follow-up question
|
|
mockPostMessage({
|
|
clineMessages: [
|
|
{
|
|
type: "say",
|
|
say: "task",
|
|
ts: Date.now() - 2000,
|
|
text: "Initial task",
|
|
},
|
|
{
|
|
type: "ask",
|
|
ask: "followup",
|
|
ts: Date.now(),
|
|
text: "Should I continue?",
|
|
},
|
|
],
|
|
})
|
|
|
|
// Wait for state update to complete
|
|
await waitFor(() => {
|
|
expect(getByTestId("chat-textarea")).toBeInTheDocument()
|
|
})
|
|
|
|
// Should not grab focus for follow-up questions
|
|
expect(mockFocus).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe("ChatView - Version Indicator Tests", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
// Reset the mock to return null by default
|
|
mockVersionIndicator.mockReturnValue(null)
|
|
})
|
|
|
|
it("displays version indicator button", () => {
|
|
// Mock VersionIndicator to return a button
|
|
mockVersionIndicator.mockReturnValue(
|
|
React.createElement("button", {
|
|
"data-testid": "version-indicator",
|
|
"aria-label": "Version 1.0.0",
|
|
className: "version-indicator-button",
|
|
}),
|
|
)
|
|
|
|
const { getByTestId } = renderChatView()
|
|
|
|
// Hydrate state with no active task
|
|
mockPostMessage({
|
|
version: "1.0.0",
|
|
clineMessages: [],
|
|
})
|
|
|
|
// Should display version indicator
|
|
expect(getByTestId("version-indicator")).toBeInTheDocument()
|
|
})
|
|
|
|
it("opens announcement modal when version indicator is clicked", async () => {
|
|
// Mock VersionIndicator to return a button with onClick
|
|
mockVersionIndicator.mockImplementation(({ onClick }: { onClick?: () => void }) =>
|
|
React.createElement("button", {
|
|
"data-testid": "version-indicator",
|
|
onClick,
|
|
}),
|
|
)
|
|
|
|
const { getByTestId, queryByTestId } = renderChatView({ showAnnouncement: false })
|
|
|
|
// Hydrate state
|
|
mockPostMessage({
|
|
version: "1.0.0",
|
|
clineMessages: [],
|
|
})
|
|
|
|
// Wait for component to render
|
|
await waitFor(() => {
|
|
expect(getByTestId("version-indicator")).toBeInTheDocument()
|
|
})
|
|
|
|
// Click version indicator
|
|
const versionIndicator = getByTestId("version-indicator")
|
|
act(() => {
|
|
versionIndicator.click()
|
|
})
|
|
|
|
// Wait for announcement modal to appear
|
|
await waitFor(() => {
|
|
expect(queryByTestId("announcement-modal")).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it("version indicator has correct styling classes", () => {
|
|
// Mock VersionIndicator to return a button with specific classes
|
|
mockVersionIndicator.mockReturnValue(
|
|
React.createElement("button", {
|
|
"data-testid": "version-indicator",
|
|
className: "version-indicator-button absolute top-2 right-2",
|
|
}),
|
|
)
|
|
|
|
const { getByTestId } = renderChatView()
|
|
|
|
// Hydrate state
|
|
mockPostMessage({
|
|
version: "1.0.0",
|
|
clineMessages: [],
|
|
})
|
|
|
|
const versionIndicator = getByTestId("version-indicator")
|
|
expect(versionIndicator.className).toContain("version-indicator-button")
|
|
expect(versionIndicator.className).toContain("absolute")
|
|
expect(versionIndicator.className).toContain("top-2")
|
|
expect(versionIndicator.className).toContain("right-2")
|
|
})
|
|
|
|
it("version indicator has proper accessibility attributes", () => {
|
|
// Mock VersionIndicator to return a button with aria-label
|
|
mockVersionIndicator.mockReturnValue(
|
|
React.createElement("button", {
|
|
"data-testid": "version-indicator",
|
|
"aria-label": "Version 1.0.0",
|
|
role: "button",
|
|
}),
|
|
)
|
|
|
|
const { getByTestId } = renderChatView()
|
|
|
|
// Hydrate state
|
|
mockPostMessage({
|
|
version: "1.0.0",
|
|
clineMessages: [],
|
|
})
|
|
|
|
const versionIndicator = getByTestId("version-indicator")
|
|
expect(versionIndicator.getAttribute("aria-label")).toBe("Version 1.0.0")
|
|
expect(versionIndicator.getAttribute("role")).toBe("button")
|
|
})
|
|
|
|
it("does not display version indicator when there is an active task", () => {
|
|
// Mock VersionIndicator to return null (simulating hidden state)
|
|
mockVersionIndicator.mockReturnValue(null)
|
|
|
|
const { queryByTestId } = renderChatView()
|
|
|
|
// Hydrate state with active task
|
|
mockPostMessage({
|
|
version: "1.0.0",
|
|
clineMessages: [
|
|
{
|
|
type: "say",
|
|
say: "task",
|
|
ts: Date.now(),
|
|
text: "Active task",
|
|
},
|
|
],
|
|
})
|
|
|
|
// Should not display version indicator during active task
|
|
expect(queryByTestId("version-indicator")).not.toBeInTheDocument()
|
|
})
|
|
|
|
it("displays version indicator only on welcome screen (no task)", () => {
|
|
// Mock VersionIndicator to return a button
|
|
mockVersionIndicator.mockReturnValue(React.createElement("button", { "data-testid": "version-indicator" }))
|
|
|
|
const { queryByTestId } = renderChatView()
|
|
|
|
// Hydrate state with no active task
|
|
mockPostMessage({
|
|
version: "1.0.0",
|
|
clineMessages: [],
|
|
})
|
|
|
|
// Should display version indicator on welcome screen
|
|
expect(queryByTestId("version-indicator")).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe("ChatView - DismissibleUpsell Display Tests", () => {
|
|
beforeEach(() => vi.clearAllMocks())
|
|
|
|
it("does not show DismissibleUpsell when user is authenticated to Cloud", () => {
|
|
const { queryByTestId } = renderChatView()
|
|
|
|
// Hydrate state with user authenticated to cloud
|
|
mockPostMessage({
|
|
cloudIsAuthenticated: true,
|
|
taskHistory: [
|
|
{ id: "1", ts: Date.now() - 3000 },
|
|
{ id: "2", ts: Date.now() - 2000 },
|
|
{ id: "3", ts: Date.now() - 1000 },
|
|
{ id: "4", ts: Date.now() },
|
|
],
|
|
clineMessages: [], // No active task
|
|
})
|
|
|
|
// Should not show DismissibleUpsell when authenticated
|
|
expect(queryByTestId("dismissible-upsell")).not.toBeInTheDocument()
|
|
})
|
|
|
|
it("does not show DismissibleUpsell when user has only run 3 tasks in their history", () => {
|
|
const { queryByTestId } = renderChatView()
|
|
|
|
// Hydrate state with user not authenticated but only 3 tasks
|
|
mockPostMessage({
|
|
cloudIsAuthenticated: false,
|
|
taskHistory: [
|
|
{ id: "1", ts: Date.now() - 2000 },
|
|
{ id: "2", ts: Date.now() - 1000 },
|
|
{ id: "3", ts: Date.now() },
|
|
],
|
|
clineMessages: [], // No active task
|
|
})
|
|
|
|
// Should not show DismissibleUpsell with less than 4 tasks
|
|
expect(queryByTestId("dismissible-upsell")).not.toBeInTheDocument()
|
|
})
|
|
|
|
it("shows DismissibleUpsell when user is not authenticated and has run 6 or more tasks", async () => {
|
|
const { getByTestId } = renderChatView()
|
|
|
|
// Hydrate state with user not authenticated and 4 tasks
|
|
mockPostMessage({
|
|
cloudIsAuthenticated: false,
|
|
taskHistory: [
|
|
{ id: "1", ts: Date.now() - 6000 },
|
|
{ id: "2", ts: Date.now() - 5000 },
|
|
{ id: "3", ts: Date.now() - 4000 },
|
|
{ id: "4", ts: Date.now() - 3000 },
|
|
{ id: "5", ts: Date.now() - 2000 },
|
|
{ id: "6", ts: Date.now() - 1000 },
|
|
{ id: "7", ts: Date.now() },
|
|
],
|
|
clineMessages: [], // No active task
|
|
})
|
|
|
|
// Wait for component to render and show DismissibleUpsell
|
|
await waitFor(() => {
|
|
expect(getByTestId("dismissible-upsell")).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it("does not show DismissibleUpsell when there is an active task (regardless of auth status)", async () => {
|
|
const { queryByTestId } = renderChatView()
|
|
|
|
// Hydrate state with active task
|
|
mockPostMessage({
|
|
cloudIsAuthenticated: false,
|
|
taskHistory: [
|
|
{ id: "1", ts: Date.now() - 3000 },
|
|
{ id: "2", ts: Date.now() - 2000 },
|
|
{ id: "3", ts: Date.now() - 1000 },
|
|
{ id: "4", ts: Date.now() },
|
|
],
|
|
clineMessages: [
|
|
{
|
|
type: "say",
|
|
say: "task",
|
|
ts: Date.now(),
|
|
text: "Active task",
|
|
},
|
|
],
|
|
})
|
|
|
|
// Wait for component to render with active task
|
|
await waitFor(() => {
|
|
// Should not show DismissibleUpsell during active task
|
|
expect(queryByTestId("dismissible-upsell")).not.toBeInTheDocument()
|
|
// Should not show RooTips either since the entire welcome screen is hidden during active tasks
|
|
expect(queryByTestId("roo-tips")).not.toBeInTheDocument()
|
|
// Should not show RooHero either since the entire welcome screen is hidden during active tasks
|
|
expect(queryByTestId("roo-hero")).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it("shows RooTips when user is authenticated (instead of DismissibleUpsell)", () => {
|
|
const { queryByTestId, getByTestId } = renderChatView()
|
|
|
|
// Hydrate state with user authenticated to cloud
|
|
mockPostMessage({
|
|
cloudIsAuthenticated: true,
|
|
taskHistory: [
|
|
{ id: "1", ts: Date.now() - 3000 },
|
|
{ id: "2", ts: Date.now() - 2000 },
|
|
{ id: "3", ts: Date.now() - 1000 },
|
|
{ id: "4", ts: Date.now() },
|
|
],
|
|
clineMessages: [], // No active task
|
|
})
|
|
|
|
// Should not show DismissibleUpsell but should show RooTips
|
|
expect(queryByTestId("dismissible-upsell")).not.toBeInTheDocument()
|
|
expect(getByTestId("roo-tips")).toBeInTheDocument()
|
|
})
|
|
|
|
it("shows RooTips when user has fewer than 6 tasks (instead of DismissibleUpsell)", () => {
|
|
const { queryByTestId, getByTestId } = renderChatView()
|
|
|
|
// Hydrate state with user not authenticated but fewer than 4 tasks
|
|
mockPostMessage({
|
|
cloudIsAuthenticated: false,
|
|
taskHistory: [
|
|
{ id: "1", ts: Date.now() - 2000 },
|
|
{ id: "2", ts: Date.now() - 1000 },
|
|
{ id: "3", ts: Date.now() },
|
|
],
|
|
clineMessages: [], // No active task
|
|
})
|
|
|
|
// Should not show DismissibleUpsell but should show RooTips
|
|
expect(queryByTestId("dismissible-upsell")).not.toBeInTheDocument()
|
|
expect(getByTestId("roo-tips")).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe("ChatView - Message Queueing Tests", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
// Reset the mock to clear any initial calls
|
|
vi.mocked(vscode.postMessage).mockClear()
|
|
})
|
|
|
|
it("shows sending is disabled when task is active", async () => {
|
|
const { getByTestId } = renderChatView()
|
|
|
|
// Hydrate state with active task that should disable sending
|
|
mockPostMessage({
|
|
clineMessages: [
|
|
{
|
|
type: "say",
|
|
say: "task",
|
|
ts: Date.now() - 1000,
|
|
text: "Task in progress",
|
|
},
|
|
{
|
|
type: "ask",
|
|
ask: "tool",
|
|
ts: Date.now(),
|
|
text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
|
|
partial: true, // Partial messages disable sending
|
|
},
|
|
],
|
|
})
|
|
|
|
// Wait for state to be updated and check that sending is disabled
|
|
await waitFor(() => {
|
|
const chatTextArea = getByTestId("chat-textarea")
|
|
const input = chatTextArea.querySelector("input")!
|
|
expect(input.getAttribute("data-sending-disabled")).toBe("true")
|
|
})
|
|
})
|
|
|
|
it("shows sending is enabled when no task is active", async () => {
|
|
const { getByTestId } = renderChatView()
|
|
|
|
// Hydrate state with completed task
|
|
mockPostMessage({
|
|
clineMessages: [
|
|
{
|
|
type: "ask",
|
|
ask: "completion_result",
|
|
ts: Date.now(),
|
|
text: "Task completed",
|
|
partial: false,
|
|
},
|
|
],
|
|
})
|
|
|
|
// Wait for state to be updated
|
|
await waitFor(() => {
|
|
expect(getByTestId("chat-textarea")).toBeInTheDocument()
|
|
})
|
|
|
|
// Check that sending is enabled
|
|
const chatTextArea = getByTestId("chat-textarea")
|
|
const input = chatTextArea.querySelector("input")!
|
|
expect(input.getAttribute("data-sending-disabled")).toBe("false")
|
|
})
|
|
|
|
it("queues messages when API request is in progress (spinner visible)", async () => {
|
|
const { getByTestId } = renderChatView()
|
|
|
|
// First hydrate state with initial task
|
|
mockPostMessage({
|
|
clineMessages: [
|
|
{
|
|
type: "say",
|
|
say: "task",
|
|
ts: Date.now() - 2000,
|
|
text: "Initial task",
|
|
},
|
|
],
|
|
})
|
|
|
|
// Clear any initial calls
|
|
vi.mocked(vscode.postMessage).mockClear()
|
|
|
|
// Add api_req_started without cost (spinner state - API request in progress)
|
|
mockPostMessage({
|
|
clineMessages: [
|
|
{
|
|
type: "say",
|
|
say: "task",
|
|
ts: Date.now() - 2000,
|
|
text: "Initial task",
|
|
},
|
|
{
|
|
type: "say",
|
|
say: "api_req_started",
|
|
ts: Date.now(),
|
|
text: JSON.stringify({ apiProtocol: "anthropic" }), // No cost = still streaming
|
|
},
|
|
],
|
|
})
|
|
|
|
// Wait for state to be updated
|
|
await waitFor(() => {
|
|
expect(getByTestId("chat-textarea")).toBeInTheDocument()
|
|
})
|
|
|
|
// Clear message calls before simulating user input
|
|
vi.mocked(vscode.postMessage).mockClear()
|
|
|
|
// Simulate user typing and sending a message during the spinner
|
|
const chatTextArea = getByTestId("chat-textarea")
|
|
const input = chatTextArea.querySelector("input")! as HTMLInputElement
|
|
|
|
// Trigger message send by simulating typing and Enter key press
|
|
await act(async () => {
|
|
// Use fireEvent to properly trigger React's onChange handler
|
|
fireEvent.change(input, { target: { value: "follow-up question during spinner" } })
|
|
|
|
// Simulate pressing Enter to send
|
|
fireEvent.keyDown(input, { key: "Enter", code: "Enter" })
|
|
})
|
|
|
|
// Verify that the message was queued, not sent as askResponse
|
|
await waitFor(() => {
|
|
expect(vscode.postMessage).toHaveBeenCalledWith({
|
|
type: "queueMessage",
|
|
text: "follow-up question during spinner",
|
|
images: [],
|
|
})
|
|
})
|
|
|
|
// Verify it was NOT sent as a direct askResponse (which would get lost)
|
|
expect(vscode.postMessage).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
type: "askResponse",
|
|
askResponse: "messageResponse",
|
|
}),
|
|
)
|
|
})
|
|
|
|
it("sends messages normally when API request is complete (cost present)", async () => {
|
|
const { getByTestId } = renderChatView()
|
|
|
|
// Hydrate state with completed API request (cost present)
|
|
mockPostMessage({
|
|
clineMessages: [
|
|
{
|
|
type: "say",
|
|
say: "task",
|
|
ts: Date.now() - 2000,
|
|
text: "Initial task",
|
|
},
|
|
{
|
|
type: "say",
|
|
say: "api_req_started",
|
|
ts: Date.now(),
|
|
text: JSON.stringify({
|
|
apiProtocol: "anthropic",
|
|
cost: 0.05, // Cost present = streaming complete
|
|
tokensIn: 100,
|
|
tokensOut: 50,
|
|
}),
|
|
},
|
|
{
|
|
type: "say",
|
|
say: "text",
|
|
ts: Date.now(),
|
|
text: "Response from API",
|
|
},
|
|
],
|
|
})
|
|
|
|
// Wait for state to be updated
|
|
await waitFor(() => {
|
|
expect(getByTestId("chat-textarea")).toBeInTheDocument()
|
|
})
|
|
|
|
// Clear message calls before simulating user input
|
|
vi.mocked(vscode.postMessage).mockClear()
|
|
|
|
// Simulate user sending a message when API is done
|
|
const chatTextArea = getByTestId("chat-textarea")
|
|
const input = chatTextArea.querySelector("input")! as HTMLInputElement
|
|
|
|
await act(async () => {
|
|
// Use fireEvent to properly trigger React's onChange handler
|
|
fireEvent.change(input, { target: { value: "follow-up after completion" } })
|
|
|
|
// Simulate pressing Enter to send
|
|
fireEvent.keyDown(input, { key: "Enter", code: "Enter" })
|
|
})
|
|
|
|
// Verify that the message was sent as askResponse, not queued
|
|
await waitFor(() => {
|
|
expect(vscode.postMessage).toHaveBeenCalledWith({
|
|
type: "askResponse",
|
|
askResponse: "messageResponse",
|
|
text: "follow-up after completion",
|
|
images: [],
|
|
})
|
|
})
|
|
|
|
// Verify it was NOT queued
|
|
expect(vscode.postMessage).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
type: "queueMessage",
|
|
}),
|
|
)
|
|
})
|
|
|
|
it("preserves message order when messages sent during queue drain", async () => {
|
|
const { getByTestId } = renderChatView()
|
|
|
|
// Hydrate state with API request in progress and existing queue
|
|
mockPostMessage({
|
|
clineMessages: [
|
|
{
|
|
type: "say",
|
|
say: "task",
|
|
ts: Date.now() - 2000,
|
|
text: "Initial task",
|
|
},
|
|
{
|
|
type: "say",
|
|
say: "api_req_started",
|
|
ts: Date.now(),
|
|
text: JSON.stringify({ apiProtocol: "anthropic" }), // No cost = still streaming
|
|
},
|
|
],
|
|
messageQueue: [
|
|
{ id: "msg1", text: "queued message 1", images: [] },
|
|
{ id: "msg2", text: "queued message 2", images: [] },
|
|
],
|
|
})
|
|
|
|
// Wait for state to be updated
|
|
await waitFor(() => {
|
|
expect(getByTestId("chat-textarea")).toBeInTheDocument()
|
|
})
|
|
|
|
// Clear message calls before simulating user input
|
|
vi.mocked(vscode.postMessage).mockClear()
|
|
|
|
// Simulate user sending a new message while queue has items
|
|
const chatTextArea = getByTestId("chat-textarea")
|
|
const input = chatTextArea.querySelector("input")! as HTMLInputElement
|
|
|
|
await act(async () => {
|
|
fireEvent.change(input, { target: { value: "message during queue drain" } })
|
|
fireEvent.keyDown(input, { key: "Enter", code: "Enter" })
|
|
})
|
|
|
|
// Verify that the new message was queued (not sent directly) to preserve order
|
|
await waitFor(() => {
|
|
expect(vscode.postMessage).toHaveBeenCalledWith({
|
|
type: "queueMessage",
|
|
text: "message during queue drain",
|
|
images: [],
|
|
})
|
|
})
|
|
|
|
// Verify it was NOT sent as askResponse (which would break ordering)
|
|
expect(vscode.postMessage).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
type: "askResponse",
|
|
askResponse: "messageResponse",
|
|
}),
|
|
)
|
|
})
|
|
})
|
|
|
|
describe("ChatView - Context Condensing Indicator Tests", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it("should add a condensing message to groupedMessages when isCondensing is true", async () => {
|
|
// This test verifies that when the condenseTaskContextStarted message is received,
|
|
// the isCondensing state is set to true and a synthetic condensing message is added
|
|
// to the grouped messages list
|
|
const { getByTestId, container } = renderChatView()
|
|
|
|
// First hydrate state with an active task
|
|
mockPostMessage({
|
|
clineMessages: [
|
|
{
|
|
type: "say",
|
|
say: "task",
|
|
ts: Date.now() - 2000,
|
|
text: "Initial task",
|
|
},
|
|
{
|
|
type: "say",
|
|
say: "api_req_started",
|
|
ts: Date.now() - 1000,
|
|
text: JSON.stringify({ apiProtocol: "anthropic" }),
|
|
},
|
|
],
|
|
})
|
|
|
|
// Wait for component to render
|
|
await waitFor(() => {
|
|
expect(getByTestId("chat-view")).toBeInTheDocument()
|
|
})
|
|
|
|
// Allow time for useEvent hook to register message listener
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
})
|
|
|
|
// Dispatch a MessageEvent directly to trigger the message handler
|
|
// This simulates the VSCode extension sending a message to the webview
|
|
await act(async () => {
|
|
const event = new MessageEvent("message", {
|
|
data: {
|
|
type: "condenseTaskContextStarted",
|
|
text: "test-task-id",
|
|
},
|
|
})
|
|
window.dispatchEvent(event)
|
|
// Wait for React state updates
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
})
|
|
|
|
// Check that groupedMessages now includes a condensing message
|
|
// With Virtuoso mocked, items render directly and we can find the ChatRow with partial condense_context message
|
|
await waitFor(
|
|
() => {
|
|
const rows = container.querySelectorAll('[data-testid="chat-row"]')
|
|
// Check for the actual message structure: partial condense_context message
|
|
const condensingRow = Array.from(rows).find((row) => {
|
|
const text = row.textContent || ""
|
|
return text.includes('"say":"condense_context"') && text.includes('"partial":true')
|
|
})
|
|
expect(condensingRow).toBeTruthy()
|
|
},
|
|
{ timeout: 2000 },
|
|
)
|
|
})
|
|
})
|