This commit is contained in:
roomote-v0[bot] 2026-04-23 04:58:28 +00:00 • committed by GitHub
commit 7dc3ef96ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 112 additions and 52 deletions

View file

@ -1,4 +1,3 @@
import axios from "axios"
import * as yaml from "yaml" import * as yaml from "yaml"
import { z } from "zod" import { z } from "zod"
@ -88,14 +87,28 @@ export class RemoteConfigLoader {
for (let i = 0; i < maxRetries; i++) { for (let i = 0; i < maxRetries; i++) {
try { try {
const response = await axios.get(url, { // Use AbortController for timeout - fetch does not have built-in timeout
timeout: 10000, // 10 second timeout const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10000) // 10 second timeout
const response = await fetch(url, {
signal: controller.signal,
headers: { headers: {
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}) })
return response.data as T
clearTimeout(timeoutId)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
// Using fetch with native fetch API in VS Code automatically respects
// VS Code's http.proxy settings, unlike axios which requires manual
// proxy agent configuration
return (await response.text()) as T
} catch (error) { } catch (error) {
lastError = error as Error lastError = error as Error
if (i < maxRetries - 1) { if (i < maxRetries - 1) {

View file

@ -16,9 +16,6 @@ vi.mock("@roo-code/cloud", () => ({
}, },
})) }))
// Mock axios
vi.mock("axios")
// Mock TelemetryService // Mock TelemetryService
vi.mock("../../../../packages/telemetry/src/TelemetryService", () => ({ vi.mock("../../../../packages/telemetry/src/TelemetryService", () => ({
TelemetryService: { TelemetryService: {

View file

@ -1,13 +1,8 @@
// npx vitest services/marketplace/__tests__/RemoteConfigLoader.spec.ts // npx vitest services/marketplace/__tests__/RemoteConfigLoader.spec.ts
import axios from "axios"
import { RemoteConfigLoader } from "../RemoteConfigLoader" import { RemoteConfigLoader } from "../RemoteConfigLoader"
import type { MarketplaceItemType } from "@roo-code/types" import type { MarketplaceItemType } from "@roo-code/types"
// Mock axios
vi.mock("axios")
const mockedAxios = axios as any
// Mock the cloud config // Mock the cloud config
vi.mock("@roo-code/cloud", () => ({ vi.mock("@roo-code/cloud", () => ({
getRooCodeApiUrl: () => "https://test.api.com", getRooCodeApiUrl: () => "https://test.api.com",
@ -15,12 +10,21 @@ vi.mock("@roo-code/cloud", () => ({
describe("RemoteConfigLoader", () => { describe("RemoteConfigLoader", () => {
let loader: RemoteConfigLoader let loader: RemoteConfigLoader
let mockFetch: ReturnType<typeof vi.fn>
beforeEach(() => { beforeEach(() => {
loader = new RemoteConfigLoader() loader = new RemoteConfigLoader()
vi.clearAllMocks() vi.clearAllMocks()
// Clear any existing cache // Clear any existing cache
loader.clearCache() loader.clearCache()
// Reset fetch mock
mockFetch = vi.fn()
global.fetch = mockFetch
})
afterEach(() => {
vi.restoreAllMocks()
}) })
describe("loadAllItems", () => { describe("loadAllItems", () => {
@ -38,33 +42,37 @@ describe("RemoteConfigLoader", () => {
url: "https://github.com/test/test-mcp" url: "https://github.com/test/test-mcp"
content: '{"command": "test"}'` content: '{"command": "test"}'`
mockedAxios.get.mockImplementation((url: string) => { mockFetch.mockImplementation((url: string) => {
if (url.includes("/modes")) { if (url.includes("/modes")) {
return Promise.resolve({ data: mockModesYaml }) return Promise.resolve({
ok: true,
text: () => Promise.resolve(mockModesYaml),
})
} }
if (url.includes("/mcps")) { if (url.includes("/mcps")) {
return Promise.resolve({ data: mockMcpsYaml }) return Promise.resolve({
ok: true,
text: () => Promise.resolve(mockMcpsYaml),
})
} }
return Promise.reject(new Error("Unknown URL")) return Promise.reject(new Error("Unknown URL"))
}) })
const items = await loader.loadAllItems() const items = await loader.loadAllItems()
expect(mockedAxios.get).toHaveBeenCalledTimes(2) expect(mockFetch).toHaveBeenCalledTimes(2)
expect(mockedAxios.get).toHaveBeenCalledWith( expect(mockFetch).toHaveBeenCalledWith(
"https://test.api.com/api/marketplace/modes", "https://test.api.com/api/marketplace/modes",
expect.objectContaining({ expect.objectContaining({
timeout: 10000,
headers: { headers: {
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}), }),
) )
expect(mockedAxios.get).toHaveBeenCalledWith( expect(mockFetch).toHaveBeenCalledWith(
"https://test.api.com/api/marketplace/mcps", "https://test.api.com/api/marketplace/mcps",
expect.objectContaining({ expect.objectContaining({
timeout: 10000,
headers: { headers: {
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
@ -104,23 +112,29 @@ describe("RemoteConfigLoader", () => {
url: "https://github.com/test/test-mcp" url: "https://github.com/test/test-mcp"
content: "test content"` content: "test content"`
mockedAxios.get.mockImplementation((url: string) => { mockFetch.mockImplementation((url: string) => {
if (url.includes("/modes")) { if (url.includes("/modes")) {
return Promise.resolve({ data: mockModesYaml }) return Promise.resolve({
ok: true,
text: () => Promise.resolve(mockModesYaml),
})
} }
if (url.includes("/mcps")) { if (url.includes("/mcps")) {
return Promise.resolve({ data: mockMcpsYaml }) return Promise.resolve({
ok: true,
text: () => Promise.resolve(mockMcpsYaml),
})
} }
return Promise.reject(new Error("Unknown URL")) return Promise.reject(new Error("Unknown URL"))
}) })
// First call - should hit API // First call - should hit API
const items1 = await loader.loadAllItems() const items1 = await loader.loadAllItems()
expect(mockedAxios.get).toHaveBeenCalledTimes(2) expect(mockFetch).toHaveBeenCalledTimes(2)
// Second call - should use cache // Second call - should use cache
const items2 = await loader.loadAllItems() const items2 = await loader.loadAllItems()
expect(mockedAxios.get).toHaveBeenCalledTimes(2) // Still 2, not 4 expect(mockFetch).toHaveBeenCalledTimes(2) // Still 2, not 4
expect(items1).toEqual(items2) expect(items1).toEqual(items2)
}) })
@ -136,16 +150,22 @@ describe("RemoteConfigLoader", () => {
// Mock modes endpoint to fail twice then succeed // Mock modes endpoint to fail twice then succeed
let modesCallCount = 0 let modesCallCount = 0
mockedAxios.get.mockImplementation((url: string) => { mockFetch.mockImplementation((url: string) => {
if (url.includes("/modes")) { if (url.includes("/modes")) {
modesCallCount++ modesCallCount++
if (modesCallCount <= 2) { if (modesCallCount <= 2) {
return Promise.reject(new Error("Network error")) return Promise.reject(new Error("Network error"))
} }
return Promise.resolve({ data: mockModesYaml }) return Promise.resolve({
ok: true,
text: () => Promise.resolve(mockModesYaml),
})
} }
if (url.includes("/mcps")) { if (url.includes("/mcps")) {
return Promise.resolve({ data: mockMcpsYaml }) return Promise.resolve({
ok: true,
text: () => Promise.resolve(mockMcpsYaml),
})
} }
return Promise.reject(new Error("Unknown URL")) return Promise.reject(new Error("Unknown URL"))
}) })
@ -159,18 +179,15 @@ describe("RemoteConfigLoader", () => {
}) })
it("should throw error after max retries", async () => { it("should throw error after max retries", async () => {
mockedAxios.get.mockRejectedValue(new Error("Persistent network error")) mockFetch.mockRejectedValue(new Error("Persistent network error"))
await expect(loader.loadAllItems()).rejects.toThrow("Persistent network error") await expect(loader.loadAllItems()).rejects.toThrow("Persistent network error")
// Both endpoints will be called with retries since Promise.all starts both promises // Both endpoints will be called with retries since Promise.all starts both promises
// Each endpoint retries 3 times, but due to Promise.all behavior, one might fail faster // Each endpoint retries 3 times, but due to Promise.all behavior, one might fail faster
expect(mockedAxios.get).toHaveBeenCalledWith( expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("/api/marketplace/"), expect.any(Object))
expect.stringContaining("/api/marketplace/"),
expect.any(Object),
)
// Verify we got at least some retry attempts (should be at least 2 calls) // Verify we got at least some retry attempts (should be at least 2 calls)
expect(mockedAxios.get.mock.calls.length).toBeGreaterThanOrEqual(2) expect(mockFetch.mock.calls.length).toBeGreaterThanOrEqual(2)
}) })
it("should handle invalid data gracefully", async () => { it("should handle invalid data gracefully", async () => {
@ -185,12 +202,18 @@ describe("RemoteConfigLoader", () => {
url: "https://github.com/test/test-mcp" url: "https://github.com/test/test-mcp"
content: "test content"` content: "test content"`
mockedAxios.get.mockImplementation((url: string) => { mockFetch.mockImplementation((url: string) => {
if (url.includes("/modes")) { if (url.includes("/modes")) {
return Promise.resolve({ data: invalidModesYaml }) return Promise.resolve({
ok: true,
text: () => Promise.resolve(invalidModesYaml),
})
} }
if (url.includes("/mcps")) { if (url.includes("/mcps")) {
return Promise.resolve({ data: validMcpsYaml }) return Promise.resolve({
ok: true,
text: () => Promise.resolve(validMcpsYaml),
})
} }
return Promise.reject(new Error("Unknown URL")) return Promise.reject(new Error("Unknown URL"))
}) })
@ -198,6 +221,15 @@ describe("RemoteConfigLoader", () => {
// Should throw validation error for invalid modes // Should throw validation error for invalid modes
await expect(loader.loadAllItems()).rejects.toThrow() await expect(loader.loadAllItems()).rejects.toThrow()
}) })
it("should handle HTTP error responses", async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 500,
})
await expect(loader.loadAllItems()).rejects.toThrow("HTTP error! status: 500")
})
}) })
describe("getItem", () => { describe("getItem", () => {
@ -215,12 +247,18 @@ describe("RemoteConfigLoader", () => {
url: "https://github.com/test/test-mcp" url: "https://github.com/test/test-mcp"
content: "test content"` content: "test content"`
mockedAxios.get.mockImplementation((url: string) => { mockFetch.mockImplementation((url: string) => {
if (url.includes("/modes")) { if (url.includes("/modes")) {
return Promise.resolve({ data: mockModesYaml }) return Promise.resolve({
ok: true,
text: () => Promise.resolve(mockModesYaml),
})
} }
if (url.includes("/mcps")) { if (url.includes("/mcps")) {
return Promise.resolve({ data: mockMcpsYaml }) return Promise.resolve({
ok: true,
text: () => Promise.resolve(mockMcpsYaml),
})
} }
return Promise.reject(new Error("Unknown URL")) return Promise.reject(new Error("Unknown URL"))
}) })
@ -260,30 +298,36 @@ describe("RemoteConfigLoader", () => {
const mockMcpsYaml = `items: []` const mockMcpsYaml = `items: []`
mockedAxios.get.mockImplementation((url: string) => { mockFetch.mockImplementation((url: string) => {
if (url.includes("/modes")) { if (url.includes("/modes")) {
return Promise.resolve({ data: mockModesYaml }) return Promise.resolve({
ok: true,
text: () => Promise.resolve(mockModesYaml),
})
} }
if (url.includes("/mcps")) { if (url.includes("/mcps")) {
return Promise.resolve({ data: mockMcpsYaml }) return Promise.resolve({
ok: true,
text: () => Promise.resolve(mockMcpsYaml),
})
} }
return Promise.reject(new Error("Unknown URL")) return Promise.reject(new Error("Unknown URL"))
}) })
// First call // First call
await loader.loadAllItems() await loader.loadAllItems()
expect(mockedAxios.get).toHaveBeenCalledTimes(2) expect(mockFetch).toHaveBeenCalledTimes(2)
// Second call - should use cache // Second call - should use cache
await loader.loadAllItems() await loader.loadAllItems()
expect(mockedAxios.get).toHaveBeenCalledTimes(2) expect(mockFetch).toHaveBeenCalledTimes(2)
// Clear cache // Clear cache
loader.clearCache() loader.clearCache()
// Third call - should hit API again // Third call - should hit API again
await loader.loadAllItems() await loader.loadAllItems()
expect(mockedAxios.get).toHaveBeenCalledTimes(4) expect(mockFetch).toHaveBeenCalledTimes(4)
}) })
}) })
@ -297,12 +341,18 @@ describe("RemoteConfigLoader", () => {
const mockMcpsYaml = `items: []` const mockMcpsYaml = `items: []`
mockedAxios.get.mockImplementation((url: string) => { mockFetch.mockImplementation((url: string) => {
if (url.includes("/modes")) { if (url.includes("/modes")) {
return Promise.resolve({ data: mockModesYaml }) return Promise.resolve({
ok: true,
text: () => Promise.resolve(mockModesYaml),
})
} }
if (url.includes("/mcps")) { if (url.includes("/mcps")) {
return Promise.resolve({ data: mockMcpsYaml }) return Promise.resolve({
ok: true,
text: () => Promise.resolve(mockMcpsYaml),
})
} }
return Promise.reject(new Error("Unknown URL")) return Promise.reject(new Error("Unknown URL"))
}) })
@ -315,18 +365,18 @@ describe("RemoteConfigLoader", () => {
// First call // First call
await loader.loadAllItems() await loader.loadAllItems()
expect(mockedAxios.get).toHaveBeenCalledTimes(2) expect(mockFetch).toHaveBeenCalledTimes(2)
// Second call immediately - should use cache // Second call immediately - should use cache
await loader.loadAllItems() await loader.loadAllItems()
expect(mockedAxios.get).toHaveBeenCalledTimes(2) expect(mockFetch).toHaveBeenCalledTimes(2)
// Advance time by 6 minutes (360,000 ms) // Advance time by 6 minutes (360,000 ms)
currentTime += 6 * 60 * 1000 currentTime += 6 * 60 * 1000
// Third call - cache should be expired // Third call - cache should be expired
await loader.loadAllItems() await loader.loadAllItems()
expect(mockedAxios.get).toHaveBeenCalledTimes(4) expect(mockFetch).toHaveBeenCalledTimes(4)
// Restore original Date.now // Restore original Date.now
Date.now = originalDateNow Date.now = originalDateNow