From 22a873d1203c819d3a263be090604cdef9680f24 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 11 Aug 2025 02:34:16 +0000 Subject: [PATCH 1/2] fix: handle null/undefined token values in ContextCondenseRow to prevent UI crash - Added null/undefined checks for prevContextTokens, newContextTokens, and cost - Default to 0 when values are null or undefined - Added comprehensive test coverage for edge cases - Fixes #6914 --- .../components/chat/ContextCondenseRow.tsx | 11 +- .../__tests__/ContextCondenseRow.spec.tsx | 282 ++++++++++++++++++ 2 files changed, 291 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..c2fdba802d 100644 --- a/webview-ui/src/components/chat/ContextCondenseRow.tsx +++ b/webview-ui/src/components/chat/ContextCondenseRow.tsx @@ -11,6 +11,11 @@ export const ContextCondenseRow = ({ cost, prevContextTokens, newContextTokens, const { t } = useTranslation() const [isExpanded, setIsExpanded] = useState(false) + // Handle null/undefined token values to prevent crashes + const prevTokens = prevContextTokens ?? 0 + const newTokens = newContextTokens ?? 0 + const displayCost = cost ?? 0 + return (
{t("chat:contextCondense.title")} - {prevContextTokens.toLocaleString()} → {newContextTokens.toLocaleString()} {t("tokens")} + {prevTokens.toLocaleString()} → {newTokens.toLocaleString()} {t("tokens")} - 0 ? "opacity-100" : "opacity-0"}>${cost.toFixed(2)} + 0 ? "opacity-100" : "opacity-0"}> + ${displayCost.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..81435df696 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ContextCondenseRow.spec.tsx @@ -0,0 +1,282 @@ +import { render, fireEvent, screen } from "@/utils/test-utils" +import { ContextCondenseRow, CondensingContextRow, CondenseContextErrorRow } from "../ContextCondenseRow" + +// Mock the translation hook +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + "chat:contextCondense.title": "Context Condensed", + "chat:contextCondense.condensing": "Condensing context...", + "chat:contextCondense.errorHeader": "Context condensation failed", + tokens: "tokens", + } + return translations[key] || key + }, + }), + initReactI18next: { + type: "3rdParty", + init: () => {}, + }, +})) + +describe("ContextCondenseRow", () => { + describe("with valid data", () => { + const defaultProps = { + cost: 0.05, + prevContextTokens: 1000, + newContextTokens: 500, + summary: "Context has been condensed successfully", + } + + it("should render without crashing", () => { + const { container } = render() + expect(container).toBeInTheDocument() + }) + + it("should display token counts correctly", () => { + render() + // The component should display "1,000 → 500 tokens" + expect(screen.getByText(/1,000/)).toBeInTheDocument() + expect(screen.getByText(/500/)).toBeInTheDocument() + }) + + it("should display cost when greater than 0", () => { + render() + expect(screen.getByText("$0.05")).toBeInTheDocument() + }) + + it("should hide cost badge when cost is 0", () => { + const { container } = render() + const badge = container.querySelector("vscode-badge") + expect(badge).toHaveClass("opacity-0") + }) + + it("should expand and show summary when clicked", () => { + const { container } = render() + + // Summary should not be visible initially + expect(screen.queryByText(defaultProps.summary)).not.toBeInTheDocument() + + // Click to expand - find the clickable div + const expandButton = container.querySelector(".cursor-pointer") + fireEvent.click(expandButton!) + + // Summary should now be visible + expect(screen.getByText(defaultProps.summary)).toBeInTheDocument() + }) + + it("should toggle chevron icon when expanded/collapsed", () => { + const { container } = render() + + // Initially should show chevron-down + expect(container.querySelector(".codicon-chevron-down")).toBeInTheDocument() + expect(container.querySelector(".codicon-chevron-up")).not.toBeInTheDocument() + + // Click to expand + const expandButton = container.querySelector(".cursor-pointer") + fireEvent.click(expandButton!) + + // Should now show chevron-up + expect(container.querySelector(".codicon-chevron-up")).toBeInTheDocument() + expect(container.querySelector(".codicon-chevron-down")).not.toBeInTheDocument() + }) + }) + + describe("with null/undefined values", () => { + it("should handle null prevContextTokens without crashing", () => { + const props = { + cost: 0.05, + prevContextTokens: null as any, + newContextTokens: 500, + summary: "Context condensed", + } + + const { container } = render() + expect(container).toBeInTheDocument() + // Should display 0 instead of null + expect(screen.getByText(/0 →/)).toBeInTheDocument() + }) + + it("should handle null newContextTokens without crashing", () => { + const props = { + cost: 0.05, + prevContextTokens: 1000, + newContextTokens: null as any, + summary: "Context condensed", + } + + const { container } = render() + expect(container).toBeInTheDocument() + // Should display 0 instead of null + expect(screen.getByText(/→ 0/)).toBeInTheDocument() + }) + + it("should handle both tokens being null without crashing", () => { + const props = { + cost: 0.05, + prevContextTokens: null as any, + newContextTokens: null as any, + summary: "Context condensed", + } + + const { container } = render() + expect(container).toBeInTheDocument() + // Should display "0 → 0 tokens" + expect(screen.getByText(/0 → 0/)).toBeInTheDocument() + }) + + it("should handle undefined prevContextTokens without crashing", () => { + const props = { + cost: 0.05, + prevContextTokens: undefined as any, + newContextTokens: 500, + summary: "Context condensed", + } + + const { container } = render() + expect(container).toBeInTheDocument() + // Should display 0 instead of undefined + expect(screen.getByText(/0 →/)).toBeInTheDocument() + }) + + it("should handle undefined newContextTokens without crashing", () => { + const props = { + cost: 0.05, + prevContextTokens: 1000, + newContextTokens: undefined as any, + summary: "Context condensed", + } + + const { container } = render() + expect(container).toBeInTheDocument() + // Should display 0 instead of undefined + expect(screen.getByText(/→ 0/)).toBeInTheDocument() + }) + + it("should handle null cost without crashing", () => { + const props = { + cost: null as any, + prevContextTokens: 1000, + newContextTokens: 500, + summary: "Context condensed", + } + + const { container } = render() + expect(container).toBeInTheDocument() + // Should display $0.00 for null cost + expect(screen.getByText("$0.00")).toBeInTheDocument() + }) + + it("should handle undefined cost without crashing", () => { + const props = { + cost: undefined as any, + prevContextTokens: 1000, + newContextTokens: 500, + summary: "Context condensed", + } + + const { container } = render() + expect(container).toBeInTheDocument() + // Should display $0.00 for undefined cost + expect(screen.getByText("$0.00")).toBeInTheDocument() + }) + }) + + describe("edge cases", () => { + it("should handle very large token numbers", () => { + const props = { + cost: 100.99, + prevContextTokens: 1000000, + newContextTokens: 500000, + summary: "Large context condensed", + } + + render() + // Should format large numbers with commas + expect(screen.getByText(/1,000,000/)).toBeInTheDocument() + expect(screen.getByText(/500,000/)).toBeInTheDocument() + }) + + it("should handle negative token numbers gracefully", () => { + const props = { + cost: 0.05, + prevContextTokens: -100, + newContextTokens: -50, + summary: "Negative tokens", + } + + render() + // Should still render without crashing + expect(screen.getByText(/-100/)).toBeInTheDocument() + expect(screen.getByText(/-50/)).toBeInTheDocument() + }) + + it("should handle empty summary", () => { + const props = { + cost: 0.05, + prevContextTokens: 1000, + newContextTokens: 500, + summary: "", + } + + const { container } = render() + + // Click to expand + const expandButton = container.querySelector(".cursor-pointer") + fireEvent.click(expandButton!) + + // Should show the expanded area even with empty summary + const expandedArea = container.querySelector(".bg-vscode-editor-background") + expect(expandedArea).toBeInTheDocument() + }) + }) +}) + +describe("CondensingContextRow", () => { + it("should render without crashing", () => { + const { container } = render() + expect(container).toBeInTheDocument() + }) + + it("should display condensing message", () => { + render() + expect(screen.getByText("Condensing context...")).toBeInTheDocument() + }) + + it("should show progress indicator", () => { + const { container } = render() + // Check for the compress icon + expect(container.querySelector(".codicon-compress")).toBeInTheDocument() + }) +}) + +describe("CondenseContextErrorRow", () => { + it("should render without crashing", () => { + const { container } = render() + expect(container).toBeInTheDocument() + }) + + it("should display error header", () => { + render() + expect(screen.getByText("Context condensation failed")).toBeInTheDocument() + }) + + it("should display custom error text when provided", () => { + const errorText = "Failed to condense context due to API error" + render() + expect(screen.getByText(errorText)).toBeInTheDocument() + }) + + it("should show warning icon", () => { + const { container } = render() + expect(container.querySelector(".codicon-warning")).toBeInTheDocument() + }) + + it("should handle undefined error text", () => { + const { container } = render() + expect(container).toBeInTheDocument() + // Should still show the header + expect(screen.getByText("Context condensation failed")).toBeInTheDocument() + }) +}) From cd989348c702e75724b080d0a2c7569b3f388f7f Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Thu, 14 Aug 2025 18:25:34 -0500 Subject: [PATCH 2/2] Delete webview-ui/src/components/chat/__tests__/ContextCondenseRow.spec.tsx --- .../__tests__/ContextCondenseRow.spec.tsx | 282 ------------------ 1 file changed, 282 deletions(-) delete mode 100644 webview-ui/src/components/chat/__tests__/ContextCondenseRow.spec.tsx diff --git a/webview-ui/src/components/chat/__tests__/ContextCondenseRow.spec.tsx b/webview-ui/src/components/chat/__tests__/ContextCondenseRow.spec.tsx deleted file mode 100644 index 81435df696..0000000000 --- a/webview-ui/src/components/chat/__tests__/ContextCondenseRow.spec.tsx +++ /dev/null @@ -1,282 +0,0 @@ -import { render, fireEvent, screen } from "@/utils/test-utils" -import { ContextCondenseRow, CondensingContextRow, CondenseContextErrorRow } from "../ContextCondenseRow" - -// Mock the translation hook -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - "chat:contextCondense.title": "Context Condensed", - "chat:contextCondense.condensing": "Condensing context...", - "chat:contextCondense.errorHeader": "Context condensation failed", - tokens: "tokens", - } - return translations[key] || key - }, - }), - initReactI18next: { - type: "3rdParty", - init: () => {}, - }, -})) - -describe("ContextCondenseRow", () => { - describe("with valid data", () => { - const defaultProps = { - cost: 0.05, - prevContextTokens: 1000, - newContextTokens: 500, - summary: "Context has been condensed successfully", - } - - it("should render without crashing", () => { - const { container } = render() - expect(container).toBeInTheDocument() - }) - - it("should display token counts correctly", () => { - render() - // The component should display "1,000 → 500 tokens" - expect(screen.getByText(/1,000/)).toBeInTheDocument() - expect(screen.getByText(/500/)).toBeInTheDocument() - }) - - it("should display cost when greater than 0", () => { - render() - expect(screen.getByText("$0.05")).toBeInTheDocument() - }) - - it("should hide cost badge when cost is 0", () => { - const { container } = render() - const badge = container.querySelector("vscode-badge") - expect(badge).toHaveClass("opacity-0") - }) - - it("should expand and show summary when clicked", () => { - const { container } = render() - - // Summary should not be visible initially - expect(screen.queryByText(defaultProps.summary)).not.toBeInTheDocument() - - // Click to expand - find the clickable div - const expandButton = container.querySelector(".cursor-pointer") - fireEvent.click(expandButton!) - - // Summary should now be visible - expect(screen.getByText(defaultProps.summary)).toBeInTheDocument() - }) - - it("should toggle chevron icon when expanded/collapsed", () => { - const { container } = render() - - // Initially should show chevron-down - expect(container.querySelector(".codicon-chevron-down")).toBeInTheDocument() - expect(container.querySelector(".codicon-chevron-up")).not.toBeInTheDocument() - - // Click to expand - const expandButton = container.querySelector(".cursor-pointer") - fireEvent.click(expandButton!) - - // Should now show chevron-up - expect(container.querySelector(".codicon-chevron-up")).toBeInTheDocument() - expect(container.querySelector(".codicon-chevron-down")).not.toBeInTheDocument() - }) - }) - - describe("with null/undefined values", () => { - it("should handle null prevContextTokens without crashing", () => { - const props = { - cost: 0.05, - prevContextTokens: null as any, - newContextTokens: 500, - summary: "Context condensed", - } - - const { container } = render() - expect(container).toBeInTheDocument() - // Should display 0 instead of null - expect(screen.getByText(/0 →/)).toBeInTheDocument() - }) - - it("should handle null newContextTokens without crashing", () => { - const props = { - cost: 0.05, - prevContextTokens: 1000, - newContextTokens: null as any, - summary: "Context condensed", - } - - const { container } = render() - expect(container).toBeInTheDocument() - // Should display 0 instead of null - expect(screen.getByText(/→ 0/)).toBeInTheDocument() - }) - - it("should handle both tokens being null without crashing", () => { - const props = { - cost: 0.05, - prevContextTokens: null as any, - newContextTokens: null as any, - summary: "Context condensed", - } - - const { container } = render() - expect(container).toBeInTheDocument() - // Should display "0 → 0 tokens" - expect(screen.getByText(/0 → 0/)).toBeInTheDocument() - }) - - it("should handle undefined prevContextTokens without crashing", () => { - const props = { - cost: 0.05, - prevContextTokens: undefined as any, - newContextTokens: 500, - summary: "Context condensed", - } - - const { container } = render() - expect(container).toBeInTheDocument() - // Should display 0 instead of undefined - expect(screen.getByText(/0 →/)).toBeInTheDocument() - }) - - it("should handle undefined newContextTokens without crashing", () => { - const props = { - cost: 0.05, - prevContextTokens: 1000, - newContextTokens: undefined as any, - summary: "Context condensed", - } - - const { container } = render() - expect(container).toBeInTheDocument() - // Should display 0 instead of undefined - expect(screen.getByText(/→ 0/)).toBeInTheDocument() - }) - - it("should handle null cost without crashing", () => { - const props = { - cost: null as any, - prevContextTokens: 1000, - newContextTokens: 500, - summary: "Context condensed", - } - - const { container } = render() - expect(container).toBeInTheDocument() - // Should display $0.00 for null cost - expect(screen.getByText("$0.00")).toBeInTheDocument() - }) - - it("should handle undefined cost without crashing", () => { - const props = { - cost: undefined as any, - prevContextTokens: 1000, - newContextTokens: 500, - summary: "Context condensed", - } - - const { container } = render() - expect(container).toBeInTheDocument() - // Should display $0.00 for undefined cost - expect(screen.getByText("$0.00")).toBeInTheDocument() - }) - }) - - describe("edge cases", () => { - it("should handle very large token numbers", () => { - const props = { - cost: 100.99, - prevContextTokens: 1000000, - newContextTokens: 500000, - summary: "Large context condensed", - } - - render() - // Should format large numbers with commas - expect(screen.getByText(/1,000,000/)).toBeInTheDocument() - expect(screen.getByText(/500,000/)).toBeInTheDocument() - }) - - it("should handle negative token numbers gracefully", () => { - const props = { - cost: 0.05, - prevContextTokens: -100, - newContextTokens: -50, - summary: "Negative tokens", - } - - render() - // Should still render without crashing - expect(screen.getByText(/-100/)).toBeInTheDocument() - expect(screen.getByText(/-50/)).toBeInTheDocument() - }) - - it("should handle empty summary", () => { - const props = { - cost: 0.05, - prevContextTokens: 1000, - newContextTokens: 500, - summary: "", - } - - const { container } = render() - - // Click to expand - const expandButton = container.querySelector(".cursor-pointer") - fireEvent.click(expandButton!) - - // Should show the expanded area even with empty summary - const expandedArea = container.querySelector(".bg-vscode-editor-background") - expect(expandedArea).toBeInTheDocument() - }) - }) -}) - -describe("CondensingContextRow", () => { - it("should render without crashing", () => { - const { container } = render() - expect(container).toBeInTheDocument() - }) - - it("should display condensing message", () => { - render() - expect(screen.getByText("Condensing context...")).toBeInTheDocument() - }) - - it("should show progress indicator", () => { - const { container } = render() - // Check for the compress icon - expect(container.querySelector(".codicon-compress")).toBeInTheDocument() - }) -}) - -describe("CondenseContextErrorRow", () => { - it("should render without crashing", () => { - const { container } = render() - expect(container).toBeInTheDocument() - }) - - it("should display error header", () => { - render() - expect(screen.getByText("Context condensation failed")).toBeInTheDocument() - }) - - it("should display custom error text when provided", () => { - const errorText = "Failed to condense context due to API error" - render() - expect(screen.getByText(errorText)).toBeInTheDocument() - }) - - it("should show warning icon", () => { - const { container } = render() - expect(container.querySelector(".codicon-warning")).toBeInTheDocument() - }) - - it("should handle undefined error text", () => { - const { container } = render() - expect(container).toBeInTheDocument() - // Should still show the header - expect(screen.getByText("Context condensation failed")).toBeInTheDocument() - }) -})