// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/ChatView.clear-approval-buttons.spec.tsx import React from "react" import { renderWithExtensionState, waitFor, act, fireEvent } from "@/utils/test-utils" import { vscode } from "@src/utils/vscode" import ChatView, { ChatViewProps } from "../ChatView" interface ClineMessage { type: "say" | "ask" say?: string ask?: string ts: number text?: string partial?: boolean isAnswered?: boolean checkpoint?: Record } vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: vi.fn(), }, })) const mockPlayFunction = vi.fn() vi.mock("use-sound", () => ({ default: vi.fn().mockImplementation(() => [mockPlayFunction]), })) vi.mock("../ChatRow", () => ({ default: function MockChatRow({ message, completionCheckpoint, }: { message: ClineMessage completionCheckpoint?: { ts: number; commitHash: string } }) { return (
{JSON.stringify(message)} {((message.type === "say" && message.say === "completion_result") || (message.type === "ask" && message.ask === "completion_result" && message.text)) && completionCheckpoint ? (
) : null}
) }, })) vi.mock("../AutoApproveMenu", () => ({ default: () => null, })) vi.mock("react-virtuoso", () => ({ Virtuoso: function MockVirtuoso({ data, itemContent, }: { data: ClineMessage[] itemContent: (index: number, item: ClineMessage) => React.ReactNode }) { return (
{data.map((item, index) => (
{itemContent(index, item)}
))}
) }, })) vi.mock("../../common/VersionIndicator", () => ({ default: vi.fn(() => null), })) vi.mock("@src/components/welcome/RooTips", () => ({ default: () =>
Tips content
, })) vi.mock("@src/components/welcome/RooHero", () => ({ default: () =>
Hero content
, })) vi.mock("../common/TelemetryBanner", () => ({ default: () => null, })) // The i18n mock returns the key itself, so button labels render as their keys. vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key, }), initReactI18next: { type: "3rdParty", init: () => {}, }, Trans: ({ i18nKey, children }: { i18nKey: string; children?: React.ReactNode }) => <>{children || i18nKey}, })) const RUN_BUTTON_LABEL = "chat:runCommand.title" const DENY_BUTTON_LABEL = "chat:reject.title" const RESUME_BUTTON_LABEL = "chat:resumeTask.title" const START_NEW_TASK_BUTTON_LABEL = "chat:startNewTask.title" const SEE_NEW_CHANGES_BUTTON_LABEL = "chat:seeNewChanges.title" const RESTORE_CHANGES_BUTTON_LABEL = "chat:restoreChanges.title" const hydrateState = (clineMessages: ClineMessage[]) => { window.postMessage( { type: "state", state: { version: "1.0.0", clineMessages, taskHistory: [], shouldShowAnnouncement: false, allowedCommands: [], alwaysAllowExecute: false, cloudIsAuthenticated: false, telemetrySetting: "enabled", }, }, "*", ) } const defaultProps: ChatViewProps = { isHidden: false, showAnnouncement: false, hideAnnouncement: () => {}, } const renderChatView = (props: Partial = {}) => renderWithExtensionState() const askMessages = (ask: string): ClineMessage[] => [ { type: "say", say: "task", ts: 1, text: "Initial task" }, { type: "ask", ask, ts: 2, text: "echo hi", partial: false }, ] const commandAsk = (): ClineMessage[] => askMessages("command") const autoApprovedCommandAsk = (): ClineMessage[] => [ { type: "say", say: "task", ts: 1, text: "Initial task" }, { type: "ask", ask: "command", ts: 2, text: "echo hi", partial: false, isAnswered: true }, ] const completionAskWithoutCheckpoint = (): ClineMessage[] => [ { type: "say", say: "task", ts: 1, text: "Initial task" }, { type: "ask", ask: "completion_result", ts: 2, text: "Task complete", partial: false }, ] const completionAskWithCheckpoint = (): ClineMessage[] => [ { type: "say", say: "task", ts: 1, text: "Initial task" }, { type: "say", say: "checkpoint_saved", ts: 2, text: "checkpoint-after-user-prompt", checkpoint: { suppressMessage: true }, }, { type: "say", say: "completion_result", ts: 3, text: "Task complete" }, { type: "ask", ask: "completion_result", ts: 4, text: "", partial: false }, ] const askOnlyCompletionWithCheckpoint = (): ClineMessage[] => [ { type: "say", say: "task", ts: 1, text: "Initial task" }, { type: "say", say: "checkpoint_saved", ts: 2, text: "checkpoint-after-user-prompt", checkpoint: { suppressMessage: true }, }, { type: "ask", ask: "completion_result", ts: 3, text: "Task complete", partial: false }, ] describe("ChatView approval button behavior", () => { beforeEach(() => vi.clearAllMocks()) it("shows Run/Deny buttons for a command ask that requires manual approval", async () => { const { queryByText } = renderChatView() await act(async () => { hydrateState(commandAsk()) }) await waitFor(() => { expect(queryByText(RUN_BUTTON_LABEL)).toBeInTheDocument() expect(queryByText(DENY_BUTTON_LABEL)).toBeInTheDocument() }) }) describe("approval feedback segments", () => { const typed = async (input: HTMLElement) => { fireEvent.input(input, { target: { value: "use @/ok" } }) } // The extension fills the box with text that has no user segments. const generated = async () => { window.postMessage({ type: "invoke", invoke: "setChatBoxMessage", text: "ctx @/secret" }, "*") } it.each([ { ask: "command", button: RUN_BUTTON_LABEL, askResponse: "yesButtonClicked" }, { ask: "command", button: DENY_BUTTON_LABEL, askResponse: "noButtonClicked" }, { ask: "resume_task", button: RESUME_BUTTON_LABEL, askResponse: "yesButtonClicked" }, ])("sends user segments for typed text with $ask $askResponse", async ({ ask, button, askResponse }) => { const { getByText, getByRole } = renderChatView() await act(async () => { hydrateState(askMessages(ask)) }) await waitFor(() => expect(getByText(button)).toBeInTheDocument()) await act(async () => { await typed(getByRole("textbox")) }) await act(async () => { fireEvent.click(getByText(button)) }) expect(vscode.postMessage).toHaveBeenCalledWith( expect.objectContaining({ type: "askResponse", askResponse, text: "use @/ok", segments: [{ text: "use @/ok", source: "user" }], }), ) }) it.each([ { button: RUN_BUTTON_LABEL, askResponse: "yesButtonClicked" }, { button: DENY_BUTTON_LABEL, askResponse: "noButtonClicked" }, ])("keeps generated text generated with $askResponse", async ({ button, askResponse }) => { const { getByText, getByRole } = renderChatView() await act(async () => { hydrateState(askMessages("command")) }) await waitFor(() => expect(getByText(button)).toBeInTheDocument()) await act(async () => { await generated() }) await waitFor(() => expect(getByRole("textbox")).toHaveValue("ctx @/secret")) await act(async () => { fireEvent.click(getByText(button)) }) expect(vscode.postMessage).toHaveBeenCalledWith( expect.objectContaining({ type: "askResponse", askResponse, text: "ctx @/secret", segments: [{ text: "ctx @/secret", source: "generated" }], }), ) }) }) it("never shows Run/Deny buttons when the command ask is already answered (auto-approved)", async () => { const { queryByText } = renderChatView() await act(async () => { hydrateState(autoApprovedCommandAsk()) }) // isAnswered:true on the message means the ask was resolved before the // webview rendered it -- buttons must never appear. await waitFor(() => { expect(queryByText(RUN_BUTTON_LABEL)).not.toBeInTheDocument() expect(queryByText(DENY_BUTTON_LABEL)).not.toBeInTheDocument() }) // No askResponse should have been sent, since the backend already responded. const askResponseCalls = (vscode.postMessage as ReturnType).mock.calls.filter( ([msg]) => msg?.type === "askResponse", ) expect(askResponseCalls).toHaveLength(0) }) it("shows Kilo-style completion checkpoint actions inline when a latest-prompt checkpoint is available", async () => { const { queryByText, queryByTestId } = renderChatView() await act(async () => { hydrateState(completionAskWithCheckpoint()) }) await waitFor(() => { expect(queryByTestId("completion-checkpoint-actions")).toBeInTheDocument() expect(queryByText(SEE_NEW_CHANGES_BUTTON_LABEL)).toBeInTheDocument() expect(queryByText(RESTORE_CHANGES_BUTTON_LABEL)).toBeInTheDocument() expect(queryByText(START_NEW_TASK_BUTTON_LABEL)).toBeInTheDocument() }) }) it("shows inline checkpoint actions for ask-only completion rows with text", async () => { const { queryByText, queryByTestId } = renderChatView() await act(async () => { hydrateState(askOnlyCompletionWithCheckpoint()) }) await waitFor(() => { expect(queryByTestId("completion-checkpoint-actions")).toBeInTheDocument() expect(queryByText(SEE_NEW_CHANGES_BUTTON_LABEL)).toBeInTheDocument() expect(queryByText(RESTORE_CHANGES_BUTTON_LABEL)).toBeInTheDocument() }) }) it("keeps Start New Task for completion results without a latest-prompt checkpoint", async () => { const { queryByText } = renderChatView() await act(async () => { hydrateState(completionAskWithoutCheckpoint()) }) await waitFor(() => { expect(queryByText(START_NEW_TASK_BUTTON_LABEL)).toBeInTheDocument() expect(queryByText(SEE_NEW_CHANGES_BUTTON_LABEL)).not.toBeInTheDocument() expect(queryByText(RESTORE_CHANGES_BUTTON_LABEL)).not.toBeInTheDocument() }) }) it("keeps Start New Task as the bottom completion action even when inline checkpoint actions are available", async () => { const { getByText } = renderChatView() await act(async () => { hydrateState(completionAskWithCheckpoint()) }) await waitFor(() => { expect(getByText(START_NEW_TASK_BUTTON_LABEL)).toBeInTheDocument() }) fireEvent.click(getByText(START_NEW_TASK_BUTTON_LABEL)) expect(vscode.postMessage).toHaveBeenCalledWith({ type: "clearTask" }) }) })