Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
41 changes: 16 additions & 25 deletions src/api/providers/__tests__/openai-native.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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),
)
})
Expand Down
5 changes: 3 additions & 2 deletions src/api/providers/__tests__/openai-usage-tracking.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
})
Expand Down
5 changes: 3 additions & 2 deletions src/api/providers/__tests__/openai.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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()
})
Expand Down
4 changes: 2 additions & 2 deletions src/eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
23 changes: 9 additions & 14 deletions src/integrations/editor/__tests__/DiffViewProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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
}

Expand Down
23 changes: 23 additions & 0 deletions src/test-utils/__tests__/api.spec.ts
Original file line number Diff line number Diff line change
@@ -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" }))
})
})
17 changes: 17 additions & 0 deletions src/test-utils/__tests__/fs.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
51 changes: 51 additions & 0 deletions src/test-utils/__tests__/reset.spec.ts
Original file line number Diff line number Diff line change
@@ -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([])
})
})
93 changes: 93 additions & 0 deletions src/test-utils/__tests__/vscode.spec.ts
Original file line number Diff line number Diff line change
@@ -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<number>()
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")
})
})
Loading
Loading