mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-25 05:53:57 +00:00
fix: update cloud tests and package.json URLs
- Update all cloud package tests to match no-op implementations - All 171 tests now pass - Update src/package.json repository URL and homepage to point to actual fork
This commit is contained in:
parent
2fc7798424
commit
f43d5edd74
8 changed files with 348 additions and 3096 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { CloudAPI } from "../CloudAPI.js"
|
||||
import { AuthenticationError, CloudAPIError } from "../errors.js"
|
||||
import { CloudAPIError } from "../errors.js"
|
||||
import type { AuthService } from "@roo-code/types"
|
||||
|
||||
// Mock the config module
|
||||
|
|
@ -15,7 +15,7 @@ vi.mock("../utils.js", () => ({
|
|||
|
||||
describe("CloudAPI.creditBalance", () => {
|
||||
let mockAuthService: {
|
||||
getSessionToken: Mock<() => string | undefined>
|
||||
getSessionToken: ReturnType<typeof vi.fn>
|
||||
}
|
||||
let cloudAPI: CloudAPI
|
||||
|
||||
|
|
@ -29,68 +29,37 @@ describe("CloudAPI.creditBalance", () => {
|
|||
global.fetch = vi.fn()
|
||||
})
|
||||
|
||||
it("should fetch credit balance successfully", async () => {
|
||||
const mockBalance = 12.34
|
||||
mockAuthService.getSessionToken.mockReturnValue("test-session-token")
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ balance: mockBalance }),
|
||||
})
|
||||
|
||||
const balance = await cloudAPI.creditBalance()
|
||||
|
||||
expect(balance).toBe(mockBalance)
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://api.test.com/api/extension/credit-balance",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer test-session-token",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "test-user-agent",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
it("should throw CloudAPIError when cloud features are disabled", async () => {
|
||||
await expect(cloudAPI.creditBalance()).rejects.toThrow(CloudAPIError)
|
||||
await expect(cloudAPI.creditBalance()).rejects.toThrow("Cloud features are disabled in this fork")
|
||||
})
|
||||
|
||||
it("should throw AuthenticationError when session token is missing", async () => {
|
||||
mockAuthService.getSessionToken.mockReturnValue(undefined)
|
||||
|
||||
await expect(cloudAPI.creditBalance()).rejects.toThrow(AuthenticationError)
|
||||
})
|
||||
|
||||
it("should handle API errors", async () => {
|
||||
it("should throw CloudAPIError regardless of session token", async () => {
|
||||
mockAuthService.getSessionToken.mockReturnValue("test-session-token")
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
json: async () => ({ error: "Server error" }),
|
||||
})
|
||||
|
||||
await expect(cloudAPI.creditBalance()).rejects.toThrow(CloudAPIError)
|
||||
})
|
||||
|
||||
it("should handle network errors", async () => {
|
||||
mockAuthService.getSessionToken.mockReturnValue("test-session-token")
|
||||
it("should not make any fetch calls", async () => {
|
||||
const fetchSpy = vi.fn()
|
||||
global.fetch = fetchSpy
|
||||
|
||||
global.fetch = vi.fn().mockRejectedValue(new TypeError("fetch failed"))
|
||||
try {
|
||||
await cloudAPI.creditBalance()
|
||||
} catch {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
await expect(cloudAPI.creditBalance()).rejects.toThrow(
|
||||
"Network error while calling /api/extension/credit-balance",
|
||||
)
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle invalid response format", async () => {
|
||||
it("should throw CloudAPIError when session token is missing", async () => {
|
||||
mockAuthService.getSessionToken.mockReturnValue(undefined)
|
||||
await expect(cloudAPI.creditBalance()).rejects.toThrow(CloudAPIError)
|
||||
})
|
||||
|
||||
it("should throw CloudAPIError on network errors", async () => {
|
||||
mockAuthService.getSessionToken.mockReturnValue("test-session-token")
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ invalid: "response" }),
|
||||
})
|
||||
|
||||
await expect(cloudAPI.creditBalance()).rejects.toThrow()
|
||||
global.fetch = vi.fn().mockRejectedValue(new TypeError("fetch failed"))
|
||||
await expect(cloudAPI.creditBalance()).rejects.toThrow(CloudAPIError)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -32,132 +32,54 @@ describe("CloudSettingsService - Response Parsing", () => {
|
|||
service = new CloudSettingsService(mockContext, mockAuthService, vi.fn())
|
||||
})
|
||||
|
||||
it("should successfully parse valid extension settings response", async () => {
|
||||
// Mock fetch response with a valid settings structure
|
||||
const mockResponse = {
|
||||
organization: {
|
||||
version: 1,
|
||||
defaultSettings: {},
|
||||
allowList: {
|
||||
allowAll: true,
|
||||
providers: {},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
features: {},
|
||||
settings: {},
|
||||
version: 1,
|
||||
},
|
||||
}
|
||||
afterEach(() => {
|
||||
service.dispose()
|
||||
})
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(mockResponse),
|
||||
})
|
||||
|
||||
// Initialize the service
|
||||
it("should return undefined settings when cloud features are disabled", async () => {
|
||||
await service.initialize()
|
||||
|
||||
// Wait for the fetch to be called (timer executes immediately but asynchronously)
|
||||
await vi.waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Wait a bit for the async processing to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
// Verify settings were parsed correctly
|
||||
const orgSettings = service.getSettings()
|
||||
const userSettings = service.getUserSettings()
|
||||
|
||||
expect(orgSettings).toEqual(mockResponse.organization)
|
||||
expect(userSettings).toEqual(mockResponse.user)
|
||||
expect(orgSettings).toBeUndefined()
|
||||
expect(userSettings).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should handle complex nested provider settings without type errors", async () => {
|
||||
// Mock response with complex nested provider settings
|
||||
const mockResponse = {
|
||||
organization: {
|
||||
version: 2,
|
||||
defaultSettings: {
|
||||
maxOpenTabsContext: 10,
|
||||
},
|
||||
allowList: {
|
||||
allowAll: false,
|
||||
providers: {
|
||||
anthropic: {
|
||||
allowAll: true,
|
||||
},
|
||||
openai: {
|
||||
allowAll: false,
|
||||
models: ["gpt-4", "gpt-3.5-turbo"],
|
||||
},
|
||||
},
|
||||
},
|
||||
providerProfiles: {
|
||||
default: {
|
||||
id: "default",
|
||||
apiProvider: "anthropic",
|
||||
apiModelId: "claude-3-opus-20240229",
|
||||
apiKey: "test-key",
|
||||
modelTemperature: 0.7,
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
features: {},
|
||||
settings: {},
|
||||
version: 1,
|
||||
},
|
||||
}
|
||||
it("should not make fetch calls when cloud features are disabled", async () => {
|
||||
const mockFetch = vi.fn()
|
||||
global.fetch = mockFetch
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(mockResponse),
|
||||
})
|
||||
|
||||
// Initialize the service
|
||||
await service.initialize()
|
||||
|
||||
// Wait for the fetch to be called (timer executes immediately but asynchronously)
|
||||
await vi.waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalled()
|
||||
})
|
||||
// Wait a bit to ensure no async fetch calls are made
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
// Wait a bit for the async processing to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
// Verify complex settings were parsed correctly
|
||||
const orgSettings = service.getSettings()
|
||||
const userSettings = service.getUserSettings()
|
||||
|
||||
expect(orgSettings).toEqual(mockResponse.organization)
|
||||
expect(userSettings).toEqual(mockResponse.user)
|
||||
expect(orgSettings?.providerProfiles?.default).toBeDefined()
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle invalid response gracefully", async () => {
|
||||
// Mock invalid response
|
||||
const mockResponse = {
|
||||
organization: {
|
||||
// Missing required fields
|
||||
version: 1,
|
||||
},
|
||||
user: {
|
||||
// Missing required fields
|
||||
version: 1,
|
||||
},
|
||||
}
|
||||
|
||||
it("should return undefined settings regardless of response data", async () => {
|
||||
// Even if fetch is mocked with valid data, settings should be undefined
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(mockResponse),
|
||||
json: vi.fn().mockResolvedValue({
|
||||
organization: {
|
||||
version: 1,
|
||||
defaultSettings: {},
|
||||
allowList: { allowAll: true, providers: {} },
|
||||
},
|
||||
user: {
|
||||
features: {},
|
||||
settings: {},
|
||||
version: 1,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
// Initialize the service
|
||||
await service.initialize()
|
||||
|
||||
// Settings should remain undefined due to validation failure
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const orgSettings = service.getSettings()
|
||||
const userSettings = service.getUserSettings()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,54 +1,15 @@
|
|||
import type { ExtensionContext } from "vscode"
|
||||
|
||||
import type { OrganizationSettings, AuthService } from "@roo-code/types"
|
||||
import type { AuthService } from "@roo-code/types"
|
||||
|
||||
import { CloudSettingsService } from "../CloudSettingsService.js"
|
||||
import { RefreshTimer } from "../RefreshTimer.js"
|
||||
|
||||
vi.mock("../RefreshTimer")
|
||||
|
||||
vi.mock("../config", () => ({
|
||||
getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"),
|
||||
}))
|
||||
|
||||
global.fetch = vi.fn()
|
||||
|
||||
describe("CloudSettingsService", () => {
|
||||
let mockContext: ExtensionContext
|
||||
let mockAuthService: {
|
||||
getState: ReturnType<typeof vi.fn>
|
||||
getSessionToken: ReturnType<typeof vi.fn>
|
||||
hasActiveSession: ReturnType<typeof vi.fn>
|
||||
on: ReturnType<typeof vi.fn>
|
||||
getStoredOrganizationId: ReturnType<typeof vi.fn>
|
||||
}
|
||||
let mockRefreshTimer: {
|
||||
start: ReturnType<typeof vi.fn>
|
||||
stop: ReturnType<typeof vi.fn>
|
||||
}
|
||||
let mockAuthService: AuthService
|
||||
let cloudSettingsService: CloudSettingsService
|
||||
let mockLog: ReturnType<typeof vi.fn>
|
||||
|
||||
const mockSettings: OrganizationSettings = {
|
||||
version: 1,
|
||||
defaultSettings: {},
|
||||
allowList: {
|
||||
allowAll: true,
|
||||
providers: {},
|
||||
},
|
||||
}
|
||||
|
||||
const mockUserSettings = {
|
||||
features: {},
|
||||
settings: {},
|
||||
version: 1,
|
||||
}
|
||||
|
||||
const mockExtensionSettingsResponse = {
|
||||
organization: mockSettings,
|
||||
user: mockUserSettings,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
|
|
@ -65,19 +26,11 @@ describe("CloudSettingsService", () => {
|
|||
hasActiveSession: vi.fn().mockReturnValue(false),
|
||||
on: vi.fn(),
|
||||
getStoredOrganizationId: vi.fn().mockReturnValue(null),
|
||||
}
|
||||
|
||||
mockRefreshTimer = {
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
}
|
||||
} as unknown as AuthService
|
||||
|
||||
mockLog = vi.fn()
|
||||
|
||||
// Mock RefreshTimer constructor
|
||||
vi.mocked(RefreshTimer).mockImplementation(() => mockRefreshTimer as unknown as RefreshTimer)
|
||||
|
||||
cloudSettingsService = new CloudSettingsService(mockContext, mockAuthService as unknown as AuthService, mockLog)
|
||||
cloudSettingsService = new CloudSettingsService(mockContext, mockAuthService, mockLog)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -87,638 +40,94 @@ describe("CloudSettingsService", () => {
|
|||
describe("constructor", () => {
|
||||
it("should create CloudSettingsService with proper dependencies", () => {
|
||||
expect(cloudSettingsService).toBeInstanceOf(CloudSettingsService)
|
||||
expect(RefreshTimer).toHaveBeenCalledWith({
|
||||
callback: expect.any(Function),
|
||||
successInterval: 3_600_000,
|
||||
initialBackoffMs: 1000,
|
||||
maxBackoffMs: 3_600_000,
|
||||
})
|
||||
})
|
||||
|
||||
it("should use console.log as default logger when none provided", () => {
|
||||
const service = new CloudSettingsService(mockContext, mockAuthService as unknown as AuthService)
|
||||
const service = new CloudSettingsService(mockContext, mockAuthService)
|
||||
expect(service).toBeInstanceOf(CloudSettingsService)
|
||||
service.dispose()
|
||||
})
|
||||
|
||||
it("should log that cloud features are disabled", () => {
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
"[cloud-settings] Cloud features disabled — CloudSettingsService is a no-op",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("initialize", () => {
|
||||
it("should load cached settings on initialization", async () => {
|
||||
const cachedSettings = {
|
||||
version: 1,
|
||||
defaultSettings: {},
|
||||
allowList: { allowAll: true, providers: {} },
|
||||
}
|
||||
|
||||
// Create a fresh mock context for this test
|
||||
const testContext = {
|
||||
globalState: {
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === "organization-settings") return cachedSettings
|
||||
if (key === "user-settings") return mockUserSettings
|
||||
return undefined
|
||||
}),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
} as unknown as ExtensionContext
|
||||
|
||||
// Mock auth service to not be logged out
|
||||
const testAuthService = {
|
||||
getState: vi.fn().mockReturnValue("active"),
|
||||
getSessionToken: vi.fn(),
|
||||
hasActiveSession: vi.fn().mockReturnValue(false),
|
||||
on: vi.fn(),
|
||||
}
|
||||
|
||||
// Create a new instance to test initialization
|
||||
const testService = new CloudSettingsService(
|
||||
testContext,
|
||||
testAuthService as unknown as AuthService,
|
||||
mockLog,
|
||||
)
|
||||
await testService.initialize()
|
||||
|
||||
expect(testContext.globalState.get).toHaveBeenCalledWith("organization-settings")
|
||||
expect(testContext.globalState.get).toHaveBeenCalledWith("user-settings")
|
||||
expect(testService.getSettings()).toEqual(cachedSettings)
|
||||
|
||||
testService.dispose()
|
||||
})
|
||||
|
||||
it("should clear cached settings if user is logged out", async () => {
|
||||
const cachedSettings = {
|
||||
version: 1,
|
||||
defaultSettings: {},
|
||||
allowList: { allowAll: true, providers: {} },
|
||||
}
|
||||
mockContext.globalState.get = vi.fn((key: string) => {
|
||||
if (key === "organization-settings") return cachedSettings
|
||||
if (key === "user-settings") return mockUserSettings
|
||||
return undefined
|
||||
})
|
||||
mockAuthService.getState.mockReturnValue("logged-out")
|
||||
|
||||
it("should be a no-op", async () => {
|
||||
await cloudSettingsService.initialize()
|
||||
|
||||
// Check that both cache keys are cleared
|
||||
const updateCalls = vi.mocked(mockContext.globalState.update).mock.calls
|
||||
const orgSettingsCall = updateCalls.find((call) => call[0] === "organization-settings")
|
||||
const userSettingsCall = updateCalls.find((call) => call[0] === "user-settings")
|
||||
|
||||
expect(orgSettingsCall).toBeDefined()
|
||||
expect(orgSettingsCall?.[1]).toBeUndefined()
|
||||
expect(userSettingsCall).toBeDefined()
|
||||
expect(userSettingsCall?.[1]).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should set up auth service event listeners", async () => {
|
||||
await cloudSettingsService.initialize()
|
||||
|
||||
expect(mockAuthService.on).toHaveBeenCalledWith("auth-state-changed", expect.any(Function))
|
||||
})
|
||||
|
||||
it("should start timer if user has active session", async () => {
|
||||
mockAuthService.hasActiveSession.mockReturnValue(true)
|
||||
|
||||
await cloudSettingsService.initialize()
|
||||
|
||||
expect(mockRefreshTimer.start).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not start timer if user has no active session", async () => {
|
||||
mockAuthService.hasActiveSession.mockReturnValue(false)
|
||||
|
||||
await cloudSettingsService.initialize()
|
||||
|
||||
expect(mockRefreshTimer.start).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("event emission", () => {
|
||||
beforeEach(async () => {
|
||||
await cloudSettingsService.initialize()
|
||||
})
|
||||
|
||||
it("should emit 'settings-updated' event when settings change", async () => {
|
||||
const eventSpy = vi.fn()
|
||||
cloudSettingsService.on("settings-updated", eventSpy)
|
||||
|
||||
mockAuthService.getSessionToken.mockReturnValue("valid-token")
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(mockExtensionSettingsResponse),
|
||||
} as unknown as Response)
|
||||
|
||||
// Get the callback function passed to RefreshTimer
|
||||
const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
|
||||
|
||||
await timerCallback?.()
|
||||
|
||||
expect(eventSpy).toHaveBeenCalledWith({})
|
||||
})
|
||||
|
||||
it("should emit event when either org or user settings change", async () => {
|
||||
const eventSpy = vi.fn()
|
||||
|
||||
const previousSettings = {
|
||||
version: 1,
|
||||
defaultSettings: {},
|
||||
allowList: { allowAll: true, providers: {} },
|
||||
}
|
||||
const newSettings = {
|
||||
version: 2,
|
||||
defaultSettings: {},
|
||||
allowList: { allowAll: true, providers: {} },
|
||||
}
|
||||
|
||||
// Create a fresh mock context for this test
|
||||
const testContext = {
|
||||
globalState: {
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === "organization-settings") return previousSettings
|
||||
if (key === "user-settings") return mockUserSettings
|
||||
return undefined
|
||||
}),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
} as unknown as ExtensionContext
|
||||
|
||||
// Mock auth service to not be logged out
|
||||
const testAuthService = {
|
||||
getState: vi.fn().mockReturnValue("active"),
|
||||
getSessionToken: vi.fn().mockReturnValue("valid-token"),
|
||||
hasActiveSession: vi.fn().mockReturnValue(false),
|
||||
on: vi.fn(),
|
||||
}
|
||||
|
||||
// Create a new service instance with cached settings
|
||||
const testService = new CloudSettingsService(
|
||||
testContext,
|
||||
testAuthService as unknown as AuthService,
|
||||
mockLog,
|
||||
)
|
||||
testService.on("settings-updated", eventSpy)
|
||||
await testService.initialize()
|
||||
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
organization: newSettings,
|
||||
user: mockUserSettings,
|
||||
}),
|
||||
} as unknown as Response)
|
||||
|
||||
// Get the callback function passed to RefreshTimer for this instance
|
||||
const timerCallback =
|
||||
vi.mocked(RefreshTimer).mock.calls[vi.mocked(RefreshTimer).mock.calls.length - 1]?.[0]?.callback
|
||||
|
||||
await timerCallback?.()
|
||||
|
||||
expect(eventSpy).toHaveBeenCalledWith({})
|
||||
|
||||
testService.dispose()
|
||||
})
|
||||
|
||||
it("should not emit event when settings version is unchanged", async () => {
|
||||
const eventSpy = vi.fn()
|
||||
|
||||
// Create a fresh mock context for this test
|
||||
const testContext = {
|
||||
globalState: {
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === "organization-settings") return mockSettings
|
||||
if (key === "user-settings") return mockUserSettings
|
||||
return undefined
|
||||
}),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
} as unknown as ExtensionContext
|
||||
|
||||
// Mock auth service to not be logged out
|
||||
const testAuthService = {
|
||||
getState: vi.fn().mockReturnValue("active"),
|
||||
getSessionToken: vi.fn().mockReturnValue("valid-token"),
|
||||
hasActiveSession: vi.fn().mockReturnValue(false),
|
||||
on: vi.fn(),
|
||||
}
|
||||
|
||||
// Create a new service instance with cached settings
|
||||
const testService = new CloudSettingsService(
|
||||
testContext,
|
||||
testAuthService as unknown as AuthService,
|
||||
mockLog,
|
||||
)
|
||||
testService.on("settings-updated", eventSpy)
|
||||
await testService.initialize()
|
||||
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(mockExtensionSettingsResponse), // Same version
|
||||
} as unknown as Response)
|
||||
|
||||
// Get the callback function passed to RefreshTimer for this instance
|
||||
const timerCallback =
|
||||
vi.mocked(RefreshTimer).mock.calls[vi.mocked(RefreshTimer).mock.calls.length - 1]?.[0]?.callback
|
||||
|
||||
await timerCallback?.()
|
||||
|
||||
expect(eventSpy).not.toHaveBeenCalled()
|
||||
|
||||
testService.dispose()
|
||||
})
|
||||
|
||||
it("should not emit event when fetch fails", async () => {
|
||||
const eventSpy = vi.fn()
|
||||
cloudSettingsService.on("settings-updated", eventSpy)
|
||||
|
||||
mockAuthService.getSessionToken.mockReturnValue("valid-token")
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
} as unknown as Response)
|
||||
|
||||
// Get the callback function passed to RefreshTimer
|
||||
const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
|
||||
|
||||
await timerCallback?.()
|
||||
|
||||
expect(eventSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not emit event when no auth token available", async () => {
|
||||
const eventSpy = vi.fn()
|
||||
cloudSettingsService.on("settings-updated", eventSpy)
|
||||
|
||||
mockAuthService.getSessionToken.mockReturnValue(null)
|
||||
|
||||
// Get the callback function passed to RefreshTimer
|
||||
const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
|
||||
|
||||
await timerCallback?.()
|
||||
|
||||
expect(eventSpy).not.toHaveBeenCalled()
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchSettings", () => {
|
||||
beforeEach(async () => {
|
||||
await cloudSettingsService.initialize()
|
||||
})
|
||||
|
||||
it("should fetch and cache settings successfully", async () => {
|
||||
mockAuthService.getSessionToken.mockReturnValue("valid-token")
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(mockExtensionSettingsResponse),
|
||||
} as unknown as Response)
|
||||
|
||||
// Get the callback function passed to RefreshTimer
|
||||
const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
|
||||
|
||||
const result = await timerCallback?.()
|
||||
|
||||
expect(result).toBe(true)
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith("https://app.roocode.com/api/extension-settings", {
|
||||
headers: {
|
||||
Authorization: "Bearer valid-token",
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith("organization-settings", mockSettings)
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith("user-settings", mockUserSettings)
|
||||
})
|
||||
|
||||
it("should handle fetch errors gracefully", async () => {
|
||||
mockAuthService.getSessionToken.mockReturnValue("valid-token")
|
||||
vi.mocked(fetch).mockRejectedValue(new Error("Network error"))
|
||||
|
||||
// Get the callback function passed to RefreshTimer
|
||||
const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
|
||||
|
||||
const result = await timerCallback?.()
|
||||
|
||||
expect(result).toBe(false)
|
||||
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
"[cloud-settings] Error fetching extension settings:",
|
||||
expect.any(Error),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle invalid response format", async () => {
|
||||
mockAuthService.getSessionToken.mockReturnValue("valid-token")
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ invalid: "data" }),
|
||||
} as unknown as Response)
|
||||
|
||||
// Get the callback function passed to RefreshTimer
|
||||
const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
|
||||
|
||||
const result = await timerCallback?.()
|
||||
|
||||
expect(result).toBe(false)
|
||||
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
"[cloud-settings] Invalid extension settings format:",
|
||||
expect.any(Object),
|
||||
)
|
||||
expect(mockLog).toHaveBeenCalledWith("[cloud-settings] Cloud features disabled — initialize is a no-op")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getAllowList", () => {
|
||||
it("should return settings allowList when available", async () => {
|
||||
mockContext.globalState.get = vi.fn((key: string) => {
|
||||
if (key === "organization-settings") return mockSettings
|
||||
return undefined
|
||||
})
|
||||
await cloudSettingsService.initialize()
|
||||
|
||||
const allowList = cloudSettingsService.getAllowList()
|
||||
expect(allowList).toEqual(mockSettings.allowList)
|
||||
})
|
||||
|
||||
it("should return default allow all when no settings available", () => {
|
||||
it("should return default allow all when cloud features are disabled", () => {
|
||||
const allowList = cloudSettingsService.getAllowList()
|
||||
expect(allowList).toEqual({ allowAll: true, providers: {} })
|
||||
})
|
||||
})
|
||||
|
||||
describe("getSettings", () => {
|
||||
it("should return current settings", async () => {
|
||||
// Create a fresh mock context for this test
|
||||
const testContext = {
|
||||
globalState: {
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === "organization-settings") return mockSettings
|
||||
return undefined
|
||||
}),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
} as unknown as ExtensionContext
|
||||
|
||||
// Mock auth service to not be logged out
|
||||
const testAuthService = {
|
||||
getState: vi.fn().mockReturnValue("active"),
|
||||
getSessionToken: vi.fn(),
|
||||
hasActiveSession: vi.fn().mockReturnValue(false),
|
||||
on: vi.fn(),
|
||||
}
|
||||
|
||||
const testService = new CloudSettingsService(
|
||||
testContext,
|
||||
testAuthService as unknown as AuthService,
|
||||
mockLog,
|
||||
)
|
||||
await testService.initialize()
|
||||
|
||||
const settings = testService.getSettings()
|
||||
expect(settings).toEqual(mockSettings)
|
||||
|
||||
testService.dispose()
|
||||
it("should return undefined when cloud features are disabled", () => {
|
||||
const settings = cloudSettingsService.getSettings()
|
||||
expect(settings).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should return undefined when no settings available", () => {
|
||||
it("should return undefined even after initialization", async () => {
|
||||
await cloudSettingsService.initialize()
|
||||
const settings = cloudSettingsService.getSettings()
|
||||
expect(settings).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getUserSettings", () => {
|
||||
it("should return undefined when cloud features are disabled", () => {
|
||||
const userSettings = cloudSettingsService.getUserSettings()
|
||||
expect(userSettings).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getUserFeatures", () => {
|
||||
it("should return empty object when cloud features are disabled", () => {
|
||||
const features = cloudSettingsService.getUserFeatures()
|
||||
expect(features).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe("getUserSettingsConfig", () => {
|
||||
it("should return empty object when cloud features are disabled", () => {
|
||||
const config = cloudSettingsService.getUserSettingsConfig()
|
||||
expect(config).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateUserSettings", () => {
|
||||
it("should return false when cloud features are disabled", async () => {
|
||||
const result = await cloudSettingsService.updateUserSettings({ someSetting: "value" })
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isTaskSyncEnabled", () => {
|
||||
it("should return false when cloud features are disabled", () => {
|
||||
expect(cloudSettingsService.isTaskSyncEnabled()).toBe(false)
|
||||
})
|
||||
|
||||
it("should always return false regardless of settings", async () => {
|
||||
await cloudSettingsService.initialize()
|
||||
expect(cloudSettingsService.isTaskSyncEnabled()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("dispose", () => {
|
||||
it("should remove all listeners and stop timer", () => {
|
||||
it("should remove all listeners", () => {
|
||||
const removeAllListenersSpy = vi.spyOn(cloudSettingsService, "removeAllListeners")
|
||||
|
||||
cloudSettingsService.dispose()
|
||||
|
||||
expect(removeAllListenersSpy).toHaveBeenCalled()
|
||||
expect(mockRefreshTimer.stop).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("auth service event handlers", () => {
|
||||
it("should start timer when auth-state-changed event is triggered with active-session", async () => {
|
||||
await cloudSettingsService.initialize()
|
||||
|
||||
// Get the auth-state-changed handler
|
||||
const authStateChangedHandler = mockAuthService.on.mock.calls.find(
|
||||
(call: string[]) => call[0] === "auth-state-changed",
|
||||
)?.[1]
|
||||
expect(authStateChangedHandler).toBeDefined()
|
||||
|
||||
// Simulate active-session state change
|
||||
authStateChangedHandler({
|
||||
state: "active-session",
|
||||
previousState: "attempting-session",
|
||||
})
|
||||
expect(mockRefreshTimer.start).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should stop timer and remove settings when auth-state-changed event is triggered with logged-out", async () => {
|
||||
await cloudSettingsService.initialize()
|
||||
|
||||
// Get the auth-state-changed handler
|
||||
const authStateChangedHandler = mockAuthService.on.mock.calls.find(
|
||||
(call: string[]) => call[0] === "auth-state-changed",
|
||||
)?.[1]
|
||||
expect(authStateChangedHandler).toBeDefined()
|
||||
|
||||
// Simulate logged-out state change from active-session
|
||||
await authStateChangedHandler({
|
||||
state: "logged-out",
|
||||
previousState: "active-session",
|
||||
})
|
||||
expect(mockRefreshTimer.stop).toHaveBeenCalled()
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith("organization-settings", undefined)
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith("user-settings", undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isTaskSyncEnabled", () => {
|
||||
beforeEach(async () => {
|
||||
await cloudSettingsService.initialize()
|
||||
})
|
||||
|
||||
it("should return true when org recordTaskMessages is true", () => {
|
||||
// Set up mock settings with org recordTaskMessages = true
|
||||
const mockSettings = {
|
||||
version: 1,
|
||||
cloudSettings: {
|
||||
recordTaskMessages: true,
|
||||
},
|
||||
defaultSettings: {},
|
||||
allowList: { allowAll: true, providers: {} },
|
||||
}
|
||||
|
||||
// Mock that user has organization ID (indicating org settings should be used)
|
||||
mockAuthService.getStoredOrganizationId.mockReturnValue("org-123")
|
||||
|
||||
// Use reflection to set private settings
|
||||
;(cloudSettingsService as unknown as { settings: typeof mockSettings }).settings = mockSettings
|
||||
|
||||
expect(cloudSettingsService.isTaskSyncEnabled()).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when org recordTaskMessages is false", () => {
|
||||
// Set up mock settings with org recordTaskMessages = false
|
||||
const mockSettings = {
|
||||
version: 1,
|
||||
cloudSettings: {
|
||||
recordTaskMessages: false,
|
||||
},
|
||||
defaultSettings: {},
|
||||
allowList: { allowAll: true, providers: {} },
|
||||
}
|
||||
|
||||
// Mock that user has organization ID (indicating org settings should be used)
|
||||
mockAuthService.getStoredOrganizationId.mockReturnValue("org-123")
|
||||
|
||||
// Use reflection to set private settings
|
||||
;(cloudSettingsService as unknown as { settings: typeof mockSettings }).settings = mockSettings
|
||||
|
||||
expect(cloudSettingsService.isTaskSyncEnabled()).toBe(false)
|
||||
})
|
||||
|
||||
it("should fall back to user taskSyncEnabled when org recordTaskMessages is undefined", () => {
|
||||
// Set up mock settings with org recordTaskMessages undefined
|
||||
const mockSettings = {
|
||||
version: 1,
|
||||
cloudSettings: {},
|
||||
defaultSettings: {},
|
||||
allowList: { allowAll: true, providers: {} },
|
||||
}
|
||||
|
||||
const mockUserSettings = {
|
||||
version: 1,
|
||||
features: {},
|
||||
settings: {
|
||||
taskSyncEnabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Mock that user has no organization ID (indicating user settings should be used)
|
||||
mockAuthService.getStoredOrganizationId.mockReturnValue(null)
|
||||
|
||||
// Use reflection to set private settings
|
||||
;(cloudSettingsService as unknown as { settings: typeof mockSettings }).settings = mockSettings
|
||||
;(cloudSettingsService as unknown as { userSettings: typeof mockUserSettings }).userSettings =
|
||||
mockUserSettings
|
||||
|
||||
expect(cloudSettingsService.isTaskSyncEnabled()).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when user taskSyncEnabled is false", () => {
|
||||
// Set up mock settings with org recordTaskMessages undefined
|
||||
const mockSettings = {
|
||||
version: 1,
|
||||
cloudSettings: {},
|
||||
defaultSettings: {},
|
||||
allowList: { allowAll: true, providers: {} },
|
||||
}
|
||||
|
||||
const mockUserSettings = {
|
||||
version: 1,
|
||||
features: {},
|
||||
settings: {
|
||||
taskSyncEnabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Mock that user has no organization ID (indicating user settings should be used)
|
||||
mockAuthService.getStoredOrganizationId.mockReturnValue(null)
|
||||
|
||||
// Use reflection to set private settings
|
||||
;(cloudSettingsService as unknown as { settings: typeof mockSettings }).settings = mockSettings
|
||||
;(cloudSettingsService as unknown as { userSettings: typeof mockUserSettings }).userSettings =
|
||||
mockUserSettings
|
||||
|
||||
expect(cloudSettingsService.isTaskSyncEnabled()).toBe(false)
|
||||
})
|
||||
|
||||
it("should return true when user taskSyncEnabled is undefined (default)", () => {
|
||||
// Set up mock settings with org recordTaskMessages undefined
|
||||
const mockSettings = {
|
||||
version: 1,
|
||||
cloudSettings: {},
|
||||
defaultSettings: {},
|
||||
allowList: { allowAll: true, providers: {} },
|
||||
}
|
||||
|
||||
const mockUserSettings = {
|
||||
version: 1,
|
||||
features: {},
|
||||
settings: {},
|
||||
}
|
||||
|
||||
// Mock that user has no organization ID (indicating user settings should be used)
|
||||
mockAuthService.getStoredOrganizationId.mockReturnValue(null)
|
||||
|
||||
// Use reflection to set private settings
|
||||
;(cloudSettingsService as unknown as { settings: typeof mockSettings }).settings = mockSettings
|
||||
;(cloudSettingsService as unknown as { userSettings: typeof mockUserSettings }).userSettings =
|
||||
mockUserSettings
|
||||
|
||||
expect(cloudSettingsService.isTaskSyncEnabled()).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when no settings are available", () => {
|
||||
// Mock that user has no organization ID
|
||||
mockAuthService.getStoredOrganizationId.mockReturnValue(null)
|
||||
|
||||
// Clear both settings
|
||||
;(cloudSettingsService as unknown as { settings: undefined }).settings = undefined
|
||||
;(cloudSettingsService as unknown as { userSettings: undefined }).userSettings = undefined
|
||||
|
||||
expect(cloudSettingsService.isTaskSyncEnabled()).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when only org settings are available but cloudSettings is undefined", () => {
|
||||
const mockSettings = {
|
||||
version: 1,
|
||||
defaultSettings: {},
|
||||
allowList: { allowAll: true, providers: {} },
|
||||
}
|
||||
|
||||
// Mock that user has organization ID (indicating org settings should be used)
|
||||
mockAuthService.getStoredOrganizationId.mockReturnValue("org-123")
|
||||
|
||||
// Use reflection to set private settings
|
||||
;(cloudSettingsService as unknown as { settings: typeof mockSettings }).settings = mockSettings
|
||||
;(cloudSettingsService as unknown as { userSettings: undefined }).userSettings = undefined
|
||||
|
||||
expect(cloudSettingsService.isTaskSyncEnabled()).toBe(false)
|
||||
})
|
||||
|
||||
it("should prioritize org settings over user settings", () => {
|
||||
// Set up conflicting settings: org = false, user = true
|
||||
const mockSettings = {
|
||||
version: 1,
|
||||
cloudSettings: {
|
||||
recordTaskMessages: false,
|
||||
},
|
||||
defaultSettings: {},
|
||||
allowList: { allowAll: true, providers: {} },
|
||||
}
|
||||
|
||||
const mockUserSettings = {
|
||||
version: 1,
|
||||
features: {},
|
||||
settings: {
|
||||
taskSyncEnabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Mock that user has organization ID (indicating org settings should be used)
|
||||
mockAuthService.getStoredOrganizationId.mockReturnValue("org-123")
|
||||
|
||||
// Use reflection to set private settings
|
||||
;(cloudSettingsService as unknown as { settings: typeof mockSettings }).settings = mockSettings
|
||||
;(cloudSettingsService as unknown as { userSettings: typeof mockUserSettings }).userSettings =
|
||||
mockUserSettings
|
||||
|
||||
// Should return false (org setting takes precedence)
|
||||
expect(cloudSettingsService.isTaskSyncEnabled()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,38 +1,11 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import type { MockedFunction } from "vitest"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
import type { SettingsService, AuthService } from "@roo-code/types"
|
||||
|
||||
import { CloudAPI } from "../CloudAPI.js"
|
||||
import { CloudShareService } from "../CloudShareService.js"
|
||||
import { CloudAPIError, TaskNotFoundError } from "../errors.js"
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
global.fetch = mockFetch as any
|
||||
|
||||
vi.mock("vscode", () => ({
|
||||
window: {
|
||||
showInformationMessage: vi.fn(),
|
||||
showErrorMessage: vi.fn(),
|
||||
showQuickPick: vi.fn(),
|
||||
},
|
||||
env: {
|
||||
clipboard: {
|
||||
writeText: vi.fn(),
|
||||
},
|
||||
openExternal: vi.fn(),
|
||||
},
|
||||
Uri: {
|
||||
parse: vi.fn(),
|
||||
},
|
||||
extensions: {
|
||||
getExtension: vi.fn(() => ({
|
||||
packageJSON: { version: "1.0.0" },
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../Config", () => ({
|
||||
getRooCodeApiUrl: () => "https://app.roocode.com",
|
||||
|
|
@ -51,7 +24,6 @@ describe("CloudShareService", () => {
|
|||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetch.mockClear()
|
||||
|
||||
mockLog = vi.fn()
|
||||
mockAuthService = {
|
||||
|
|
@ -70,249 +42,63 @@ describe("CloudShareService", () => {
|
|||
})
|
||||
|
||||
describe("shareTask", () => {
|
||||
it("should share task with organization visibility and copy to clipboard", async () => {
|
||||
const mockResponseData = {
|
||||
success: true,
|
||||
shareUrl: "https://app.roocode.com/share/abc123",
|
||||
}
|
||||
|
||||
;(mockAuthService.getSessionToken as any).mockReturnValue("session-token")
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(mockResponseData),
|
||||
})
|
||||
|
||||
const result = await shareService.shareTask("task-123", "organization")
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.shareUrl).toBe("https://app.roocode.com/share/abc123")
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith("https://app.roocode.com/api/extension/share", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer session-token",
|
||||
"User-Agent": "Roo-Code 1.0.0",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
taskId: "task-123",
|
||||
visibility: "organization",
|
||||
}),
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
|
||||
expect(vscode.env.clipboard.writeText).toHaveBeenCalledWith("https://app.roocode.com/share/abc123")
|
||||
})
|
||||
|
||||
it("should share task with public visibility", async () => {
|
||||
const mockResponseData = {
|
||||
success: true,
|
||||
shareUrl: "https://app.roocode.com/share/abc123",
|
||||
}
|
||||
|
||||
;(mockAuthService.getSessionToken as any).mockReturnValue("session-token")
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(mockResponseData),
|
||||
})
|
||||
|
||||
const result = await shareService.shareTask("task-123", "public")
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith("https://app.roocode.com/api/extension/share", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer session-token",
|
||||
"User-Agent": "Roo-Code 1.0.0",
|
||||
},
|
||||
body: JSON.stringify({ taskId: "task-123", visibility: "public" }),
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
})
|
||||
|
||||
it("should default to organization visibility when not specified", async () => {
|
||||
const mockResponseData = {
|
||||
success: true,
|
||||
shareUrl: "https://app.roocode.com/share/abc123",
|
||||
}
|
||||
|
||||
;(mockAuthService.getSessionToken as any).mockReturnValue("session-token")
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(mockResponseData),
|
||||
})
|
||||
|
||||
const result = await shareService.shareTask("task-123")
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockFetch).toHaveBeenCalledWith("https://app.roocode.com/api/extension/share", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer session-token",
|
||||
"User-Agent": "Roo-Code 1.0.0",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
taskId: "task-123",
|
||||
visibility: "organization",
|
||||
}),
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle API error response", async () => {
|
||||
const mockResponseData = {
|
||||
success: false,
|
||||
error: "Task not found",
|
||||
}
|
||||
|
||||
;(mockAuthService.getSessionToken as any).mockReturnValue("session-token")
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(mockResponseData),
|
||||
})
|
||||
|
||||
const result = await shareService.shareTask("task-123", "organization")
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Task not found")
|
||||
})
|
||||
|
||||
it("should handle authentication errors", async () => {
|
||||
;(mockAuthService.getSessionToken as any).mockReturnValue(null)
|
||||
|
||||
await expect(shareService.shareTask("task-123", "organization")).rejects.toThrow("Authentication required")
|
||||
})
|
||||
|
||||
it("should handle unexpected errors", async () => {
|
||||
;(mockAuthService.getSessionToken as any).mockReturnValue("session-token")
|
||||
mockFetch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
await expect(shareService.shareTask("task-123", "organization")).rejects.toThrow("Network error")
|
||||
})
|
||||
|
||||
it("should throw TaskNotFoundError for 404 responses", async () => {
|
||||
;(mockAuthService.getSessionToken as any).mockReturnValue("session-token")
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
json: vi.fn().mockRejectedValue(new Error("Invalid JSON")),
|
||||
text: vi.fn().mockResolvedValue("Not Found"),
|
||||
})
|
||||
|
||||
await expect(shareService.shareTask("task-123", "organization")).rejects.toThrow(TaskNotFoundError)
|
||||
await expect(shareService.shareTask("task-123", "organization")).rejects.toThrow("Task not found")
|
||||
})
|
||||
|
||||
it("should throw generic Error for non-404 HTTP errors", async () => {
|
||||
;(mockAuthService.getSessionToken as any).mockReturnValue("session-token")
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
json: vi.fn().mockRejectedValue(new Error("Invalid JSON")),
|
||||
text: vi.fn().mockResolvedValue("Internal Server Error"),
|
||||
})
|
||||
|
||||
await expect(shareService.shareTask("task-123", "organization")).rejects.toThrow(CloudAPIError)
|
||||
it("should throw error when cloud features are disabled", async () => {
|
||||
await expect(shareService.shareTask("task-123", "organization")).rejects.toThrow(
|
||||
"HTTP 500: Internal Server Error",
|
||||
"Cloud features are disabled in this fork",
|
||||
)
|
||||
})
|
||||
|
||||
it("should create TaskNotFoundError with correct properties", async () => {
|
||||
;(mockAuthService.getSessionToken as any).mockReturnValue("session-token")
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
json: vi.fn().mockRejectedValue(new Error("Invalid JSON")),
|
||||
text: vi.fn().mockResolvedValue("Not Found"),
|
||||
})
|
||||
it("should throw error for public visibility", async () => {
|
||||
await expect(shareService.shareTask("task-123", "public")).rejects.toThrow(
|
||||
"Cloud features are disabled in this fork",
|
||||
)
|
||||
})
|
||||
|
||||
it("should throw error with default visibility", async () => {
|
||||
await expect(shareService.shareTask("task-123")).rejects.toThrow("Cloud features are disabled in this fork")
|
||||
})
|
||||
|
||||
it("should not make any fetch calls", async () => {
|
||||
const mockFetch = vi.fn()
|
||||
global.fetch = mockFetch as any
|
||||
|
||||
try {
|
||||
await shareService.shareTask("task-123", "organization")
|
||||
expect.fail("Expected TaskNotFoundError to be thrown")
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(TaskNotFoundError)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as TaskNotFoundError).message).toBe("Task not found")
|
||||
} catch {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("canShareTask", () => {
|
||||
it("should return true when authenticated and sharing is enabled", async () => {
|
||||
it("should return false when cloud features are disabled", async () => {
|
||||
const result = await shareService.canShareTask()
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false regardless of authentication state", async () => {
|
||||
;(mockAuthService.isAuthenticated as any).mockReturnValue(true)
|
||||
const result = await shareService.canShareTask()
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false regardless of settings", async () => {
|
||||
;(mockSettingsService.getSettings as any).mockReturnValue({
|
||||
cloudSettings: {
|
||||
enableTaskSharing: true,
|
||||
},
|
||||
})
|
||||
|
||||
const result = await shareService.canShareTask()
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when authenticated but sharing is disabled", async () => {
|
||||
;(mockAuthService.isAuthenticated as any).mockReturnValue(true)
|
||||
;(mockSettingsService.getSettings as any).mockReturnValue({
|
||||
cloudSettings: {
|
||||
enableTaskSharing: false,
|
||||
},
|
||||
})
|
||||
|
||||
const result = await shareService.canShareTask()
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it("should return false when authenticated and sharing setting is undefined (default)", async () => {
|
||||
;(mockAuthService.isAuthenticated as any).mockReturnValue(true)
|
||||
;(mockSettingsService.getSettings as any).mockReturnValue({
|
||||
cloudSettings: {},
|
||||
})
|
||||
|
||||
const result = await shareService.canShareTask()
|
||||
|
||||
describe("canSharePublicly", () => {
|
||||
it("should return false when cloud features are disabled", async () => {
|
||||
const result = await shareService.canSharePublicly()
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when authenticated and no settings available (default)", async () => {
|
||||
;(mockAuthService.isAuthenticated as any).mockReturnValue(true)
|
||||
;(mockSettingsService.getSettings as any).mockReturnValue(undefined)
|
||||
|
||||
const result = await shareService.canShareTask()
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when settings service returns undefined", async () => {
|
||||
;(mockSettingsService.getSettings as any).mockReturnValue(undefined)
|
||||
|
||||
const result = await shareService.canShareTask()
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle errors gracefully", async () => {
|
||||
;(mockSettingsService.getSettings as any).mockImplementation(() => {
|
||||
throw new Error("Settings error")
|
||||
})
|
||||
|
||||
const result = await shareService.canShareTask()
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
"[ShareService] Error checking if task can be shared:",
|
||||
expect.any(Error),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -84,9 +84,7 @@ describe("TelemetryClient", () => {
|
|||
expect(isEventCapturable(TelemetryEventName.TASK_CONVERSATION_MESSAGE)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return true for TASK_MESSAGE events when isTaskSyncEnabled returns true", () => {
|
||||
mockSettingsService.isTaskSyncEnabled.mockReturnValue(true)
|
||||
|
||||
it("should return true for TASK_MESSAGE events (not in exclude list)", () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>(
|
||||
|
|
@ -94,22 +92,8 @@ describe("TelemetryClient", () => {
|
|||
"isEventCapturable",
|
||||
).bind(client)
|
||||
|
||||
// TASK_MESSAGE is not in the exclude list, so base class returns true
|
||||
expect(isEventCapturable(TelemetryEventName.TASK_MESSAGE)).toBe(true)
|
||||
expect(mockSettingsService.isTaskSyncEnabled).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should return false for TASK_MESSAGE events when isTaskSyncEnabled returns false", () => {
|
||||
mockSettingsService.isTaskSyncEnabled.mockReturnValue(false)
|
||||
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>(
|
||||
client,
|
||||
"isEventCapturable",
|
||||
).bind(client)
|
||||
|
||||
expect(isEventCapturable(TelemetryEventName.TASK_MESSAGE)).toBe(false)
|
||||
expect(mockSettingsService.isTaskSyncEnabled).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -198,65 +182,7 @@ describe("TelemetryClient", () => {
|
|||
})
|
||||
|
||||
describe("capture", () => {
|
||||
it("should not capture events that are not capturable", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
await client.capture({
|
||||
event: TelemetryEventName.TASK_CONVERSATION_MESSAGE, // In exclude list.
|
||||
properties: { test: "value" },
|
||||
})
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not capture TASK_MESSAGE events when recordTaskMessages is false", async () => {
|
||||
mockSettingsService.getSettings.mockReturnValue({
|
||||
cloudSettings: {
|
||||
recordTaskMessages: false,
|
||||
},
|
||||
})
|
||||
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
await client.capture({
|
||||
event: TelemetryEventName.TASK_MESSAGE,
|
||||
properties: {
|
||||
taskId: "test-task-id",
|
||||
message: {
|
||||
ts: 1,
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: "test message",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not capture TASK_MESSAGE events when isTaskSyncEnabled returns false", async () => {
|
||||
mockSettingsService.isTaskSyncEnabled.mockReturnValue(false)
|
||||
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
await client.capture({
|
||||
event: TelemetryEventName.TASK_MESSAGE,
|
||||
properties: {
|
||||
taskId: "test-task-id",
|
||||
message: {
|
||||
ts: 1,
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: "test message",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
expect(mockSettingsService.isTaskSyncEnabled).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not send request when schema validation fails", async () => {
|
||||
it("should not send any requests (cloud features disabled)", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
await client.capture({
|
||||
|
|
@ -265,126 +191,50 @@ describe("TelemetryClient", () => {
|
|||
})
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
expect(console.error).toHaveBeenCalledWith(expect.stringContaining("Invalid telemetry event"))
|
||||
})
|
||||
|
||||
it("should send request when event is capturable and validation passes", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
const providerProperties = {
|
||||
appName: "roo-code",
|
||||
appVersion: "1.0.0",
|
||||
vscodeVersion: "1.60.0",
|
||||
platform: "darwin",
|
||||
editorName: "vscode",
|
||||
language: "en",
|
||||
mode: "code",
|
||||
}
|
||||
|
||||
const eventProperties = {
|
||||
taskId: "test-task-id",
|
||||
}
|
||||
|
||||
const mockValidatedData = {
|
||||
type: TelemetryEventName.TASK_CREATED,
|
||||
properties: {
|
||||
...providerProperties,
|
||||
taskId: "test-task-id",
|
||||
},
|
||||
}
|
||||
|
||||
const mockProvider: TelemetryPropertiesProvider = {
|
||||
getTelemetryProperties: vi.fn().mockResolvedValue(providerProperties),
|
||||
}
|
||||
|
||||
client.setProvider(mockProvider)
|
||||
|
||||
await client.capture({
|
||||
event: TelemetryEventName.TASK_CREATED,
|
||||
properties: eventProperties,
|
||||
})
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://app.roocode.com/api/events",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify(mockValidatedData),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should attempt to capture TASK_MESSAGE events when isTaskSyncEnabled returns true", async () => {
|
||||
mockSettingsService.isTaskSyncEnabled.mockReturnValue(true)
|
||||
|
||||
const eventProperties = {
|
||||
appName: "roo-code",
|
||||
appVersion: "1.0.0",
|
||||
vscodeVersion: "1.60.0",
|
||||
platform: "darwin",
|
||||
editorName: "vscode",
|
||||
language: "en",
|
||||
mode: "code",
|
||||
taskId: "test-task-id",
|
||||
message: {
|
||||
ts: 1,
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: "test message",
|
||||
},
|
||||
}
|
||||
|
||||
const mockValidatedData = {
|
||||
type: TelemetryEventName.TASK_MESSAGE,
|
||||
properties: eventProperties,
|
||||
}
|
||||
|
||||
it("should not send requests for TASK_MESSAGE events", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
await client.capture({
|
||||
event: TelemetryEventName.TASK_MESSAGE,
|
||||
properties: eventProperties,
|
||||
properties: {
|
||||
taskId: "test-task-id",
|
||||
message: { ts: 1, type: "say", say: "text", text: "test message" },
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockSettingsService.isTaskSyncEnabled).toHaveBeenCalled()
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://app.roocode.com/api/events",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify(mockValidatedData),
|
||||
}),
|
||||
)
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle fetch errors gracefully", async () => {
|
||||
it("should not send requests for TASK_CONVERSATION_MESSAGE events", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
mockFetch.mockRejectedValue(new Error("Network error"))
|
||||
await client.capture({
|
||||
event: TelemetryEventName.TASK_CONVERSATION_MESSAGE,
|
||||
properties: { test: "value" },
|
||||
})
|
||||
|
||||
await expect(
|
||||
client.capture({
|
||||
event: TelemetryEventName.TASK_CREATED,
|
||||
properties: { test: "value" },
|
||||
}),
|
||||
).resolves.not.toThrow()
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("telemetry state methods", () => {
|
||||
it("should always return true for isTelemetryEnabled", () => {
|
||||
it("should always return false for isTelemetryEnabled", () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
expect(client.isTelemetryEnabled()).toBe(true)
|
||||
expect(client.isTelemetryEnabled()).toBe(false)
|
||||
})
|
||||
|
||||
it("should have empty implementations for updateTelemetryState and shutdown", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
client.updateTelemetryState(true)
|
||||
await client.shutdown()
|
||||
// Should not throw
|
||||
})
|
||||
})
|
||||
|
||||
describe("backfillMessages", () => {
|
||||
it("should not send request when not authenticated", async () => {
|
||||
mockAuthService.isAuthenticated.mockReturnValue(false)
|
||||
it("should not send any requests (cloud features disabled)", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
const messages = [
|
||||
|
|
@ -401,8 +251,7 @@ describe("TelemetryClient", () => {
|
|||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not send request when no session token available", async () => {
|
||||
mockAuthService.getSessionToken.mockReturnValue(null)
|
||||
it("should not send request even when authenticated", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
const messages = [
|
||||
|
|
@ -417,87 +266,15 @@ describe("TelemetryClient", () => {
|
|||
await client.backfillMessages(messages, "test-task-id")
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
"[TelemetryClient#backfillMessages] Unauthorized: No session token available.",
|
||||
)
|
||||
})
|
||||
|
||||
it("should send FormData request with correct structure when authenticated", async () => {
|
||||
it("should not send request even with provider set", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
const providerProperties = {
|
||||
appName: "roo-code",
|
||||
appVersion: "1.0.0",
|
||||
vscodeVersion: "1.60.0",
|
||||
platform: "darwin",
|
||||
editorName: "vscode",
|
||||
language: "en",
|
||||
mode: "code",
|
||||
}
|
||||
|
||||
const mockProvider: TelemetryPropertiesProvider = {
|
||||
getTelemetryProperties: vi.fn().mockResolvedValue(providerProperties),
|
||||
}
|
||||
|
||||
client.setProvider(mockProvider)
|
||||
|
||||
const messages = [
|
||||
{
|
||||
ts: 1,
|
||||
type: "say" as const,
|
||||
say: "text" as const,
|
||||
text: "test message 1",
|
||||
},
|
||||
{
|
||||
ts: 2,
|
||||
type: "ask" as const,
|
||||
ask: "followup" as const,
|
||||
text: "test question",
|
||||
},
|
||||
]
|
||||
|
||||
await client.backfillMessages(messages, "test-task-id")
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://app.roocode.com/api/events/backfill",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer mock-token",
|
||||
},
|
||||
body: expect.any(FormData),
|
||||
getTelemetryProperties: vi.fn().mockResolvedValue({
|
||||
appVersion: "1.0.0",
|
||||
}),
|
||||
)
|
||||
|
||||
// Verify FormData contents
|
||||
const call = mockFetch.mock.calls[0]
|
||||
const formData = call?.[1]?.body as FormData
|
||||
|
||||
expect(formData.get("taskId")).toBe("test-task-id")
|
||||
|
||||
// Parse and compare properties as objects since JSON.stringify order can vary
|
||||
const propertiesJson = formData.get("properties") as string
|
||||
const parsedProperties = JSON.parse(propertiesJson)
|
||||
expect(parsedProperties).toEqual({
|
||||
taskId: "test-task-id",
|
||||
...providerProperties,
|
||||
})
|
||||
// The messages are stored as a File object under the "file" key
|
||||
const fileField = formData.get("file") as File
|
||||
expect(fileField).toBeInstanceOf(File)
|
||||
expect(fileField.name).toBe("task.json")
|
||||
expect(fileField.type).toBe("application/json")
|
||||
|
||||
// Read the file content to verify the messages
|
||||
const fileContent = await fileField.text()
|
||||
expect(fileContent).toBe(JSON.stringify(messages))
|
||||
})
|
||||
|
||||
it("should handle provider errors gracefully", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
const mockProvider: TelemetryPropertiesProvider = {
|
||||
getTelemetryProperties: vi.fn().mockRejectedValue(new Error("Provider error")),
|
||||
}
|
||||
|
||||
client.setProvider(mockProvider)
|
||||
|
|
@ -513,163 +290,15 @@ describe("TelemetryClient", () => {
|
|||
|
||||
await client.backfillMessages(messages, "test-task-id")
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://app.roocode.com/api/events/backfill",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer mock-token",
|
||||
},
|
||||
body: expect.any(FormData),
|
||||
}),
|
||||
)
|
||||
|
||||
// Verify FormData contents - should still work with just taskId
|
||||
const call = mockFetch.mock.calls[0]
|
||||
const formData = call?.[1]?.body as FormData
|
||||
|
||||
expect(formData.get("taskId")).toBe("test-task-id")
|
||||
expect(formData.get("properties")).toBe(
|
||||
JSON.stringify({
|
||||
taskId: "test-task-id",
|
||||
}),
|
||||
)
|
||||
|
||||
// The messages are stored as a File object under the "file" key
|
||||
const fileField = formData.get("file") as File
|
||||
expect(fileField).toBeInstanceOf(File)
|
||||
expect(fileField.name).toBe("task.json")
|
||||
expect(fileField.type).toBe("application/json")
|
||||
|
||||
// Read the file content to verify the messages
|
||||
const fileContent = await fileField.text()
|
||||
expect(fileContent).toBe(JSON.stringify(messages))
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should work without provider set", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
const messages = [
|
||||
{
|
||||
ts: 1,
|
||||
type: "say" as const,
|
||||
say: "text" as const,
|
||||
text: "test message",
|
||||
},
|
||||
]
|
||||
|
||||
await client.backfillMessages(messages, "test-task-id")
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://app.roocode.com/api/events/backfill",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer mock-token",
|
||||
},
|
||||
body: expect.any(FormData),
|
||||
}),
|
||||
)
|
||||
|
||||
// Verify FormData contents - should work with just taskId
|
||||
const call = mockFetch.mock.calls[0]
|
||||
const formData = call?.[1]?.body as FormData
|
||||
|
||||
expect(formData.get("taskId")).toBe("test-task-id")
|
||||
expect(formData.get("properties")).toBe(
|
||||
JSON.stringify({
|
||||
taskId: "test-task-id",
|
||||
}),
|
||||
)
|
||||
|
||||
// The messages are stored as a File object under the "file" key
|
||||
const fileField = formData.get("file") as File
|
||||
expect(fileField).toBeInstanceOf(File)
|
||||
expect(fileField.name).toBe("task.json")
|
||||
expect(fileField.type).toBe("application/json")
|
||||
|
||||
// Read the file content to verify the messages
|
||||
const fileContent = await fileField.text()
|
||||
expect(fileContent).toBe(JSON.stringify(messages))
|
||||
})
|
||||
|
||||
it("should handle fetch errors gracefully", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
mockFetch.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
const messages = [
|
||||
{
|
||||
ts: 1,
|
||||
type: "say" as const,
|
||||
say: "text" as const,
|
||||
text: "test message",
|
||||
},
|
||||
]
|
||||
|
||||
await expect(client.backfillMessages(messages, "test-task-id")).resolves.not.toThrow()
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"[TelemetryClient#backfillMessages] Error uploading messages: Error: Network error",
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle HTTP error responses", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
})
|
||||
|
||||
const messages = [
|
||||
{
|
||||
ts: 1,
|
||||
type: "say" as const,
|
||||
say: "text" as const,
|
||||
text: "test message",
|
||||
},
|
||||
]
|
||||
|
||||
await client.backfillMessages(messages, "test-task-id")
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
"[TelemetryClient#backfillMessages] POST events/backfill -> 404 Not Found",
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle empty messages array", async () => {
|
||||
it("should handle empty messages array without sending requests", async () => {
|
||||
const client = new TelemetryClient(mockAuthService, mockSettingsService)
|
||||
|
||||
await client.backfillMessages([], "test-task-id")
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://app.roocode.com/api/events/backfill",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer mock-token",
|
||||
},
|
||||
body: expect.any(FormData),
|
||||
}),
|
||||
)
|
||||
|
||||
// Verify FormData contents
|
||||
const call = mockFetch.mock.calls[0]
|
||||
const formData = call?.[1]?.body as FormData
|
||||
|
||||
// The messages are stored as a File object under the "file" key
|
||||
const fileField = formData.get("file") as File
|
||||
expect(fileField).toBeInstanceOf(File)
|
||||
expect(fileField.name).toBe("task.json")
|
||||
expect(fileField.type).toBe("application/json")
|
||||
|
||||
// Read the file content to verify the empty messages array
|
||||
const fileContent = await fileField.text()
|
||||
expect(fileContent).toBe("[]")
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -31,46 +31,33 @@ describe("RetryQueue", () => {
|
|||
})
|
||||
|
||||
describe("enqueue", () => {
|
||||
it("should add a request to the queue", async () => {
|
||||
it("should be a no-op when cloud features are disabled", async () => {
|
||||
const url = "https://api.example.com/test"
|
||||
const options = { method: "POST", body: JSON.stringify({ test: "data" }) }
|
||||
|
||||
await retryQueue.enqueue(url, options, "telemetry")
|
||||
|
||||
// Queue should remain empty
|
||||
const stats = retryQueue.getStats()
|
||||
expect(stats.totalQueued).toBe(1)
|
||||
expect(stats.byType["telemetry"]).toBe(1)
|
||||
expect(stats.totalQueued).toBe(0)
|
||||
})
|
||||
|
||||
it("should enforce max queue size with FIFO eviction", async () => {
|
||||
it("should not add items to queue regardless of max size", async () => {
|
||||
// Create a queue with max size of 3
|
||||
retryQueue = new RetryQueue(mockContext, { maxQueueSize: 3 })
|
||||
|
||||
// Add 4 requests
|
||||
// Try to add 4 requests
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
await retryQueue.enqueue(`https://api.example.com/test${i}`, { method: "POST" }, "telemetry")
|
||||
}
|
||||
|
||||
// All should be no-ops, queue remains empty
|
||||
const stats = retryQueue.getStats()
|
||||
expect(stats.totalQueued).toBe(3) // Should only have 3 items (oldest was evicted)
|
||||
expect(stats.totalQueued).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("persistence", () => {
|
||||
it("should persist queue to workspace state", async () => {
|
||||
await retryQueue.enqueue("https://api.example.com/test", { method: "POST" }, "telemetry")
|
||||
|
||||
expect(mockContext.workspaceState.update).toHaveBeenCalledWith(
|
||||
"roo.retryQueue",
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
url: "https://api.example.com/test",
|
||||
type: "telemetry",
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it("should load persisted queue on initialization", () => {
|
||||
const persistedRequests: QueuedRequest[] = [
|
||||
{
|
||||
|
|
@ -94,62 +81,84 @@ describe("RetryQueue", () => {
|
|||
|
||||
retryQueue = new RetryQueue(mockContext)
|
||||
|
||||
// Queue should load persisted data
|
||||
const stats = retryQueue.getStats()
|
||||
expect(stats.totalQueued).toBe(1)
|
||||
expect(mockContext.workspaceState.get).toHaveBeenCalledWith("roo.retryQueue")
|
||||
})
|
||||
|
||||
it("should persist queue to workspace state", async () => {
|
||||
// Enqueue is a no-op, but if we manually add items
|
||||
// they should still be persisted
|
||||
// Since enqueue is no-op, we can't test this directly
|
||||
// But persistence mechanism should still work
|
||||
const stats = retryQueue.getStats()
|
||||
expect(stats.totalQueued).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("clear", () => {
|
||||
it("should clear all queued requests", async () => {
|
||||
await retryQueue.enqueue("https://api.example.com/test1", { method: "POST" }, "telemetry")
|
||||
await retryQueue.enqueue("https://api.example.com/test2", { method: "POST" }, "api-call")
|
||||
|
||||
let stats = retryQueue.getStats()
|
||||
expect(stats.totalQueued).toBe(2)
|
||||
|
||||
it("should clear all queued requests", () => {
|
||||
// Since enqueue is a no-op, queue is empty
|
||||
// But clear should still work
|
||||
retryQueue.clear()
|
||||
|
||||
stats = retryQueue.getStats()
|
||||
const stats = retryQueue.getStats()
|
||||
expect(stats.totalQueued).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getStats", () => {
|
||||
it("should return correct statistics", async () => {
|
||||
const now = Date.now()
|
||||
it("should return correct statistics for empty queue", () => {
|
||||
const stats = retryQueue.getStats()
|
||||
|
||||
await retryQueue.enqueue("https://api.example.com/test1", { method: "POST" }, "telemetry")
|
||||
await retryQueue.enqueue("https://api.example.com/test2", { method: "POST" }, "api-call")
|
||||
await retryQueue.enqueue("https://api.example.com/test3", { method: "POST" }, "telemetry")
|
||||
expect(stats.totalQueued).toBe(0)
|
||||
expect(stats.byType).toEqual({})
|
||||
expect(stats.oldestRequest).toBeUndefined()
|
||||
expect(stats.newestRequest).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should return correct statistics when items are loaded from persistence", () => {
|
||||
const persistedRequests: QueuedRequest[] = [
|
||||
{
|
||||
id: "test-1",
|
||||
url: "https://api.example.com/test1",
|
||||
options: { method: "POST" },
|
||||
timestamp: Date.now(),
|
||||
retryCount: 0,
|
||||
type: "telemetry",
|
||||
},
|
||||
{
|
||||
id: "test-2",
|
||||
url: "https://api.example.com/test2",
|
||||
options: { method: "POST" },
|
||||
timestamp: Date.now() + 1000,
|
||||
retryCount: 1,
|
||||
type: "api-call",
|
||||
},
|
||||
]
|
||||
|
||||
const storage = new Map([["roo.retryQueue", persistedRequests]])
|
||||
mockContext = {
|
||||
workspaceState: {
|
||||
get: vi.fn((key: string) => storage.get(key)),
|
||||
update: vi.fn(),
|
||||
},
|
||||
} as unknown as ExtensionContext
|
||||
|
||||
retryQueue = new RetryQueue(mockContext)
|
||||
|
||||
const stats = retryQueue.getStats()
|
||||
|
||||
expect(stats.totalQueued).toBe(3)
|
||||
expect(stats.byType["telemetry"]).toBe(2)
|
||||
expect(stats.totalQueued).toBe(2)
|
||||
expect(stats.byType["telemetry"]).toBe(1)
|
||||
expect(stats.byType["api-call"]).toBe(1)
|
||||
expect(stats.oldestRequest).toBeDefined()
|
||||
expect(stats.newestRequest).toBeDefined()
|
||||
expect(stats.oldestRequest!.getTime()).toBeGreaterThanOrEqual(now)
|
||||
expect(stats.newestRequest!.getTime()).toBeGreaterThanOrEqual(now)
|
||||
})
|
||||
})
|
||||
|
||||
describe("events", () => {
|
||||
it("should emit request-queued event when enqueueing", async () => {
|
||||
const listener = vi.fn()
|
||||
retryQueue.on("request-queued", listener)
|
||||
|
||||
await retryQueue.enqueue("https://api.example.com/test", { method: "POST" }, "telemetry")
|
||||
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://api.example.com/test",
|
||||
type: "telemetry",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should emit queue-cleared event when clearing", () => {
|
||||
const listener = vi.fn()
|
||||
retryQueue.on("queue-cleared", listener)
|
||||
|
|
@ -161,7 +170,7 @@ describe("RetryQueue", () => {
|
|||
})
|
||||
|
||||
describe("auth state management", () => {
|
||||
it("should pause and resume the queue", () => {
|
||||
it("should pause and resume queue", () => {
|
||||
expect(retryQueue.isPausedState()).toBe(false)
|
||||
|
||||
retryQueue.pause()
|
||||
|
|
@ -171,29 +180,6 @@ describe("RetryQueue", () => {
|
|||
expect(retryQueue.isPausedState()).toBe(false)
|
||||
})
|
||||
|
||||
it("should not process retries when paused", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
|
||||
global.fetch = fetchMock
|
||||
|
||||
await retryQueue.enqueue("https://api.example.com/test", { method: "POST" }, "telemetry")
|
||||
|
||||
// Pause the queue
|
||||
retryQueue.pause()
|
||||
|
||||
// Try to retry all
|
||||
await retryQueue.retryAll()
|
||||
|
||||
// Fetch should not be called because queue is paused
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
|
||||
// Resume and retry
|
||||
retryQueue.resume()
|
||||
await retryQueue.retryAll()
|
||||
|
||||
// Now fetch should be called
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should track and update current user ID", () => {
|
||||
expect(retryQueue.getCurrentUserId()).toBeUndefined()
|
||||
|
||||
|
|
@ -208,491 +194,74 @@ describe("RetryQueue", () => {
|
|||
})
|
||||
|
||||
it("should clear queue when user changes", async () => {
|
||||
// Add some requests
|
||||
await retryQueue.enqueue("https://api.example.com/test1", { method: "POST" }, "telemetry")
|
||||
await retryQueue.enqueue("https://api.example.com/test2", { method: "POST" }, "telemetry")
|
||||
|
||||
// Since enqueue is a no-op, we can't add items
|
||||
// But clear mechanism should still work
|
||||
let stats = retryQueue.getStats()
|
||||
expect(stats.totalQueued).toBe(2)
|
||||
expect(stats.totalQueued).toBe(0)
|
||||
|
||||
// Set initial user
|
||||
retryQueue.setCurrentUserId("user_123")
|
||||
|
||||
// Same user login - should not clear
|
||||
let wasCleared = retryQueue.clearIfUserChanged("user_123")
|
||||
expect(wasCleared).toBe(false)
|
||||
stats = retryQueue.getStats()
|
||||
expect(stats.totalQueued).toBe(2)
|
||||
|
||||
// Different user login - should clear
|
||||
// Different user login - should clear (even though queue is already empty)
|
||||
wasCleared = retryQueue.clearIfUserChanged("user_456")
|
||||
expect(wasCleared).toBe(true)
|
||||
stats = retryQueue.getStats()
|
||||
expect(stats.totalQueued).toBe(0)
|
||||
expect(wasCleared).toBe(true) // Returns true because user ID changed
|
||||
expect(retryQueue.getCurrentUserId()).toBe("user_456")
|
||||
})
|
||||
|
||||
it("should clear queue on logout (undefined user)", async () => {
|
||||
// Set initial user
|
||||
retryQueue.setCurrentUserId("user_123")
|
||||
|
||||
// Add some requests
|
||||
await retryQueue.enqueue("https://api.example.com/test1", { method: "POST" }, "telemetry")
|
||||
await retryQueue.enqueue("https://api.example.com/test2", { method: "POST" }, "telemetry")
|
||||
|
||||
let stats = retryQueue.getStats()
|
||||
expect(stats.totalQueued).toBe(2)
|
||||
|
||||
// Logout (undefined user) - should clear
|
||||
const wasCleared = retryQueue.clearIfUserChanged(undefined)
|
||||
expect(wasCleared).toBe(true)
|
||||
stats = retryQueue.getStats()
|
||||
expect(stats.totalQueued).toBe(0)
|
||||
expect(wasCleared).toBe(true) // Returns true because user ID changed
|
||||
expect(retryQueue.getCurrentUserId()).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should not clear on first login (no previous user)", async () => {
|
||||
// Add some requests before any user is set
|
||||
await retryQueue.enqueue("https://api.example.com/test1", { method: "POST" }, "telemetry")
|
||||
await retryQueue.enqueue("https://api.example.com/test2", { method: "POST" }, "telemetry")
|
||||
|
||||
let stats = retryQueue.getStats()
|
||||
expect(stats.totalQueued).toBe(2)
|
||||
|
||||
// First login - should not clear
|
||||
const wasCleared = retryQueue.clearIfUserChanged("user_123")
|
||||
expect(wasCleared).toBe(false)
|
||||
stats = retryQueue.getStats()
|
||||
expect(stats.totalQueued).toBe(2)
|
||||
expect(retryQueue.getCurrentUserId()).toBe("user_123")
|
||||
})
|
||||
|
||||
it("should handle multiple user transitions correctly", async () => {
|
||||
const clearListener = vi.fn()
|
||||
retryQueue.on("queue-cleared", clearListener)
|
||||
|
||||
// First user logs in
|
||||
retryQueue.clearIfUserChanged("user_123")
|
||||
await retryQueue.enqueue("https://api.example.com/user1-req", { method: "POST" }, "telemetry")
|
||||
|
||||
// User logs out
|
||||
const clearedOnLogout = retryQueue.clearIfUserChanged(undefined)
|
||||
expect(clearedOnLogout).toBe(true)
|
||||
expect(clearListener).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Different user logs in
|
||||
await retryQueue.enqueue("https://api.example.com/user2-req", { method: "POST" }, "telemetry")
|
||||
const clearedOnNewUser = retryQueue.clearIfUserChanged("user_456")
|
||||
expect(clearedOnNewUser).toBe(true)
|
||||
expect(clearListener).toHaveBeenCalledTimes(2)
|
||||
|
||||
// Same user logs back in
|
||||
await retryQueue.enqueue("https://api.example.com/user2-req2", { method: "POST" }, "telemetry")
|
||||
const notCleared = retryQueue.clearIfUserChanged("user_456")
|
||||
expect(notCleared).toBe(false)
|
||||
expect(clearListener).toHaveBeenCalledTimes(2) // Still 2, not cleared
|
||||
|
||||
const stats = retryQueue.getStats()
|
||||
expect(stats.totalQueued).toBe(1) // Only the last request remains
|
||||
})
|
||||
})
|
||||
|
||||
describe("retryAll", () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock global fetch
|
||||
fetchMock = vi.fn()
|
||||
global.fetch = fetchMock
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("should process requests in FIFO order", async () => {
|
||||
const successListener = vi.fn()
|
||||
retryQueue.on("request-retry-success", successListener)
|
||||
|
||||
// Add multiple requests
|
||||
await retryQueue.enqueue("https://api.example.com/test1", { method: "POST" }, "telemetry")
|
||||
await retryQueue.enqueue("https://api.example.com/test2", { method: "POST" }, "telemetry")
|
||||
await retryQueue.enqueue("https://api.example.com/test3", { method: "POST" }, "telemetry")
|
||||
|
||||
// Mock successful responses
|
||||
fetchMock.mockResolvedValue({ ok: true })
|
||||
|
||||
await retryQueue.retryAll()
|
||||
|
||||
// Check that fetch was called in FIFO order
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3)
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe("https://api.example.com/test1")
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe("https://api.example.com/test2")
|
||||
expect(fetchMock.mock.calls[2]?.[0]).toBe("https://api.example.com/test3")
|
||||
|
||||
// Check that success events were emitted
|
||||
expect(successListener).toHaveBeenCalledTimes(3)
|
||||
|
||||
// Queue should be empty after successful retries
|
||||
const stats = retryQueue.getStats()
|
||||
expect(stats.totalQueued).toBe(0)
|
||||
})
|
||||
|
||||
it("should handle failed retries and increment retry count", async () => {
|
||||
const failListener = vi.fn()
|
||||
retryQueue.on("request-retry-failed", failListener)
|
||||
|
||||
await retryQueue.enqueue("https://api.example.com/test", { method: "POST" }, "telemetry")
|
||||
|
||||
// Mock failed response
|
||||
fetchMock.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
await retryQueue.retryAll()
|
||||
|
||||
// Check that failure event was emitted
|
||||
expect(failListener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://api.example.com/test",
|
||||
retryCount: 1,
|
||||
lastError: "Network error",
|
||||
}),
|
||||
expect.any(Error),
|
||||
)
|
||||
|
||||
// Request should still be in queue
|
||||
const stats = retryQueue.getStats()
|
||||
expect(stats.totalQueued).toBe(1)
|
||||
})
|
||||
|
||||
it("should enforce max retries limit", async () => {
|
||||
// Create queue with max retries of 2
|
||||
retryQueue = new RetryQueue(mockContext, { maxRetries: 2 })
|
||||
|
||||
const maxRetriesListener = vi.fn()
|
||||
retryQueue.on("request-max-retries-exceeded", maxRetriesListener)
|
||||
|
||||
await retryQueue.enqueue("https://api.example.com/test", { method: "POST" }, "telemetry")
|
||||
|
||||
// Mock failed responses
|
||||
fetchMock.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
// First retry
|
||||
await retryQueue.retryAll()
|
||||
let stats = retryQueue.getStats()
|
||||
expect(stats.totalQueued).toBe(1) // Still in queue
|
||||
|
||||
// Second retry - should hit max retries
|
||||
await retryQueue.retryAll()
|
||||
|
||||
// Check that max retries event was emitted
|
||||
expect(maxRetriesListener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://api.example.com/test",
|
||||
retryCount: 2,
|
||||
}),
|
||||
expect.any(Error),
|
||||
)
|
||||
|
||||
// Request should be removed from queue after exceeding max retries
|
||||
stats = retryQueue.getStats()
|
||||
expect(stats.totalQueued).toBe(0)
|
||||
})
|
||||
|
||||
it("should not process if already processing", async () => {
|
||||
// Add a request
|
||||
await retryQueue.enqueue("https://api.example.com/test", { method: "POST" }, "telemetry")
|
||||
|
||||
// Mock a slow response
|
||||
fetchMock.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve({ ok: true }), 100)))
|
||||
|
||||
// Start first retryAll (don't await)
|
||||
const firstCall = retryQueue.retryAll()
|
||||
|
||||
// Try to call retryAll again immediately
|
||||
const secondCall = retryQueue.retryAll()
|
||||
|
||||
// Both should complete without errors
|
||||
await Promise.all([firstCall, secondCall])
|
||||
|
||||
// Fetch should only be called once (from the first call)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should handle empty queue gracefully", async () => {
|
||||
// Call retryAll on empty queue
|
||||
await expect(retryQueue.retryAll()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
// No fetch calls should be made
|
||||
it("should not process when paused", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
|
||||
global.fetch = fetchMock
|
||||
|
||||
// Pause queue
|
||||
retryQueue.pause()
|
||||
|
||||
// Try to retry all
|
||||
await retryQueue.retryAll()
|
||||
|
||||
// Fetch should not be called because queue is paused
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
|
||||
// Resume and retry
|
||||
retryQueue.resume()
|
||||
await retryQueue.retryAll()
|
||||
|
||||
// Still no fetch calls because queue is empty
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should use auth header provider if available", async () => {
|
||||
const authHeaderProvider = vi.fn().mockReturnValue({
|
||||
Authorization: "Bearer fresh-token",
|
||||
})
|
||||
|
||||
retryQueue = new RetryQueue(mockContext, {}, undefined, authHeaderProvider)
|
||||
|
||||
await retryQueue.enqueue(
|
||||
"https://api.example.com/test",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
"telemetry",
|
||||
)
|
||||
|
||||
fetchMock.mockResolvedValue({ ok: true })
|
||||
|
||||
it("should not process if already processing", async () => {
|
||||
// This test is less meaningful since enqueue is a no-op
|
||||
// But mechanism should still work
|
||||
await retryQueue.retryAll()
|
||||
|
||||
// Check that fresh auth headers were used
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.example.com/test",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer fresh-token",
|
||||
"Content-Type": "application/json",
|
||||
"X-Retry-Queue": "true",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(authHeaderProvider).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should respect configurable timeout", async () => {
|
||||
// Create queue with custom timeout (short timeout for testing)
|
||||
retryQueue = new RetryQueue(mockContext, { requestTimeout: 100 })
|
||||
|
||||
await retryQueue.enqueue("https://api.example.com/test", { method: "POST" }, "telemetry")
|
||||
|
||||
// Mock fetch to reject with abort error
|
||||
const abortError = new Error("The operation was aborted")
|
||||
abortError.name = "AbortError"
|
||||
fetchMock.mockRejectedValue(abortError)
|
||||
|
||||
const failListener = vi.fn()
|
||||
retryQueue.on("request-retry-failed", failListener)
|
||||
|
||||
// Should not throw
|
||||
await retryQueue.retryAll()
|
||||
|
||||
// Check that the request failed with an abort error
|
||||
expect(failListener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://api.example.com/test",
|
||||
lastError: "The operation was aborted",
|
||||
}),
|
||||
expect.any(Error),
|
||||
)
|
||||
|
||||
// The timeout configuration is being used (verified by the constructor accepting it)
|
||||
// The actual timeout behavior is handled by the browser's AbortController
|
||||
})
|
||||
|
||||
it("should retry on 500+ status codes", async () => {
|
||||
const failListener = vi.fn()
|
||||
const successListener = vi.fn()
|
||||
retryQueue.on("request-retry-failed", failListener)
|
||||
retryQueue.on("request-retry-success", successListener)
|
||||
|
||||
await retryQueue.enqueue("https://api.example.com/test", { method: "POST" }, "telemetry")
|
||||
|
||||
// First attempt: 500 error
|
||||
fetchMock.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" })
|
||||
|
||||
await retryQueue.retryAll()
|
||||
|
||||
// Should fail and remain in queue
|
||||
expect(failListener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://api.example.com/test",
|
||||
retryCount: 1,
|
||||
lastError: "Server error: 500 Internal Server Error",
|
||||
}),
|
||||
expect.any(Error),
|
||||
)
|
||||
|
||||
let stats = retryQueue.getStats()
|
||||
expect(stats.totalQueued).toBe(1)
|
||||
|
||||
// Second attempt: success
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, status: 200 })
|
||||
|
||||
await retryQueue.retryAll()
|
||||
|
||||
// Should succeed and be removed from queue
|
||||
expect(successListener).toHaveBeenCalled()
|
||||
stats = retryQueue.getStats()
|
||||
expect(stats.totalQueued).toBe(0)
|
||||
})
|
||||
|
||||
it("should pause entire queue on 429 rate limiting with Retry-After header", async () => {
|
||||
// Add multiple requests to test queue-wide pause
|
||||
await retryQueue.enqueue("https://api.example.com/test1", { method: "POST" }, "telemetry")
|
||||
await retryQueue.enqueue("https://api.example.com/test2", { method: "POST" }, "telemetry")
|
||||
await retryQueue.enqueue("https://api.example.com/test3", { method: "POST" }, "telemetry")
|
||||
|
||||
// Mock 429 response with Retry-After header (in seconds) for the first request
|
||||
const retryAfterResponse = {
|
||||
ok: false,
|
||||
status: 429,
|
||||
headers: {
|
||||
get: vi.fn((header: string) => {
|
||||
if (header === "Retry-After") return "2" // 2 seconds
|
||||
return null
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
fetchMock.mockResolvedValueOnce(retryAfterResponse)
|
||||
|
||||
await retryQueue.retryAll()
|
||||
|
||||
// All requests should still be in queue
|
||||
const stats = retryQueue.getStats()
|
||||
expect(stats.totalQueued).toBe(3)
|
||||
|
||||
// Only the first request should have been attempted
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMock).toHaveBeenCalledWith("https://api.example.com/test1", expect.any(Object))
|
||||
|
||||
// Try to retry immediately - should be skipped due to queue-wide rate limiting
|
||||
fetchMock.mockClear()
|
||||
await retryQueue.retryAll()
|
||||
|
||||
// No fetch calls should be made because the entire queue is paused
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should process all requests after rate limit period expires", async () => {
|
||||
// Add multiple requests
|
||||
await retryQueue.enqueue("https://api.example.com/test1", { method: "POST" }, "telemetry")
|
||||
await retryQueue.enqueue("https://api.example.com/test2", { method: "POST" }, "telemetry")
|
||||
|
||||
// Mock 429 response with very short Retry-After (for testing)
|
||||
const retryAfterResponse = {
|
||||
ok: false,
|
||||
status: 429,
|
||||
headers: {
|
||||
get: vi.fn((header: string) => {
|
||||
if (header === "Retry-After") return "0" // 0 seconds (immediate)
|
||||
return null
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
fetchMock.mockResolvedValueOnce(retryAfterResponse)
|
||||
|
||||
await retryQueue.retryAll()
|
||||
|
||||
// Queue should be paused but requests still in queue
|
||||
expect(retryQueue.getStats().totalQueued).toBe(2)
|
||||
|
||||
// Wait a tiny bit for the rate limit to "expire"
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
// Mock successful responses for both requests
|
||||
fetchMock.mockResolvedValue({ ok: true })
|
||||
|
||||
// Now retry should process all requests
|
||||
await retryQueue.retryAll()
|
||||
|
||||
// All requests should be processed and removed from queue
|
||||
expect(retryQueue.getStats().totalQueued).toBe(0)
|
||||
// First request will be retried plus the second one
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3) // 1 (429) + 2 (success)
|
||||
})
|
||||
|
||||
it("should not retry on 401/403 auth errors", async () => {
|
||||
const successListener = vi.fn()
|
||||
retryQueue.on("request-retry-success", successListener)
|
||||
|
||||
await retryQueue.enqueue("https://api.example.com/test", { method: "POST" }, "telemetry")
|
||||
|
||||
// Mock 401 error
|
||||
fetchMock.mockResolvedValueOnce({ ok: false, status: 401, statusText: "Unauthorized" })
|
||||
|
||||
await retryQueue.retryAll()
|
||||
|
||||
// Should be removed from queue without retry (401 is a client error)
|
||||
expect(successListener).toHaveBeenCalled()
|
||||
const stats = retryQueue.getStats()
|
||||
expect(stats.totalQueued).toBe(0)
|
||||
|
||||
// Test 403 as well
|
||||
await retryQueue.enqueue("https://api.example.com/test2", { method: "POST" }, "telemetry")
|
||||
fetchMock.mockResolvedValueOnce({ ok: false, status: 403, statusText: "Forbidden" })
|
||||
|
||||
await retryQueue.retryAll()
|
||||
|
||||
// Should also be removed from queue without retry
|
||||
expect(successListener).toHaveBeenCalledTimes(2)
|
||||
const stats2 = retryQueue.getStats()
|
||||
expect(stats2.totalQueued).toBe(0)
|
||||
})
|
||||
|
||||
it("should not retry on 400/404/422 client errors", async () => {
|
||||
const successListener = vi.fn()
|
||||
retryQueue.on("request-retry-success", successListener)
|
||||
|
||||
// Test various 4xx errors that should not be retried
|
||||
const clientErrors = [
|
||||
{ status: 400, statusText: "Bad Request" },
|
||||
{ status: 404, statusText: "Not Found" },
|
||||
{ status: 422, statusText: "Unprocessable Entity" },
|
||||
]
|
||||
|
||||
for (const error of clientErrors) {
|
||||
await retryQueue.enqueue(
|
||||
`https://api.example.com/test-${error.status}`,
|
||||
{ method: "POST" },
|
||||
"telemetry",
|
||||
)
|
||||
fetchMock.mockResolvedValueOnce({ ok: false, ...error })
|
||||
}
|
||||
|
||||
await retryQueue.retryAll()
|
||||
|
||||
// All requests should be removed from queue without retry
|
||||
expect(successListener).toHaveBeenCalledTimes(3)
|
||||
const stats = retryQueue.getStats()
|
||||
expect(stats.totalQueued).toBe(0)
|
||||
})
|
||||
|
||||
it("should prevent concurrent processing", async () => {
|
||||
// Add a single request
|
||||
await retryQueue.enqueue("https://api.example.com/test1", { method: "POST" }, "telemetry")
|
||||
|
||||
// Mock slow response
|
||||
let resolveFirst: () => void
|
||||
const firstPromise = new Promise<{ ok: boolean }>((resolve) => {
|
||||
resolveFirst = () => resolve({ ok: true })
|
||||
})
|
||||
|
||||
fetchMock.mockReturnValueOnce(firstPromise)
|
||||
|
||||
// Start first retryAll (don't await)
|
||||
const firstCall = retryQueue.retryAll()
|
||||
|
||||
// Try to call retryAll again immediately - should return immediately without processing
|
||||
const secondCall = retryQueue.retryAll()
|
||||
|
||||
// Second call should return immediately
|
||||
await secondCall
|
||||
|
||||
// Fetch should only be called once (from first call)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Resolve the promise
|
||||
resolveFirst!()
|
||||
|
||||
// Wait for first call to complete
|
||||
await firstCall
|
||||
|
||||
// Queue should be empty
|
||||
const stats = retryQueue.getStats()
|
||||
expect(stats.totalQueued).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -18,9 +18,9 @@
|
|||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/moo-code/Moo-Code"
|
||||
"url": "https://github.com/DavinciDreams/Roo-Code"
|
||||
},
|
||||
"homepage": "https://github.com/moo-code/Moo-Code",
|
||||
"homepage": "https://github.com/DavinciDreams/Roo-Code",
|
||||
"categories": [
|
||||
"AI",
|
||||
"Chat",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue