From 23945f04abcf2e3ea08cc7cc882f2678174afdf9 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 6 Aug 2026 02:21:34 +0000 Subject: [PATCH 1/3] refactor: add shared test utilities and pilot refactors --- AGENTS.md | 8 + .../providers/__tests__/openai-native.spec.ts | 41 ++--- src/eslint-suppressions.json | 4 +- .../editor/__tests__/DiffViewProvider.spec.ts | 23 +-- src/test-utils/__tests__/api.spec.ts | 23 +++ src/test-utils/__tests__/fs.spec.ts | 17 ++ src/test-utils/api.ts | 28 +++ src/test-utils/fs.ts | 28 +++ src/test-utils/reset.ts | 19 +++ src/test-utils/vscode.ts | 154 +++++++++++++++++ webview-ui/AGENTS.md | 1 + .../chat/__tests__/ChatView.spec.tsx | 65 +++---- .../src/context/ExtensionStateContext.tsx | 161 +++++++++--------- webview-ui/src/utils/test-utils.tsx | 50 +++++- 14 files changed, 459 insertions(+), 163 deletions(-) create mode 100644 src/test-utils/__tests__/api.spec.ts create mode 100644 src/test-utils/__tests__/fs.spec.ts create mode 100644 src/test-utils/api.ts create mode 100644 src/test-utils/fs.ts create mode 100644 src/test-utils/reset.ts create mode 100644 src/test-utils/vscode.ts diff --git a/AGENTS.md b/AGENTS.md index 1b70c7347b..d28c73b4e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,3 +43,11 @@ Prefer the narrowest test layer that proves the behavior. This follows standard - Use `apps/vscode-e2e` only when the behavior depends on the real VS Code extension host, VS Code workspace APIs, extension activation, webview/extension messaging, file watcher behavior, or a complete user workflow. - Keep e2e tests focused on high-value smoke coverage across boundaries. Avoid placing detailed protocol, parsing, storage, retry, or edge-case assertions in e2e when they can be covered reliably at a lower layer. - When fixing a regression, add the regression test at the lowest layer that would have failed for the bug. Add an e2e test only if lower-level tests cannot represent the failure mode. + +## Shared Test Utilities + +- Use `src/test-utils/stream.ts` for mechanical async-stream setup and collection. +- Use the typed helpers in `src/test-utils/api.ts`, `src/test-utils/fs.ts`, `src/test-utils/reset.ts`, and `src/test-utils/vscode.ts` when they remove repeated setup without hiding the scenario. +- Keep provider-specific payloads, failure streams, and assertions inline when they explain the behavior under test. +- Prefer shared helpers for mechanical duplication; use fixtures only when setup is reusable, typed, and independently disposable. +- New helpers must preserve failure clarity, return fresh objects, and avoid `as any`; keep unavoidable VS Code structural casts inside the helper with a brief explanation. diff --git a/src/api/providers/__tests__/openai-native.spec.ts b/src/api/providers/__tests__/openai-native.spec.ts index 247dc941d3..8c3398d443 100644 --- a/src/api/providers/__tests__/openai-native.spec.ts +++ b/src/api/providers/__tests__/openai-native.spec.ts @@ -18,7 +18,9 @@ import { ApiProviderError, OpenAiServiceTier, SERVICE_TIER_KEY, serviceTiers } f import { OpenAiNativeHandler } from "../openai-native" import { ApiHandlerOptions } from "../../../shared/api" import { Package } from "../../../shared/package" +import { expectRequestObjectContaining, makeApiHandlerOptions } from "../../../test-utils/api" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { deleteGlobalFetch } from "../../../test-utils/reset" // Mock OpenAI client - now everything uses Responses API const mockResponsesCreate = vitest.fn() @@ -41,18 +43,16 @@ const serviceTierPricingCases = [ }, ] -vitest.mock("openai", () => { - return { - __esModule: true, - default: vitest.fn().mockImplementation(function () { - return { - responses: { - create: mockResponsesCreate, - }, - } - }), - } -}) +vitest.mock("openai", () => ({ + __esModule: true, + default: vitest.fn().mockImplementation(function () { + return { + responses: { + create: mockResponsesCreate, + }, + } + }), +})) describe("OpenAiNativeHandler", () => { let handler: OpenAiNativeHandler @@ -66,24 +66,15 @@ describe("OpenAiNativeHandler", () => { ] beforeEach(() => { - mockOptions = { - apiModelId: "gpt-4.1", - openAiNativeApiKey: "test-api-key", - } + mockOptions = makeApiHandlerOptions() handler = new OpenAiNativeHandler(mockOptions) mockResponsesCreate.mockClear() mockCaptureException.mockClear() - // Clear fetch mock if it exists - if ((global as any).fetch) { - delete (global as any).fetch - } + deleteGlobalFetch() }) afterEach(() => { - // Clean up fetch mock - if ((global as any).fetch) { - delete (global as any).fetch - } + deleteGlobalFetch() }) describe("constructor", () => { @@ -152,7 +143,7 @@ describe("OpenAiNativeHandler", () => { await collectStream(handler.createMessage(systemPrompt, messages)) expect(mockResponsesCreate).toHaveBeenCalledWith( - expect.objectContaining({ [SERVICE_TIER_KEY]: serviceTier }), + expectRequestObjectContaining({ [SERVICE_TIER_KEY]: serviceTier }), expect.any(Object), ) }) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index e3e8bc5c7a..53d5ba4441 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -221,7 +221,7 @@ }, "api/providers/__tests__/openai-native.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 78 + "count": 74 } }, "api/providers/__tests__/openai-timeout.spec.ts": { @@ -1196,7 +1196,7 @@ }, "integrations/editor/__tests__/DiffViewProvider.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 311 + "count": 310 } }, "integrations/editor/__tests__/EditorUtils.spec.ts": { diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index d56338b6b5..aee88f4061 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -3,6 +3,8 @@ import * as vscode from "vscode" import * as path from "path" import delay from "delay" +import { makeRange, makeTextDocument, makeTextEditor, makeUri } from "../../../test-utils/vscode" + // Mock delay vi.mock("delay", () => ({ default: vi.fn().mockResolvedValue(undefined), @@ -356,26 +358,19 @@ describe("DiffViewProvider", () => { describe("scrollToFirstDiff method", () => { const setupEditor = (currentContent: string) => { const revealRange = vi.fn() - // Mirror how VS Code reports lineCount: a trailing newline yields a final - // empty line, so the count is the number of "\n"-delimited segments. - const lineCount = currentContent === "" ? 0 : currentContent.split("\n").length - const lines = currentContent.split("\n") - const document = { - uri: { fsPath: `${mockCwd}/mock-file-target.txt`, scheme: "file" }, + const document = makeTextDocument({ + uri: makeUri(`${mockCwd}/mock-file-target.txt`), getText: vi.fn().mockReturnValue(currentContent), - lineCount, - lineAt: vi.fn().mockImplementation((line: number) => ({ text: lines[line] ?? "" })), - } - const editor = { + }) + const editor = makeTextEditor({ document, - selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, - visibleRanges: [{ start: { line: 0 }, end: { line: 0 } }], + visibleRanges: [makeRange()], revealRange, - } + }) ;(diffViewProvider as any).activeDiffEditor = editor // Register the editor as the live modified-side editor so resolveLiveEditor // finds it by document identity, mirroring the runtime path. - vi.mocked(vscode.window).visibleTextEditors = [editor as any] + vi.mocked(vscode.window).visibleTextEditors = [editor] return revealRange } diff --git a/src/test-utils/__tests__/api.spec.ts b/src/test-utils/__tests__/api.spec.ts new file mode 100644 index 0000000000..21bc89761e --- /dev/null +++ b/src/test-utils/__tests__/api.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from "vitest" + +import { expectRequestObjectContaining, makeApiHandlerOptions, mockOpenAiResponsesClient } from "../api" + +describe("API test utilities", () => { + it("provides stable handler defaults with override support", () => { + expect(makeApiHandlerOptions({ apiModelId: "gpt-5.6-sol" })).toMatchObject({ + apiModelId: "gpt-5.6-sol", + openAiNativeApiKey: "test-api-key", + }) + }) + + it("creates an OpenAI Responses API client mock", () => { + const create = vi.fn() + const client = mockOpenAiResponsesClient(create).default() + + expect(client.responses.create).toBe(create) + }) + + it("matches only the requested request fields", () => { + expect({ model: "gpt-4.1", stream: true }).toEqual(expectRequestObjectContaining({ model: "gpt-4.1" })) + }) +}) diff --git a/src/test-utils/__tests__/fs.spec.ts b/src/test-utils/__tests__/fs.spec.ts new file mode 100644 index 0000000000..4073658a30 --- /dev/null +++ b/src/test-utils/__tests__/fs.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it, vi } from "vitest" + +import { mockFsPromises, resetFsPromises } from "../fs" + +describe("filesystem test utilities", () => { + it("provides defaults and restores them after a test-specific override", async () => { + const mock = mockFsPromises({ readFile: vi.fn().mockResolvedValue("custom content") }) + + expect(await mock.readFile()).toBe("custom content") + + resetFsPromises(mock) + + expect(await mock.readFile()).toBe("") + expect(await mock.writeFile()).toBeUndefined() + expect(await mock.access()).toBeUndefined() + }) +}) diff --git a/src/test-utils/api.ts b/src/test-utils/api.ts new file mode 100644 index 0000000000..1939fbb7e8 --- /dev/null +++ b/src/test-utils/api.ts @@ -0,0 +1,28 @@ +import { expect, vi, type Mock } from "vitest" + +import type { ApiHandlerOptions } from "../shared/api" + +export function makeApiHandlerOptions(overrides: Partial = {}): ApiHandlerOptions { + return { + apiModelId: "gpt-4.1", + openAiNativeApiKey: "test-api-key", + ...overrides, + } +} + +export function mockOpenAiResponsesClient(create: Mock) { + return { + __esModule: true, + default: vi.fn().mockImplementation(function () { + return { + responses: { create }, + } + }), + } +} + +type ObjectContainingInput = Parameters[0] + +export function expectRequestObjectContaining(value: ObjectContainingInput) { + return expect.objectContaining(value) +} diff --git a/src/test-utils/fs.ts b/src/test-utils/fs.ts new file mode 100644 index 0000000000..918fbad624 --- /dev/null +++ b/src/test-utils/fs.ts @@ -0,0 +1,28 @@ +import { vi, type Mock } from "vitest" + +export type FsPromisesMock = { + readFile: Mock + writeFile: Mock + access: Mock +} + +function createDefaultFsPromisesMock(): FsPromisesMock { + return { + readFile: vi.fn().mockResolvedValue(""), + writeFile: vi.fn().mockResolvedValue(undefined), + access: vi.fn().mockResolvedValue(undefined), + } +} + +export function mockFsPromises(overrides: Partial = {}): FsPromisesMock { + return { + ...createDefaultFsPromisesMock(), + ...overrides, + } +} + +export function resetFsPromises(mock: FsPromisesMock): void { + mock.readFile.mockReset().mockResolvedValue("") + mock.writeFile.mockReset().mockResolvedValue(undefined) + mock.access.mockReset().mockResolvedValue(undefined) +} diff --git a/src/test-utils/reset.ts b/src/test-utils/reset.ts new file mode 100644 index 0000000000..a03bcf3a5e --- /dev/null +++ b/src/test-utils/reset.ts @@ -0,0 +1,19 @@ +import nock from "nock" +import { vi } from "vitest" + +export function clearAllMocks(): void { + vi.clearAllMocks() +} + +export function restoreGlobals(): void { + vi.restoreAllMocks() +} + +export function deleteGlobalFetch(): void { + Reflect.deleteProperty(globalThis, "fetch") +} + +export function resetNock(): void { + nock.cleanAll() + nock.abortPendingRequests() +} diff --git a/src/test-utils/vscode.ts b/src/test-utils/vscode.ts new file mode 100644 index 0000000000..c61106f8f1 --- /dev/null +++ b/src/test-utils/vscode.ts @@ -0,0 +1,154 @@ +import { vi } from "vitest" +import type * as vscode from "vscode" + +export function makePosition(line = 0, character = 0): vscode.Position { + return { line, character } as vscode.Position +} + +export function makeRange(startLine = 0, startCharacter = 0, endLine = startLine, endCharacter = startCharacter) { + return { + start: makePosition(startLine, startCharacter), + end: makePosition(endLine, endCharacter), + } as vscode.Range +} + +export function makeSelection(line = 0, character = 0): vscode.Selection { + const position = makePosition(line, character) + return { anchor: position, active: position } as vscode.Selection +} + +export function makeUri( + fsPath: string, + overrides: Partial> = {}, +): vscode.Uri { + // Uri is class-backed in VS Code; this structural double cast keeps the fake local and explicit. + return { + scheme: "file", + authority: "", + path: fsPath, + query: "", + fragment: "", + fsPath, + with: vi.fn(), + toString: vi.fn(() => fsPath), + toJSON: vi.fn(() => ({ fsPath })), + ...overrides, + } as unknown as vscode.Uri +} + +export function makeTextDocument(overrides: Partial = {}): vscode.TextDocument { + const uri = overrides.uri ?? makeUri("/mock/workspace/test.txt") + const text = overrides.getText?.() ?? "" + const lines = text.split("\n") + const lineAt = vi.fn((line: number) => { + const lineText = lines[line] ?? "" + return { + lineNumber: line, + text: lineText, + range: makeRange(line, 0, line, lineText.length), + rangeIncludingLineBreak: makeRange(line, 0, line, lineText.length), + firstNonWhitespaceCharacterIndex: lineText.search(/\S|$/), + isEmptyOrWhitespace: lineText.trim().length === 0, + } as vscode.TextLine + }) + + return { + uri, + fileName: uri.fsPath, + isUntitled: false, + languageId: "plaintext", + version: 1, + isDirty: false, + isClosed: false, + save: vi.fn().mockResolvedValue(true), + eol: 1 as vscode.EndOfLine, + lineCount: text === "" ? 0 : lines.length, + encoding: "utf8", + getText: vi.fn(() => text), + getWordRangeAtPosition: vi.fn(), + lineAt, + offsetAt: vi.fn(), + positionAt: vi.fn(), + validateRange: vi.fn((range) => range), + validatePosition: vi.fn((position) => position), + ...overrides, + } as unknown as vscode.TextDocument +} + +export function makeTextEditor(overrides: Partial = {}): vscode.TextEditor { + const document = overrides.document ?? makeTextDocument() + const selection = overrides.selection ?? makeSelection() + + return { + document, + selection, + selections: [selection], + visibleRanges: [makeRange()], + options: {}, + viewColumn: 1 as vscode.ViewColumn, + edit: vi.fn().mockResolvedValue(true), + insertSnippet: vi.fn().mockResolvedValue(true), + setDecorations: vi.fn(), + revealRange: vi.fn(), + ...overrides, + } as unknown as vscode.TextEditor +} + +export function makeDisposable(overrides: Partial = {}): vscode.Disposable { + return { dispose: vi.fn(), ...overrides } +} + +export function makeEventEmitter(): vscode.EventEmitter { + const listeners = new Set<(value: T) => unknown>() + const event = ((listener: (value: T) => unknown) => { + listeners.add(listener) + return makeDisposable({ dispose: () => listeners.delete(listener) }) + }) as unknown as vscode.Event + + return { + event, + fire: (value: T) => listeners.forEach((listener) => listener(value)), + dispose: () => listeners.clear(), + } as vscode.EventEmitter +} + +export function makeWorkspaceConfiguration(values: Record = {}): vscode.WorkspaceConfiguration { + return { + get: vi.fn( + (section: string, defaultValue?: T) => + (section in values ? values[section] : defaultValue) as T | undefined, + ), + has: vi.fn((section: string) => section in values), + inspect: vi.fn(), + update: vi.fn().mockResolvedValue(undefined), + } as unknown as vscode.WorkspaceConfiguration +} + +export function makeExtensionContext(overrides: Partial = {}): vscode.ExtensionContext { + const makeMemento = () => ({ + get: vi.fn(), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn(() => []), + }) + + return { + subscriptions: [], + workspaceState: makeMemento(), + globalState: makeMemento(), + secrets: { + get: vi.fn(), + store: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + onDidChange: new Map(), + }, + extensionPath: "/mock/extension", + extensionUri: makeUri("/mock/extension"), + storagePath: "/mock/storage", + globalStoragePath: "/mock/global-storage", + logPath: "/mock/logs", + extensionMode: 1 as vscode.ExtensionMode, + environmentVariableCollection: {}, + asAbsolutePath: (relativePath: string) => `/mock/extension/${relativePath}`, + ...overrides, + } as unknown as vscode.ExtensionContext +} diff --git a/webview-ui/AGENTS.md b/webview-ui/AGENTS.md index c118e36c9a..892c76676a 100644 --- a/webview-ui/AGENTS.md +++ b/webview-ui/AGENTS.md @@ -30,6 +30,7 @@ We use a complementary two-layer strategy for testing webview UI code: - Use `apps/vscode-e2e` only when the behavior depends on the real VS Code extension environment: extension-host to webview messaging, VS Code workspace APIs, task execution flows, or other end-to-end behavior that needs `@vscode/test-electron`. - When a regression can be proven with a component or webview integration test, keep it in `webview-ui`. Do not promote it to e2e just because the UI is hosted inside VS Code. - For `SettingsView`, preserve the cached-state pattern from the repo root guidance: inputs should operate on local `cachedState` until the user saves, and tests should distinguish automatic initialization from real user edits. +- Use `@/utils/test-utils` `renderWithExtensionState` and `makeExtensionState` for common context/query setup. Keep component-specific mocks and state transitions local when they explain the scenario. ### Coverage & Codecov Quality Gates diff --git a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx index d2a84139d7..123e0014f3 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx @@ -1,10 +1,15 @@ // 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 { + makeExtensionState, + mockVscodePostMessage, + renderWithExtensionState, + waitFor, + act, + fireEvent, +} from "@/utils/test-utils" -import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" import { vscode } from "@src/utils/vscode" import type { SuggestionItem } from "@roo-code/types" @@ -29,16 +34,6 @@ interface ClineMessage { 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: { @@ -332,21 +327,13 @@ vi.mock("@vscode/webview-ui-toolkit/react", () => ({ })) // Mock window.postMessage to trigger state hydration -const mockPostMessage = (state: Partial) => { +const vscodePostMessageMock = mockVscodePostMessage(vi.mocked(vscode.postMessage)) + +const mockPostMessage = (state: Record) => { window.postMessage( { type: "state", - state: { - version: "1.0.0", - clineMessages: [], - taskHistory: [], - shouldShowAnnouncement: false, - allowedCommands: [], - alwaysAllowExecute: false, - cloudIsAuthenticated: false, - telemetrySetting: "enabled", - ...state, - }, + state: makeExtensionState(state), }, "*", ) @@ -358,16 +345,8 @@ const defaultProps: ChatViewProps = { hideAnnouncement: () => {}, } -const queryClient = new QueryClient() - const renderChatView = (props: Partial = {}) => { - return render( - - - - - , - ) + return renderWithExtensionState() } describe("ChatView - Tool Batching Tests", () => { @@ -878,7 +857,7 @@ describe("ChatView - Message Queueing Tests", () => { beforeEach(() => { vi.clearAllMocks() // Reset the mock to clear any initial calls - vi.mocked(vscode.postMessage).mockClear() + vscodePostMessageMock.cleanup() }) it("shows sending is disabled when task is active", async () => { @@ -954,7 +933,7 @@ describe("ChatView - Message Queueing Tests", () => { }) // Clear any initial calls - vi.mocked(vscode.postMessage).mockClear() + vscodePostMessageMock.cleanup() // Add api_req_started without cost (spinner state - API request in progress) mockPostMessage({ @@ -980,7 +959,7 @@ describe("ChatView - Message Queueing Tests", () => { }) // Clear message calls before simulating user input - vi.mocked(vscode.postMessage).mockClear() + vscodePostMessageMock.cleanup() // Simulate user typing and sending a message during the spinner const chatTextArea = getByTestId("chat-textarea") @@ -1051,7 +1030,7 @@ describe("ChatView - Message Queueing Tests", () => { }) // Clear message calls before simulating user input - vi.mocked(vscode.postMessage).mockClear() + vscodePostMessageMock.cleanup() // Simulate user sending a message when API is done const chatTextArea = getByTestId("chat-textarea") @@ -1114,7 +1093,7 @@ describe("ChatView - Message Queueing Tests", () => { }) // Clear message calls before simulating user input - vi.mocked(vscode.postMessage).mockClear() + vscodePostMessageMock.cleanup() // Simulate user sending a new message while queue has items const chatTextArea = getByTestId("chat-textarea") @@ -1177,7 +1156,7 @@ describe("ChatView - Message Queueing Tests", () => { }) // Clear message calls before simulating user input - vi.mocked(vscode.postMessage).mockClear() + vscodePostMessageMock.cleanup() // Simulate user typing and sending a message during command execution const chatTextArea = getByTestId("chat-textarea") @@ -1272,7 +1251,7 @@ describe("ChatView - Message Queueing Tests", () => { describe("ChatView - Follow-up Suggestions", () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(vscode.postMessage).mockClear() + vscodePostMessageMock.cleanup() }) it("switches to a known mode from a malformed object mode suggestion", async () => { @@ -1302,7 +1281,7 @@ describe("ChatView - Follow-up Suggestions", () => { }) const suggestion = await waitFor(() => getByRole("button", { name: "Use code mode" })) - vi.mocked(vscode.postMessage).mockClear() + vscodePostMessageMock.cleanup() fireEvent.click(suggestion) @@ -1344,7 +1323,7 @@ describe("ChatView - Follow-up Suggestions", () => { }) const suggestion = await waitFor(() => getByRole("button", { name: "Use invalid mode" })) - vi.mocked(vscode.postMessage).mockClear() + vscodePostMessageMock.cleanup() fireEvent.click(suggestion) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index d0d4e37afa..88463bb2dc 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -195,83 +195,90 @@ export const mergeExtensionState = (prevState: ExtensionState, newState: Partial } } -export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [state, setState] = useState({ - apiConfiguration: {}, - version: "", - clineMessages: [], - taskHistory: [], - shouldShowAnnouncement: false, - allowedCommands: [], - deniedCommands: [], - soundEnabled: false, - soundVolume: 0.5, - ttsEnabled: false, - ttsSpeed: 1.0, - enableCheckpoints: true, - checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, // Default to 15 seconds - language: "en", // Default language code - writeDelayMs: 1000, - diffFuzzyThreshold: DEFAULT_DIFF_FUZZY_THRESHOLD, - terminalShellIntegrationTimeout: 4000, - mcpEnabled: true, - taskSyncEnabled: false, - currentApiConfigName: "default", - listApiConfigMeta: [], - mode: defaultModeSlug, - customModePrompts: defaultPrompts, - customSupportPrompts: {}, - experiments: experimentDefault, - enhancementApiConfigId: "", - hasOpenedModeSelector: false, // Default to false (not opened yet) - autoApprovalEnabled: false, - customModes: [], - maxOpenTabsContext: 20, - maxWorkspaceFiles: 200, - cwd: "", - telemetrySetting: "unset", - showRooIgnoredFiles: true, // Default to showing .rooignore'd files with lock symbol (current behavior). - enableSubfolderRules: false, // Default to disabled - must be enabled to load rules from subdirectories - renderContext: "sidebar", - maxReadFileLine: -1, // Default max line limit for read_file tool (-1 for default) - maxImageFileSize: 5, // Default max image file size in MB - maxTotalImageSize: 20, // Default max total image size in MB - pinnedApiConfigs: {}, // Empty object for pinned API configs - terminalZshOhMy: false, // Default Oh My Zsh integration setting - terminalZshP10k: false, // Default Powerlevel10k integration setting - terminalZdotdir: false, // Default ZDOTDIR handling setting - terminalProfile: undefined, // Default VS Code terminal profile (use VS Code default) - historyPreviewCollapsed: false, // Initialize the new state (default to expanded) - reasoningBlockCollapsed: true, // Default to collapsed - enterBehavior: "send", // Default: Enter sends, Shift+Enter creates newline - cloudUserInfo: null, - cloudIsAuthenticated: false, - cloudOrganizations: [], - sharingEnabled: false, - publicSharingEnabled: false, - organizationAllowList: ORGANIZATION_ALLOW_ALL, - organizationSettingsVersion: -1, - autoCondenseContext: true, - autoCondenseContextPercent: 100, - profileThresholds: {}, - codebaseIndexConfig: { - codebaseIndexEnabled: true, - codebaseIndexQdrantUrl: "http://localhost:6333", - codebaseIndexEmbedderProvider: "openai", - codebaseIndexEmbedderBaseUrl: "", - codebaseIndexEmbedderModelId: "", - codebaseIndexSearchMaxResults: undefined, - codebaseIndexSearchMinScore: undefined, - }, - codebaseIndexModels: { ollama: {}, openai: {} }, - includeDiagnosticMessages: true, - maxDiagnosticMessages: 50, - openRouterImageApiKey: "", - openRouterImageGenerationSelectedModel: "", - includeCurrentTime: true, - includeCurrentCost: true, - lockApiConfigAcrossModes: false, - }) +const createInitialExtensionState = (): ExtensionState => ({ + apiConfiguration: {}, + version: "", + clineMessages: [], + taskHistory: [], + shouldShowAnnouncement: false, + allowedCommands: [], + deniedCommands: [], + soundEnabled: false, + soundVolume: 0.5, + ttsEnabled: false, + ttsSpeed: 1.0, + enableCheckpoints: true, + checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, // Default to 15 seconds + language: "en", // Default language code + writeDelayMs: 1000, + diffFuzzyThreshold: DEFAULT_DIFF_FUZZY_THRESHOLD, + terminalShellIntegrationTimeout: 4000, + mcpEnabled: true, + taskSyncEnabled: false, + currentApiConfigName: "default", + listApiConfigMeta: [], + mode: defaultModeSlug, + customModePrompts: defaultPrompts, + customSupportPrompts: {}, + experiments: experimentDefault, + enhancementApiConfigId: "", + hasOpenedModeSelector: false, // Default to false (not opened yet) + autoApprovalEnabled: false, + customModes: [], + maxOpenTabsContext: 20, + maxWorkspaceFiles: 200, + cwd: "", + telemetrySetting: "unset", + showRooIgnoredFiles: true, // Default to showing .rooignore'd files with lock symbol (current behavior). + enableSubfolderRules: false, // Default to disabled - must be enabled to load rules from subdirectories + renderContext: "sidebar", + maxReadFileLine: -1, // Default max line limit for read_file tool (-1 for default) + maxImageFileSize: 5, // Default max image file size in MB + maxTotalImageSize: 20, // Default max total image size in MB + pinnedApiConfigs: {}, // Empty object for pinned API configs + terminalZshOhMy: false, // Default Oh My Zsh integration setting + terminalZshP10k: false, // Default Powerlevel10k integration setting + terminalZdotdir: false, // Default ZDOTDIR handling setting + terminalProfile: undefined, // Default VS Code terminal profile (use VS Code default) + historyPreviewCollapsed: false, // Initialize the new state (default to expanded) + reasoningBlockCollapsed: true, // Default to collapsed + enterBehavior: "send", // Default: Enter sends, Shift+Enter creates newline + cloudUserInfo: null, + cloudIsAuthenticated: false, + cloudOrganizations: [], + sharingEnabled: false, + publicSharingEnabled: false, + organizationAllowList: ORGANIZATION_ALLOW_ALL, + organizationSettingsVersion: -1, + autoCondenseContext: true, + autoCondenseContextPercent: 100, + profileThresholds: {}, + codebaseIndexConfig: { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://localhost:6333", + codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderBaseUrl: "", + codebaseIndexEmbedderModelId: "", + codebaseIndexSearchMaxResults: undefined, + codebaseIndexSearchMinScore: undefined, + }, + codebaseIndexModels: { ollama: {}, openai: {} }, + includeDiagnosticMessages: true, + maxDiagnosticMessages: 50, + openRouterImageApiKey: "", + openRouterImageGenerationSelectedModel: "", + includeCurrentTime: true, + includeCurrentCost: true, + lockApiConfigAcrossModes: false, +}) + +export const ExtensionStateContextProvider: React.FC<{ + children: React.ReactNode + initialState?: Partial +}> = ({ children, initialState }) => { + const [state, setState] = useState(() => + mergeExtensionState(createInitialExtensionState(), initialState ?? {}), + ) const [didHydrateState, setDidHydrateState] = useState(false) const [showWelcome, setShowWelcome] = useState(false) diff --git a/webview-ui/src/utils/test-utils.tsx b/webview-ui/src/utils/test-utils.tsx index c3b7bda301..22a47745bc 100644 --- a/webview-ui/src/utils/test-utils.tsx +++ b/webview-ui/src/utils/test-utils.tsx @@ -1,9 +1,13 @@ import React from "react" -import { render, RenderOptions } from "@testing-library/react" +import { render as rtlRender, type RenderOptions } from "@testing-library/react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { vi, type Mock } from "vitest" + +import type { ExtensionState } from "@roo-code/types" import { TooltipProvider } from "@src/components/ui/tooltip" import { STANDARD_TOOLTIP_DELAY } from "@src/components/ui/standard-tooltip" +import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" interface AllTheProvidersProps { children: React.ReactNode @@ -18,6 +22,28 @@ export const createTestQueryClient = () => }, }) +export const makeExtensionState = (overrides: Partial = {}): Partial => ({ + version: "1.0.0", + clineMessages: [], + taskHistory: [], + shouldShowAnnouncement: false, + allowedCommands: [], + deniedCommands: [], + alwaysAllowExecute: false, + cloudIsAuthenticated: false, + telemetrySetting: "enabled", + ...overrides, +}) + +export function mockVscodePostMessage(existing?: Mock) { + const postMessage = existing ?? vi.fn() + + return { + postMessage, + cleanup: () => postMessage.mockClear(), + } +} + const AllTheProviders = ({ children }: AllTheProvidersProps) => { const queryClient = createTestQueryClient() @@ -29,7 +55,27 @@ const AllTheProviders = ({ children }: AllTheProvidersProps) => { } const customRender = (ui: React.ReactElement, options?: Omit) => - render(ui, { wrapper: AllTheProviders, ...options }) + rtlRender(ui, { wrapper: AllTheProviders, ...options }) + +export type RenderWithExtensionStateOptions = { + state?: Partial + queryClient?: QueryClient +} & Omit + +export const renderWithExtensionState = ( + ui: React.ReactElement, + { state = {}, queryClient = createTestQueryClient(), ...options }: RenderWithExtensionStateOptions = {}, +) => + rtlRender(ui, { + wrapper: ({ children }) => ( + + + {children} + + + ), + ...options, + }) // re-export everything export * from "@testing-library/react" From e758970e10d0e25c7328eba53b28db0e14079248 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 6 Aug 2026 02:36:44 +0000 Subject: [PATCH 2/3] refactor: reuse API test options in OpenAI specs --- src/api/providers/__tests__/openai-usage-tracking.spec.ts | 5 +++-- src/api/providers/__tests__/openai.spec.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/api/providers/__tests__/openai-usage-tracking.spec.ts b/src/api/providers/__tests__/openai-usage-tracking.spec.ts index 15fccf5abb..e9c2f5e4fd 100644 --- a/src/api/providers/__tests__/openai-usage-tracking.spec.ts +++ b/src/api/providers/__tests__/openai-usage-tracking.spec.ts @@ -4,6 +4,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import { ApiHandlerOptions } from "../../../shared/api" import { OpenAiHandler } from "../openai" +import { makeApiHandlerOptions } from "../../../test-utils/api" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" const mockCreate = vitest.fn() @@ -89,11 +90,11 @@ describe("OpenAiHandler with usage tracking fix", () => { let mockOptions: ApiHandlerOptions beforeEach(() => { - mockOptions = { + mockOptions = makeApiHandlerOptions({ openAiApiKey: "test-api-key", openAiModelId: "gpt-4", openAiBaseUrl: "https://api.openai.com/v1", - } + }) handler = new OpenAiHandler(mockOptions) mockCreate.mockClear() }) diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 3e18f03a4c..e8146a999a 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -6,6 +6,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import { openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_TEMPERATURE } from "@roo-code/types" import { Package } from "../../../shared/package" +import { makeApiHandlerOptions } from "../../../test-utils/api" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" import axios from "axios" @@ -88,11 +89,11 @@ describe("OpenAiHandler", () => { let mockOptions: ApiHandlerOptions beforeEach(() => { - mockOptions = { + mockOptions = makeApiHandlerOptions({ openAiApiKey: "test-api-key", openAiModelId: "gpt-4", openAiBaseUrl: "https://api.openai.com/v1", - } + }) handler = new OpenAiHandler(mockOptions) mockCreate.mockClear() }) From 359d91b15ad21ba376b59bc1ad7fd662e34c1abf Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 6 Aug 2026 02:52:47 +0000 Subject: [PATCH 3/3] fix: cover shared test helpers and initial context state --- src/test-utils/__tests__/reset.spec.ts | 51 ++++++++++ src/test-utils/__tests__/vscode.spec.ts | 93 +++++++++++++++++++ .../src/context/ExtensionStateContext.tsx | 39 +++++--- .../__tests__/ExtensionStateContext.spec.tsx | 75 +++++++++++++++ 4 files changed, 246 insertions(+), 12 deletions(-) create mode 100644 src/test-utils/__tests__/reset.spec.ts create mode 100644 src/test-utils/__tests__/vscode.spec.ts diff --git a/src/test-utils/__tests__/reset.spec.ts b/src/test-utils/__tests__/reset.spec.ts new file mode 100644 index 0000000000..97e736c77f --- /dev/null +++ b/src/test-utils/__tests__/reset.spec.ts @@ -0,0 +1,51 @@ +import nock from "nock" +import { describe, expect, it, vi } from "vitest" + +import { clearAllMocks, deleteGlobalFetch, resetNock, restoreGlobals } from "../reset" + +describe("test reset utilities", () => { + it("clears and restores Vitest mocks", () => { + const mock = vi.fn() + mock() + + clearAllMocks() + expect(mock).not.toHaveBeenCalled() + + const target = { method: () => "original" } + vi.spyOn(target, "method").mockReturnValue("mocked") + expect(target.method()).toBe("mocked") + + restoreGlobals() + expect(target.method()).toBe("original") + }) + + it("deletes the global fetch override", () => { + const originalFetch = globalThis.fetch + Object.defineProperty(globalThis, "fetch", { + configurable: true, + writable: true, + value: vi.fn(), + }) + + deleteGlobalFetch() + + expect("fetch" in globalThis).toBe(false) + + if (originalFetch) { + Object.defineProperty(globalThis, "fetch", { + configurable: true, + writable: true, + value: originalFetch, + }) + } + }) + + it("cleans pending nock scopes", () => { + nock("https://test.example").get("/health").reply(200) + expect(nock.pendingMocks()).toHaveLength(1) + + resetNock() + + expect(nock.pendingMocks()).toEqual([]) + }) +}) diff --git a/src/test-utils/__tests__/vscode.spec.ts b/src/test-utils/__tests__/vscode.spec.ts new file mode 100644 index 0000000000..81ce4a7468 --- /dev/null +++ b/src/test-utils/__tests__/vscode.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from "vitest" + +import { + makeDisposable, + makeEventEmitter, + makeExtensionContext, + makePosition, + makeRange, + makeSelection, + makeTextDocument, + makeTextEditor, + makeUri, + makeWorkspaceConfiguration, +} from "../vscode" + +describe("VS Code test utilities", () => { + it("creates the common VS Code value shapes", async () => { + expect(makePosition(2, 3)).toEqual({ line: 2, character: 3 }) + expect(makeRange(1, 2, 3, 4)).toEqual({ + start: { line: 1, character: 2 }, + end: { line: 3, character: 4 }, + }) + expect(makeSelection(4, 5)).toEqual({ + anchor: { line: 4, character: 5 }, + active: { line: 4, character: 5 }, + }) + + const uri = makeUri("/tmp/test.ts", { scheme: "untitled" }) + const document = makeTextDocument({ + uri, + getText: vi.fn().mockReturnValue("first\nsecond"), + }) + const editor = makeTextEditor({ document }) + + expect(uri).toMatchObject({ fsPath: "/tmp/test.ts", scheme: "untitled" }) + expect(uri.toString()).toBe("/tmp/test.ts") + expect(uri.toJSON()).toEqual({ fsPath: "/tmp/test.ts" }) + expect(document.lineCount).toBe(2) + expect(document.lineAt(1).text).toBe("second") + expect(document.getText()).toBe("first\nsecond") + expect(document.getWordRangeAtPosition(makePosition())).toBeUndefined() + expect(document.offsetAt(makePosition())).toBeUndefined() + expect(document.positionAt(0)).toBeUndefined() + expect(document.validateRange(makeRange())).toEqual(makeRange()) + expect(document.validatePosition(makePosition())).toEqual(makePosition()) + expect(makeTextDocument().getText()).toBe("") + expect(editor.document).toBe(document) + expect(await editor.edit(() => undefined)).toBe(true) + + const disposable = makeDisposable() + disposable.dispose() + expect(disposable.dispose).toHaveBeenCalledOnce() + }) + + it("supports event subscriptions and cleanup", () => { + const emitter = makeEventEmitter() + const listener = vi.fn() + const subscription = emitter.event(listener) + + emitter.fire(1) + expect(listener).toHaveBeenCalledWith(1) + + subscription.dispose() + emitter.fire(2) + expect(listener).toHaveBeenCalledOnce() + + emitter.dispose() + }) + + it("creates configurable workspace settings", async () => { + const configuration = makeWorkspaceConfiguration({ enabled: true }) + + expect(configuration.get("enabled")).toBe(true) + expect(configuration.get("missing", "fallback")).toBe("fallback") + expect(configuration.has("enabled")).toBe(true) + expect(configuration.has("missing")).toBe(false) + + await configuration.update("enabled", false) + expect(configuration.update).toHaveBeenCalledWith("enabled", false) + }) + + it("creates an extension context with fresh state containers", async () => { + const context = makeExtensionContext({ extensionPath: "/custom/extension" }) + + expect(context.extensionPath).toBe("/custom/extension") + expect(context.asAbsolutePath("dist")).toBe("/mock/extension/dist") + expect(context.workspaceState.keys()).toEqual([]) + await context.workspaceState.update("key", "value") + await context.secrets.store("key", "value") + expect(context.workspaceState.update).toHaveBeenCalledWith("key", "value") + expect(context.secrets.store).toHaveBeenCalledWith("key", "value") + }) +}) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 88463bb2dc..a672abbdc1 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -272,9 +272,13 @@ const createInitialExtensionState = (): ExtensionState => ({ lockApiConfigAcrossModes: false, }) +type ExtensionStateProviderInitialState = Partial & { + routerModels?: RouterModels +} + export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode - initialState?: Partial + initialState?: ExtensionStateProviderInitialState }> = ({ children, initialState }) => { const [state, setState] = useState(() => mergeExtensionState(createInitialExtensionState(), initialState ?? {}), @@ -288,19 +292,30 @@ export const ExtensionStateContextProvider: React.FC<{ const [commands, setCommands] = useState([]) const [mcpServers, setMcpServers] = useState([]) const [currentCheckpoint, setCurrentCheckpoint] = useState() - const [extensionRouterModels, setExtensionRouterModels] = useState(undefined) - const [marketplaceItems, setMarketplaceItems] = useState([]) - const [alwaysAllowFollowupQuestions, setAlwaysAllowFollowupQuestions] = useState(false) // Add state for follow-up questions auto-approve - const [followupAutoApproveTimeoutMs, setFollowupAutoApproveTimeoutMs] = useState(undefined) // Will be set from global settings - const [marketplaceInstalledMetadata, setMarketplaceInstalledMetadata] = useState({ - project: {}, - global: {}, - }) + const [extensionRouterModels, setExtensionRouterModels] = useState( + () => initialState?.routerModels, + ) + const [marketplaceItems, setMarketplaceItems] = useState(() => initialState?.marketplaceItems ?? []) + const [alwaysAllowFollowupQuestions, setAlwaysAllowFollowupQuestions] = useState( + () => initialState?.alwaysAllowFollowupQuestions ?? false, + ) // Add state for follow-up questions auto-approve + const [followupAutoApproveTimeoutMs, setFollowupAutoApproveTimeoutMs] = useState( + () => initialState?.followupAutoApproveTimeoutMs, + ) // Will be set from global settings + const [marketplaceInstalledMetadata, setMarketplaceInstalledMetadata] = useState( + () => + initialState?.marketplaceInstalledMetadata ?? { + project: {}, + global: {}, + }, + ) const [skills, setSkills] = useState([]) const [rules, setRules] = useState([]) - const [includeTaskHistoryInEnhance, setIncludeTaskHistoryInEnhance] = useState(true) - const [includeCurrentTime, setIncludeCurrentTime] = useState(true) - const [includeCurrentCost, setIncludeCurrentCost] = useState(true) + const [includeTaskHistoryInEnhance, setIncludeTaskHistoryInEnhance] = useState( + () => initialState?.includeTaskHistoryInEnhance ?? true, + ) + const [includeCurrentTime, setIncludeCurrentTime] = useState(() => initialState?.includeCurrentTime ?? true) + const [includeCurrentCost, setIncludeCurrentCost] = useState(() => initialState?.includeCurrentCost ?? true) const setListApiConfigMeta = useCallback( (value: ProviderSettingsEntry[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })), diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index b9655ace0a..23ac911585 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -6,6 +6,9 @@ import { type ExperimentId, type ExtensionState, type ClineMessage, + type MarketplaceItem, + type MarketplaceInstalledMetadata, + type RouterModels, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, DEFAULT_DIFF_FUZZY_THRESHOLD, } from "@roo-code/types" @@ -71,6 +74,34 @@ const ApiConfigTestComponent = () => { ) } +const InitialStateTestComponent = () => { + const { + alwaysAllowFollowupQuestions, + followupAutoApproveTimeoutMs, + includeTaskHistoryInEnhance, + includeCurrentTime, + includeCurrentCost, + routerModels, + marketplaceItems, + marketplaceInstalledMetadata, + } = useExtensionState() + + return ( +
+ {JSON.stringify({ + alwaysAllowFollowupQuestions, + followupAutoApproveTimeoutMs, + includeTaskHistoryInEnhance, + includeCurrentTime, + includeCurrentCost, + routerModels, + marketplaceItems, + marketplaceInstalledMetadata, + })} +
+ ) +} + describe("ExtensionStateContext", () => { it("initializes with empty allowedCommands array", () => { render( @@ -181,6 +212,50 @@ describe("ExtensionStateContext", () => { expect(JSON.parse(screen.getByTestId("show-rooignored-files").textContent!)).toBe(true) }) + it("initializes shadowed context fields from initialState", () => { + const routerModels = {} as RouterModels + const marketplaceItems: MarketplaceItem[] = [ + { + id: "mode-item", + name: "Test mode", + description: "A test mode", + type: "mode", + content: "custom mode content", + }, + ] + const marketplaceInstalledMetadata: MarketplaceInstalledMetadata = { + project: { "mode-item": { type: "mode" } }, + global: {}, + } + + render( + + + , + ) + + expect(JSON.parse(screen.getByTestId("initial-state").textContent!)).toEqual({ + alwaysAllowFollowupQuestions: true, + followupAutoApproveTimeoutMs: 1500, + includeTaskHistoryInEnhance: false, + includeCurrentTime: false, + includeCurrentCost: false, + routerModels: {}, + marketplaceItems, + marketplaceInstalledMetadata, + }) + }) + it("updates showRooIgnoredFiles through setShowRooIgnoredFiles", () => { render(