From 294d34a9e3051c56376f3577cebd20c6ba381169 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 11 Aug 2025 02:32:52 +0000 Subject: [PATCH] fix: handle null/undefined token values in ContextCondenseRow to prevent UI crash - Added null-safe operators for prevContextTokens, newContextTokens, and cost - Fallback to "0" for null/undefined token values - Added comprehensive test coverage for edge cases - Fixes #6914 --- .../components/chat/ContextCondenseRow.tsx | 7 +- .../__tests__/ContextCondenseRow.spec.tsx | 175 ++++++++++++++++++ 2 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 webview-ui/src/components/chat/__tests__/ContextCondenseRow.spec.tsx diff --git a/webview-ui/src/components/chat/ContextCondenseRow.tsx b/webview-ui/src/components/chat/ContextCondenseRow.tsx index 9664b03e00..8cd87df59b 100644 --- a/webview-ui/src/components/chat/ContextCondenseRow.tsx +++ b/webview-ui/src/components/chat/ContextCondenseRow.tsx @@ -33,9 +33,12 @@ export const ContextCondenseRow = ({ cost, prevContextTokens, newContextTokens, {t("chat:contextCondense.title")} - {prevContextTokens.toLocaleString()} → {newContextTokens.toLocaleString()} {t("tokens")} + {prevContextTokens?.toLocaleString() ?? "0"} → {newContextTokens?.toLocaleString() ?? "0"}{" "} + {t("tokens")} - 0 ? "opacity-100" : "opacity-0"}>${cost.toFixed(2)} + 0 ? "opacity-100" : "opacity-0"}> + ${(cost ?? 0).toFixed(2)} + diff --git a/webview-ui/src/components/chat/__tests__/ContextCondenseRow.spec.tsx b/webview-ui/src/components/chat/__tests__/ContextCondenseRow.spec.tsx new file mode 100644 index 0000000000..9340d001fa --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ContextCondenseRow.spec.tsx @@ -0,0 +1,175 @@ +// npx vitest run src/components/chat/__tests__/ContextCondenseRow.spec.tsx + +import React from "react" +import { render, fireEvent } from "@/utils/test-utils" +import { ContextCondenseRow, CondensingContextRow, CondenseContextErrorRow } from "../ContextCondenseRow" + +// Mock i18n +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +// Mock VSCode components +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeBadge: function MockVSCodeBadge({ children, className }: { children: React.ReactNode; className?: string }) { + return {children} + }, +})) + +// Mock Markdown component +vi.mock("../Markdown", () => ({ + Markdown: function MockMarkdown({ markdown }: { markdown: string }) { + return
{markdown}
+ }, +})) + +// Mock ProgressIndicator component +vi.mock("../ProgressIndicator", () => ({ + ProgressIndicator: function MockProgressIndicator() { + return
Loading...
+ }, +})) + +describe("ContextCondenseRow", () => { + it("renders with valid token values", () => { + const { getByText } = render( + , + ) + + expect(getByText("chat:contextCondense.title")).toBeInTheDocument() + expect(getByText(/1,000 → 500 tokens/)).toBeInTheDocument() + expect(getByText("$1.50")).toBeInTheDocument() + }) + + it("handles null token values gracefully", () => { + const { getByText } = render( + , + ) + + expect(getByText("chat:contextCondense.title")).toBeInTheDocument() + // Should display "0" for null values + expect(getByText(/0 → 0 tokens/)).toBeInTheDocument() + }) + + it("handles undefined token values gracefully", () => { + const { getByText } = render( + , + ) + + expect(getByText("chat:contextCondense.title")).toBeInTheDocument() + // Should display "0" for undefined values + expect(getByText(/0 → 0 tokens/)).toBeInTheDocument() + expect(getByText("$0.00")).toBeInTheDocument() + }) + + it("handles mixed null and valid token values", () => { + const { getByText } = render( + , + ) + + expect(getByText("chat:contextCondense.title")).toBeInTheDocument() + // Should display "2,000 → 0 tokens" for mixed values + expect(getByText(/2,000 → 0 tokens/)).toBeInTheDocument() + expect(getByText("$2.50")).toBeInTheDocument() + }) + + it("expands and collapses when clicked", () => { + const { getByText, queryByTestId } = render( + , + ) + + // Initially collapsed + expect(queryByTestId("markdown")).not.toBeInTheDocument() + + // Click to expand + const header = getByText("chat:contextCondense.title").parentElement?.parentElement + fireEvent.click(header!) + + // Should show summary + expect(queryByTestId("markdown")).toBeInTheDocument() + expect(getByText("Context condensed successfully")).toBeInTheDocument() + + // Click to collapse + fireEvent.click(header!) + + // Should hide summary + expect(queryByTestId("markdown")).not.toBeInTheDocument() + }) + + it("hides badge when cost is 0", () => { + const { container } = render( + , + ) + + const badge = container.querySelector(".opacity-0") + expect(badge).toBeInTheDocument() + }) + + it("shows badge when cost is greater than 0", () => { + const { container } = render( + , + ) + + const badge = container.querySelector(".opacity-100") + expect(badge).toBeInTheDocument() + }) +}) + +describe("CondensingContextRow", () => { + it("renders with progress indicator", () => { + const { getByText, getByTestId } = render() + + expect(getByTestId("progress-indicator")).toBeInTheDocument() + expect(getByText("chat:contextCondense.condensing")).toBeInTheDocument() + }) +}) + +describe("CondenseContextErrorRow", () => { + it("renders with error text", () => { + const errorText = "Failed to condense context: API error" + const { getByText } = render() + + expect(getByText("chat:contextCondense.errorHeader")).toBeInTheDocument() + expect(getByText(errorText)).toBeInTheDocument() + }) + + it("renders without error text", () => { + const { getByText, queryByText } = render() + + expect(getByText("chat:contextCondense.errorHeader")).toBeInTheDocument() + // Should not show any error text when not provided + expect(queryByText(/Failed/)).not.toBeInTheDocument() + }) +})