From f43d5edd74f46f5719abeb46e9ef4779e890dbbc Mon Sep 17 00:00:00 2001 From: davincidreams Date: Thu, 23 Apr 2026 09:27:21 -0400 Subject: [PATCH] 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 --- .../__tests__/CloudAPI.creditBalance.spec.ts | 79 +- .../CloudSettingsService.parsing.test.ts | 134 +- .../__tests__/CloudSettingsService.test.ts | 711 +--------- .../src/__tests__/CloudShareService.test.ts | 282 +--- .../src/__tests__/TelemetryClient.test.ts | 423 +----- .../src/__tests__/WebAuthService.spec.ts | 1200 ++--------------- .../retry-queue/__tests__/RetryQueue.test.ts | 611 ++------- src/package.json | 4 +- 8 files changed, 348 insertions(+), 3096 deletions(-) diff --git a/packages/cloud/src/__tests__/CloudAPI.creditBalance.spec.ts b/packages/cloud/src/__tests__/CloudAPI.creditBalance.spec.ts index 67ab0cf3b9..9faeb8a41a 100644 --- a/packages/cloud/src/__tests__/CloudAPI.creditBalance.spec.ts +++ b/packages/cloud/src/__tests__/CloudAPI.creditBalance.spec.ts @@ -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 } 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) }) }) diff --git a/packages/cloud/src/__tests__/CloudSettingsService.parsing.test.ts b/packages/cloud/src/__tests__/CloudSettingsService.parsing.test.ts index b0486b971c..25f1e0cb5c 100644 --- a/packages/cloud/src/__tests__/CloudSettingsService.parsing.test.ts +++ b/packages/cloud/src/__tests__/CloudSettingsService.parsing.test.ts @@ -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() diff --git a/packages/cloud/src/__tests__/CloudSettingsService.test.ts b/packages/cloud/src/__tests__/CloudSettingsService.test.ts index 1b41a54f24..70a4604dec 100644 --- a/packages/cloud/src/__tests__/CloudSettingsService.test.ts +++ b/packages/cloud/src/__tests__/CloudSettingsService.test.ts @@ -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 - getSessionToken: ReturnType - hasActiveSession: ReturnType - on: ReturnType - getStoredOrganizationId: ReturnType - } - let mockRefreshTimer: { - start: ReturnType - stop: ReturnType - } + let mockAuthService: AuthService let cloudSettingsService: CloudSettingsService let mockLog: ReturnType - 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) }) }) }) diff --git a/packages/cloud/src/__tests__/CloudShareService.test.ts b/packages/cloud/src/__tests__/CloudShareService.test.ts index d8a3820b92..c18e4cb288 100644 --- a/packages/cloud/src/__tests__/CloudShareService.test.ts +++ b/packages/cloud/src/__tests__/CloudShareService.test.ts @@ -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), - ) - }) }) }) diff --git a/packages/cloud/src/__tests__/TelemetryClient.test.ts b/packages/cloud/src/__tests__/TelemetryClient.test.ts index 5cb952dc28..481bea345a 100644 --- a/packages/cloud/src/__tests__/TelemetryClient.test.ts +++ b/packages/cloud/src/__tests__/TelemetryClient.test.ts @@ -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() }) }) }) diff --git a/packages/cloud/src/__tests__/WebAuthService.spec.ts b/packages/cloud/src/__tests__/WebAuthService.spec.ts index aa406e400d..b0cc9bbac1 100644 --- a/packages/cloud/src/__tests__/WebAuthService.spec.ts +++ b/packages/cloud/src/__tests__/WebAuthService.spec.ts @@ -1,23 +1,8 @@ -// npx vitest run src/__tests__/auth/WebAuthService.spec.ts +// npx vitest run src/__tests__/WebAuthService.spec.ts -import crypto from "crypto" - -import type { Mock } from "vitest" import type { ExtensionContext } from "vscode" import { WebAuthService } from "../WebAuthService.js" -import { RefreshTimer } from "../RefreshTimer.js" -import { getClerkBaseUrl, getRooCodeApiUrl } from "../config.js" -import { getUserAgent } from "../utils.js" - -vi.mock("crypto") - -vi.mock("../RefreshTimer") -vi.mock("../config") -vi.mock("../utils") - -const mockFetch = vi.fn() -global.fetch = mockFetch vi.mock("vscode", () => ({ window: { @@ -35,42 +20,14 @@ vi.mock("vscode", () => ({ describe("WebAuthService", () => { let authService: WebAuthService - let mockTimer: { - start: Mock - stop: Mock - reset: Mock - } - let mockLog: Mock - let mockContext: { - subscriptions: { push: Mock } - secrets: { - get: Mock - store: Mock - delete: Mock - onDidChange: Mock - } - globalState: { - get: Mock - update: Mock - } - extension: { - packageJSON: { - version: string - publisher: string - name: string - } - } - } + let mockLog: ReturnType + let mockContext: ExtensionContext beforeEach(() => { - // Reset all mocks vi.clearAllMocks() - // Setup mock context with proper subscriptions array mockContext = { - subscriptions: { - push: vi.fn(), - }, + subscriptions: { push: vi.fn() }, secrets: { get: vi.fn().mockResolvedValue(undefined), store: vi.fn().mockResolvedValue(undefined), @@ -88,35 +45,15 @@ describe("WebAuthService", () => { name: "roo-cline", }, }, - } + } as unknown as ExtensionContext - // Setup timer mock - mockTimer = { - start: vi.fn(), - stop: vi.fn(), - reset: vi.fn(), - } - const MockedRefreshTimer = vi.mocked(RefreshTimer) - MockedRefreshTimer.mockImplementation(() => mockTimer as unknown as RefreshTimer) - - // Setup config mocks - use production URL by default to maintain existing test behavior - vi.mocked(getClerkBaseUrl).mockReturnValue("https://clerk.roocode.com") - vi.mocked(getRooCodeApiUrl).mockReturnValue("https://api.test.com") - - // Setup utils mock - vi.mocked(getUserAgent).mockReturnValue("Roo-Code 1.0.0") - - // Setup crypto mock - vi.mocked(crypto.randomBytes).mockReturnValue(Buffer.from("test-random-bytes") as never) - - // Setup log mock mockLog = vi.fn() - authService = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog) + authService = new WebAuthService(mockContext, mockLog) }) afterEach(() => { - vi.clearAllMocks() + authService.dispose() }) describe("constructor", () => { @@ -128,1140 +65,171 @@ describe("WebAuthService", () => { expect(authService.getUserInfo()).toBeNull() }) - it("should create RefreshTimer with correct configuration", () => { - expect(RefreshTimer).toHaveBeenCalledWith({ - callback: expect.any(Function), - successInterval: 50_000, - initialBackoffMs: 1_000, - maxBackoffMs: 300_000, - }) + it("should use console.log as default logger", () => { + const serviceWithoutLog = new WebAuthService(mockContext) + expect(serviceWithoutLog).toBeInstanceOf(WebAuthService) + serviceWithoutLog.dispose() }) - it("should use console.log as default logger", () => { - const serviceWithoutLog = new WebAuthService(mockContext as unknown as ExtensionContext) - // Can't directly test console.log usage, but constructor should not throw - expect(serviceWithoutLog).toBeInstanceOf(WebAuthService) + it("should log that cloud features are disabled", () => { + expect(mockLog).toHaveBeenCalledWith("[auth] Using WebAuthService (cloud features disabled)") }) }) describe("initialize", () => { - it("should handle credentials change and setup event listener", async () => { + it("should transition to logged-out state", async () => { await authService.initialize() - - expect(mockContext.subscriptions.push).toHaveBeenCalled() - expect(mockContext.secrets.onDidChange).toHaveBeenCalled() + expect(authService.getState()).toBe("logged-out") }) it("should not initialize twice", async () => { await authService.initialize() - const firstCallCount = vi.mocked(mockContext.secrets.onDidChange).mock.calls.length - await authService.initialize() - expect(mockContext.secrets.onDidChange).toHaveBeenCalledTimes(firstCallCount) expect(mockLog).toHaveBeenCalledWith("[auth] initialize() called after already initialized") }) - it("should transition to logged-out when no credentials exist", async () => { - mockContext.secrets.get.mockResolvedValue(undefined) - - const authStateChangedSpy = vi.fn() - authService.on("auth-state-changed", authStateChangedSpy) + it("should always end in logged-out state regardless of stored credentials", async () => { + // Even if credentials exist, the no-op service goes to logged-out + ;(mockContext as any).secrets.get.mockResolvedValue( + JSON.stringify({ clientToken: "test-token", sessionId: "test-session" }), + ) await authService.initialize() - expect(authService.getState()).toBe("logged-out") - expect(authStateChangedSpy).toHaveBeenCalledWith({ - state: "logged-out", - previousState: "initializing", - }) }) - it("should transition to attempting-session when valid credentials exist", async () => { - const credentials = { - clientToken: "test-token", - sessionId: "test-session", - } - mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials)) - - const authStateChangedSpy = vi.fn() - authService.on("auth-state-changed", authStateChangedSpy) - + it("should log that cloud features are disabled on initialize", async () => { await authService.initialize() - - expect(authService.getState()).toBe("attempting-session") - expect(authStateChangedSpy).toHaveBeenCalledWith({ - state: "attempting-session", - previousState: "initializing", - }) - expect(mockTimer.start).toHaveBeenCalled() - }) - - it("should handle invalid credentials gracefully", async () => { - mockContext.secrets.get.mockResolvedValue("invalid-json") - - const authStateChangedSpy = vi.fn() - authService.on("auth-state-changed", authStateChangedSpy) - - await authService.initialize() - - expect(authService.getState()).toBe("logged-out") - expect(mockLog).toHaveBeenCalledWith("[auth] Failed to parse stored credentials:", expect.any(Error)) - }) - - it("should handle credentials change events", async () => { - let onDidChangeCallback: (e: { key: string }) => void - - mockContext.secrets.onDidChange.mockImplementation((callback: (e: { key: string }) => void) => { - onDidChangeCallback = callback - return { dispose: vi.fn() } - }) - - await authService.initialize() - - // Simulate credentials change event - const newCredentials = { - clientToken: "new-token", - sessionId: "new-session", - } - mockContext.secrets.get.mockResolvedValue(JSON.stringify(newCredentials)) - - const authStateChangedSpy = vi.fn() - authService.on("auth-state-changed", authStateChangedSpy) - - onDidChangeCallback!({ key: "clerk-auth-credentials" }) - await new Promise((resolve) => setTimeout(resolve, 0)) // Wait for async handling - - expect(authStateChangedSpy).toHaveBeenCalled() + expect(mockLog).toHaveBeenCalledWith("[auth] Cloud features disabled — initialized in logged-out state") }) }) describe("login", () => { - beforeEach(async () => { + it("should be a no-op when cloud features are disabled", async () => { await authService.initialize() + await authService.login() + // Should not throw, just log + expect(mockLog).toHaveBeenCalledWith("[auth] Cloud features disabled — login is a no-op") }) - it("should generate state and open external URL", async () => { - const mockOpenExternal = vi.fn() - const vscode = await import("vscode") - vi.mocked(vscode.env.openExternal).mockImplementation(mockOpenExternal) - + it("should not open any external URLs", async () => { + await authService.initialize() await authService.login() - expect(crypto.randomBytes).toHaveBeenCalledWith(16) - - expect(mockContext.globalState.update).toHaveBeenCalledWith( - "clerk-auth-state", - "746573742d72616e646f6d2d6279746573", - ) - - expect(mockOpenExternal).toHaveBeenCalledWith( - expect.objectContaining({ - toString: expect.any(Function), - }), - ) - }) - - it("should use package.json values for redirect URI with default sign-in endpoint", async () => { - const mockOpenExternal = vi.fn() const vscode = await import("vscode") - vi.mocked(vscode.env.openExternal).mockImplementation(mockOpenExternal) - - await authService.login() - - const expectedUrl = - "https://api.test.com/extension/sign-in?state=746573742d72616e646f6d2d6279746573&auth_redirect=vscode%3A%2F%2FRooVeterinaryInc.roo-cline" - expect(mockOpenExternal).toHaveBeenCalledWith( - expect.objectContaining({ - toString: expect.any(Function), - }), - ) - - // Verify the actual URL - const calledUri = mockOpenExternal.mock.calls[0]?.[0] - expect(calledUri.toString()).toBe(expectedUrl) - }) - - it("should use provider signup URL when useProviderSignup is true", async () => { - const mockOpenExternal = vi.fn() - const vscode = await import("vscode") - vi.mocked(vscode.env.openExternal).mockImplementation(mockOpenExternal) - - await authService.login(undefined, true) - - const expectedUrl = - "https://api.test.com/extension/provider-sign-up?state=746573742d72616e646f6d2d6279746573&auth_redirect=vscode%3A%2F%2FRooVeterinaryInc.roo-cline" - expect(mockOpenExternal).toHaveBeenCalledWith( - expect.objectContaining({ - toString: expect.any(Function), - }), - ) - - // Verify the actual URL - const calledUri = mockOpenExternal.mock.calls[0]?.[0] - expect(calledUri.toString()).toBe(expectedUrl) - }) - - it("should handle errors during login", async () => { - vi.mocked(crypto.randomBytes).mockImplementation(() => { - throw new Error("Crypto error") - }) - - await expect(authService.login()).rejects.toThrow("Failed to initiate Roo Code Cloud authentication") - expect(mockLog).toHaveBeenCalledWith("[auth] Error initiating Roo Code Cloud auth: Error: Crypto error") + expect(vscode.env.openExternal).not.toHaveBeenCalled() }) }) describe("handleCallback", () => { - beforeEach(async () => { + it("should be a no-op when cloud features are disabled", async () => { await authService.initialize() + await authService.handleCallback("code", "state") + expect(mockLog).toHaveBeenCalledWith("[auth] Cloud features disabled — handleCallback is a no-op") }) - it("should handle invalid parameters", async () => { - const vscode = await import("vscode") - const mockShowInfo = vi.fn() - vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo) + it("should not make any fetch calls", async () => { + const mockFetch = vi.fn() + global.fetch = mockFetch - await authService.handleCallback(null, "state") - expect(mockShowInfo).toHaveBeenCalledWith("Invalid Roo Code Cloud sign in url") + await authService.initialize() + await authService.handleCallback("auth-code", "valid-state") - await authService.handleCallback("code", null) - expect(mockShowInfo).toHaveBeenCalledWith("Invalid Roo Code Cloud sign in url") - }) - - it("should validate state parameter", async () => { - mockContext.globalState.get.mockReturnValue("stored-state") - - await expect(authService.handleCallback("code", "different-state")).rejects.toThrow( - "Failed to handle Roo Code Cloud callback", - ) - expect(mockLog).toHaveBeenCalledWith("[auth] State mismatch in callback") - }) - - it("should successfully handle valid callback", async () => { - const storedState = "valid-state" - mockContext.globalState.get.mockReturnValue(storedState) - - // Mock successful Clerk sign-in response - const mockResponse = { - ok: true, - json: () => - Promise.resolve({ - response: { created_session_id: "session-123" }, - }), - headers: { - get: (header: string) => (header === "authorization" ? "Bearer token-123" : null), - }, - } - mockFetch.mockResolvedValue(mockResponse) - - const vscode = await import("vscode") - const mockShowInfo = vi.fn() - vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo) - - await authService.handleCallback("auth-code", storedState) - - expect(mockContext.secrets.store).toHaveBeenCalledWith( - "clerk-auth-credentials", - JSON.stringify({ - clientToken: "Bearer token-123", - sessionId: "session-123", - organizationId: null, - }), - ) - expect(mockShowInfo).toHaveBeenCalledWith("Successfully authenticated with Roo Code Cloud") - }) - - it("should store provider model when provided in callback", async () => { - const storedState = "valid-state" - mockContext.globalState.get.mockReturnValue(storedState) - - // Mock successful Clerk sign-in response - const mockResponse = { - ok: true, - json: () => - Promise.resolve({ - response: { created_session_id: "session-123" }, - }), - headers: { - get: (header: string) => (header === "authorization" ? "Bearer token-123" : null), - }, - } - mockFetch.mockResolvedValue(mockResponse) - - const vscode = await import("vscode") - const mockShowInfo = vi.fn() - vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo) - - await authService.handleCallback("auth-code", storedState, null, "xai/grok-code-fast-1") - - expect(mockContext.globalState.update).toHaveBeenCalledWith("roo-provider-model", "xai/grok-code-fast-1") - expect(mockContext.globalState.update).toHaveBeenCalledWith("roo-auth-skip-model", undefined) - expect(mockLog).toHaveBeenCalledWith("[auth] Stored provider model: xai/grok-code-fast-1") - }) - - it("should set skip model flag when provider model is NOT provided in callback", async () => { - const storedState = "valid-state" - mockContext.globalState.get.mockReturnValue(storedState) - - // Mock successful Clerk sign-in response - const mockResponse = { - ok: true, - json: () => - Promise.resolve({ - response: { created_session_id: "session-123" }, - }), - headers: { - get: (header: string) => (header === "authorization" ? "Bearer token-123" : null), - }, - } - mockFetch.mockResolvedValue(mockResponse) - - const vscode = await import("vscode") - const mockShowInfo = vi.fn() - vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo) - - // Call without provider model - await authService.handleCallback("auth-code", storedState, null) - - expect(mockContext.globalState.update).toHaveBeenCalledWith("roo-auth-skip-model", true) - expect(mockLog).toHaveBeenCalledWith("[auth] No provider model selected during signup") - }) - - it("should handle Clerk API errors", async () => { - const storedState = "valid-state" - mockContext.globalState.get.mockReturnValue(storedState) - - mockFetch.mockResolvedValue({ - ok: false, - status: 400, - statusText: "Bad Request", - }) - - const authStateChangedSpy = vi.fn() - authService.on("auth-state-changed", authStateChangedSpy) - - await expect(authService.handleCallback("auth-code", storedState)).rejects.toThrow( - "Failed to handle Roo Code Cloud callback", - ) - expect(authStateChangedSpy).toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() }) }) describe("logout", () => { - beforeEach(async () => { + it("should be a no-op when cloud features are disabled", async () => { await authService.initialize() + await authService.logout() + expect(mockLog).toHaveBeenCalledWith("[auth] Cloud features disabled — logout is a no-op") }) - it("should clear credentials and call Clerk logout", async () => { - // Set up credentials first by simulating a login state - const credentials = { - clientToken: "test-token", - sessionId: "test-session", - } - - // Manually set the credentials in the service - authService["credentials"] = credentials - - // Mock successful logout response - mockFetch.mockResolvedValue({ ok: true }) - - const vscode = await import("vscode") - const mockShowInfo = vi.fn() - vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo) + it("should not make any fetch calls", async () => { + const mockFetch = vi.fn() + global.fetch = mockFetch + await authService.initialize() await authService.logout() - expect(mockContext.secrets.delete).toHaveBeenCalledWith("clerk-auth-credentials") - expect(mockContext.globalState.update).toHaveBeenCalledWith("clerk-auth-state", undefined) - expect(mockFetch).toHaveBeenCalledWith( - "https://clerk.roocode.com/v1/client/sessions/test-session/remove", - expect.objectContaining({ - method: "POST", - headers: expect.objectContaining({ - Authorization: "Bearer test-token", - }), - }), - ) - expect(mockShowInfo).toHaveBeenCalledWith("Logged out from Roo Code Cloud") - }) - - it("should handle logout without credentials", async () => { - const vscode = await import("vscode") - const mockShowInfo = vi.fn() - vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo) - - await authService.logout() - - expect(mockContext.secrets.delete).toHaveBeenCalled() expect(mockFetch).not.toHaveBeenCalled() - expect(mockShowInfo).toHaveBeenCalledWith("Logged out from Roo Code Cloud") - }) - - it("should handle Clerk logout errors gracefully", async () => { - // Set up credentials first by simulating a login state - const credentials = { - clientToken: "test-token", - sessionId: "test-session", - } - - // Manually set the credentials in the service - authService["credentials"] = credentials - - // Mock failed logout response - mockFetch.mockRejectedValue(new Error("Network error")) - - const vscode = await import("vscode") - const mockShowInfo = vi.fn() - vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo) - - await authService.logout() - - expect(mockLog).toHaveBeenCalledWith("[auth] Error calling clerkLogout:", expect.any(Error)) - expect(mockShowInfo).toHaveBeenCalledWith("Logged out from Roo Code Cloud") }) }) describe("state management", () => { - it("should return correct state", () => { - expect(authService.getState()).toBe("initializing") + it("should return correct state after initialization", async () => { + await authService.initialize() + expect(authService.getState()).toBe("logged-out") }) - it("should return correct authentication status", async () => { + it("should always return false for isAuthenticated", async () => { + expect(authService.isAuthenticated()).toBe(false) await authService.initialize() expect(authService.isAuthenticated()).toBe(false) - - // Create a new service instance with credentials - const credentials = { - clientToken: "test-token", - sessionId: "test-session", - } - mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials)) - - const authenticatedService = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog) - await authenticatedService.initialize() - - expect(authenticatedService.isAuthenticated()).toBe(true) - expect(authenticatedService.hasActiveSession()).toBe(false) }) - it("should return session token only for active sessions", () => { - expect(authService.getSessionToken()).toBeUndefined() - - // Manually set state to active-session for testing - // This would normally happen through refreshSession - authService["state"] = "active-session" - authService["sessionToken"] = "test-jwt" - - expect(authService.getSessionToken()).toBe("test-jwt") + it("should always return false for hasActiveSession", async () => { + expect(authService.hasActiveSession()).toBe(false) + await authService.initialize() + expect(authService.hasActiveSession()).toBe(false) }) - it("should return correct values for new methods", async () => { + it("should always return false for hasOrIsAcquiringActiveSession", async () => { + expect(authService.hasOrIsAcquiringActiveSession()).toBe(false) await authService.initialize() expect(authService.hasOrIsAcquiringActiveSession()).toBe(false) - - // Create a new service instance with credentials (attempting-session) - const credentials = { - clientToken: "test-token", - sessionId: "test-session", - } - mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials)) - - const attemptingService = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog) - await attemptingService.initialize() - - expect(attemptingService.hasOrIsAcquiringActiveSession()).toBe(true) - expect(attemptingService.hasActiveSession()).toBe(false) - - // Manually set state to active-session for testing - attemptingService["state"] = "active-session" - expect(attemptingService.hasOrIsAcquiringActiveSession()).toBe(true) - expect(attemptingService.hasActiveSession()).toBe(true) }) - }) - describe("session refresh", () => { - beforeEach(async () => { - // Set up with credentials - const credentials = { - clientToken: "test-token", - sessionId: "test-session", - } - mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials)) + it("should always return undefined for getSessionToken", async () => { + expect(authService.getSessionToken()).toBeUndefined() await authService.initialize() + expect(authService.getSessionToken()).toBeUndefined() }) - it("should refresh session successfully", async () => { - // Mock successful token creation and user info fetch - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ jwt: "new-jwt-token" }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - response: { - first_name: "John", - last_name: "Doe", - image_url: "https://example.com/avatar.jpg", - primary_email_address_id: "email-1", - email_addresses: [{ id: "email-1", email_address: "john@example.com" }], - }, - }), - }) - - const authStateChangedSpy = vi.fn() - const userInfoSpy = vi.fn() - authService.on("auth-state-changed", authStateChangedSpy) - authService.on("user-info", userInfoSpy) - - // Trigger refresh by calling the timer callback - const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback - await timerCallback?.() - - // Wait for async operations to complete - await new Promise((resolve) => setTimeout(resolve, 0)) - - expect(authService.getState()).toBe("active-session") - expect(authService.hasActiveSession()).toBe(true) - expect(authService.getSessionToken()).toBe("new-jwt-token") - expect(authStateChangedSpy).toHaveBeenCalledWith({ - state: "active-session", - previousState: "attempting-session", - }) - expect(userInfoSpy).toHaveBeenCalledWith({ - userInfo: { - id: undefined, - name: "John Doe", - email: "john@example.com", - picture: "https://example.com/avatar.jpg", - }, - }) - }) - - it("should handle invalid client token error", async () => { - // Mock 401 response (invalid token) - mockFetch.mockResolvedValue({ - ok: false, - status: 401, - statusText: "Unauthorized", - }) - - const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback - - await expect(timerCallback?.()).rejects.toThrow() - expect(mockContext.secrets.delete).toHaveBeenCalledWith("clerk-auth-credentials") - expect(mockLog).toHaveBeenCalledWith("[auth] Invalid/Expired client token: clearing credentials") - }) - - it("should handle network errors during refresh", async () => { - mockFetch.mockRejectedValue(new Error("Network error")) - - const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback - - await expect(timerCallback?.()).rejects.toThrow("Network error") - expect(mockLog).toHaveBeenCalledWith("[auth] Failed to refresh session", expect.any(Error)) - }) - - it("should transition to inactive-session on first attempt failure", async () => { - // Mock failed token creation response - mockFetch.mockResolvedValue({ - ok: false, - status: 500, - statusText: "Internal Server Error", - }) - - const authStateChangedSpy = vi.fn() - authService.on("auth-state-changed", authStateChangedSpy) - - // Verify we start in attempting-session state - expect(authService.getState()).toBe("attempting-session") - expect(authService["isFirstRefreshAttempt"]).toBe(true) - - const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback - - await expect(timerCallback?.()).rejects.toThrow() - - // Should transition to inactive-session after first failure - expect(authService.getState()).toBe("inactive-session") - expect(authService["isFirstRefreshAttempt"]).toBe(false) - expect(authStateChangedSpy).toHaveBeenCalledWith({ - state: "inactive-session", - previousState: "attempting-session", - }) - }) - - it("should not transition to inactive-session on subsequent failures", async () => { - // First, transition to inactive-session by failing the first attempt - mockFetch.mockResolvedValue({ - ok: false, - status: 500, - statusText: "Internal Server Error", - }) - - const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback - await expect(timerCallback?.()).rejects.toThrow() - - // Verify we're now in inactive-session - expect(authService.getState()).toBe("inactive-session") - expect(authService["isFirstRefreshAttempt"]).toBe(false) - - const authStateChangedSpy = vi.fn() - authService.on("auth-state-changed", authStateChangedSpy) - - // Subsequent failure should not trigger another transition - await expect(timerCallback?.()).rejects.toThrow() - - expect(authService.getState()).toBe("inactive-session") - expect(authStateChangedSpy).not.toHaveBeenCalled() - }) - - it("should clear credentials on 401 during first refresh attempt (bug fix)", async () => { - // Mock 401 response during first refresh attempt - mockFetch.mockResolvedValue({ - ok: false, - status: 401, - statusText: "Unauthorized", - }) - - const authStateChangedSpy = vi.fn() - authService.on("auth-state-changed", authStateChangedSpy) - - const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback - await expect(timerCallback?.()).rejects.toThrow() - - // Should clear credentials (not just transition to inactive-session) - expect(mockContext.secrets.delete).toHaveBeenCalledWith("clerk-auth-credentials") - expect(mockLog).toHaveBeenCalledWith("[auth] Invalid/Expired client token: clearing credentials") - - // Simulate credentials cleared event - mockContext.secrets.get.mockResolvedValue(undefined) - await authService["handleCredentialsChange"]() - - expect(authService.getState()).toBe("logged-out") - expect(authStateChangedSpy).toHaveBeenCalledWith({ - state: "logged-out", - previousState: "attempting-session", - }) - }) - }) - - describe("user info", () => { - it("should return null initially", () => { + it("should always return null for getUserInfo", async () => { + expect(authService.getUserInfo()).toBeNull() + await authService.initialize() expect(authService.getUserInfo()).toBeNull() }) - it("should parse user info correctly for personal accounts", async () => { - // Set up with credentials for personal account (no organizationId) - const credentials = { - clientToken: "test-token", - sessionId: "test-session", - organizationId: null, - } - mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials)) + it("should always return null for getStoredOrganizationId", async () => { + expect(authService.getStoredOrganizationId()).toBeNull() await authService.initialize() - - // Clear previous mock calls - mockFetch.mockClear() - - // Mock successful responses - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ jwt: "jwt-token" }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - response: { - first_name: "Jane", - last_name: "Smith", - image_url: "https://example.com/jane.jpg", - primary_email_address_id: "email-2", - email_addresses: [ - { id: "email-1", email_address: "jane.old@example.com" }, - { id: "email-2", email_address: "jane@example.com" }, - ], - }, - }), - }) - - const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback - await timerCallback?.() - - // Wait for async operations to complete - await new Promise((resolve) => setTimeout(resolve, 0)) - - const userInfo = authService.getUserInfo() - expect(userInfo).toEqual({ - id: undefined, - name: "Jane Smith", - email: "jane@example.com", - picture: "https://example.com/jane.jpg", - }) - }) - - it("should parse user info correctly for organization accounts", async () => { - // Set up with credentials for organization account - const credentials = { - clientToken: "test-token", - sessionId: "test-session", - organizationId: "org_1", - } - mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials)) - await authService.initialize() - - // Clear previous mock calls - mockFetch.mockClear() - - // Mock successful responses - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ jwt: "jwt-token" }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - response: { - first_name: "Jane", - last_name: "Smith", - image_url: "https://example.com/jane.jpg", - primary_email_address_id: "email-2", - email_addresses: [ - { id: "email-1", email_address: "jane.old@example.com" }, - { id: "email-2", email_address: "jane@example.com" }, - ], - }, - }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - response: [ - { - id: "org_member_id_1", - role: "member", - organization: { - id: "org_1", - name: "Org 1", - }, - }, - ], - }), - }) - - const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback - await timerCallback?.() - - // Wait for async operations to complete - await new Promise((resolve) => setTimeout(resolve, 0)) - - const userInfo = authService.getUserInfo() - expect(userInfo).toEqual({ - id: undefined, - name: "Jane Smith", - email: "jane@example.com", - picture: "https://example.com/jane.jpg", - organizationId: "org_1", - organizationName: "Org 1", - organizationRole: "member", - organizationImageUrl: undefined, - }) - }) - - it("should handle missing user info fields", async () => { - // Set up with credentials for personal account (no organizationId) - const credentials = { - clientToken: "test-token", - sessionId: "test-session", - organizationId: null, - } - mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials)) - await authService.initialize() - - // Clear previous mock calls - mockFetch.mockClear() - - // Mock responses with minimal data - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ jwt: "jwt-token" }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - response: { - first_name: "John", - last_name: "Doe", - }, - }), - }) - - const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback - - await timerCallback?.() - - // Wait for async operations to complete - await new Promise((resolve) => setTimeout(resolve, 0)) - - const userInfo = authService.getUserInfo() - expect(userInfo).toEqual({ - id: undefined, - name: "John Doe", - email: undefined, - picture: undefined, - }) + expect(authService.getStoredOrganizationId()).toBeNull() }) }) - describe("event emissions", () => { - it("should emit auth-state-changed event for logged-out", async () => { - const authStateChangedSpy = vi.fn() - authService.on("auth-state-changed", authStateChangedSpy) - + describe("switchOrganization", () => { + it("should be a no-op when cloud features are disabled", async () => { await authService.initialize() - - expect(authStateChangedSpy).toHaveBeenCalledWith({ - state: "logged-out", - previousState: "initializing", - }) - }) - - it("should emit auth-state-changed event for attempting-session", async () => { - const credentials = { - clientToken: "test-token", - sessionId: "test-session", - } - mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials)) - - const authStateChangedSpy = vi.fn() - authService.on("auth-state-changed", authStateChangedSpy) - - await authService.initialize() - - expect(authStateChangedSpy).toHaveBeenCalledWith({ - state: "attempting-session", - previousState: "initializing", - }) - }) - - it("should emit auth-state-changed event for active-session", async () => { - // Set up with credentials - const credentials = { - clientToken: "test-token", - sessionId: "test-session", - } - mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials)) - await authService.initialize() - - // Clear previous mock calls - mockFetch.mockClear() - - // Mock both the token creation and user info fetch - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ jwt: "jwt-token" }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - response: { - first_name: "Test", - last_name: "User", - }, - }), - }) - - const authStateChangedSpy = vi.fn() - authService.on("auth-state-changed", authStateChangedSpy) - - const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback - - await timerCallback?.() - - // Wait for async operations to complete - await new Promise((resolve) => setTimeout(resolve, 0)) - - expect(authStateChangedSpy).toHaveBeenCalledWith({ - state: "active-session", - previousState: "attempting-session", - }) - }) - - it("should emit user-info event", async () => { - // Set up with credentials - const credentials = { - clientToken: "test-token", - sessionId: "test-session", - } - mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials)) - await authService.initialize() - - // Clear previous mock calls - mockFetch.mockClear() - - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ jwt: "jwt-token" }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - response: { - first_name: "Test", - last_name: "User", - }, - }), - }) - - const userInfoSpy = vi.fn() - authService.on("user-info", userInfoSpy) - - const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback - - await timerCallback?.() - - // Wait for async operations to complete - await new Promise((resolve) => setTimeout(resolve, 0)) - - expect(userInfoSpy).toHaveBeenCalledWith({ - userInfo: { - id: undefined, - name: "Test User", - email: undefined, - picture: undefined, - }, - }) + await authService.switchOrganization("org-123") + expect(mockLog).toHaveBeenCalledWith("[auth] Cloud features disabled — switchOrganization is a no-op") }) }) - describe("error handling", () => { - it("should handle credentials change errors", async () => { - mockContext.secrets.get.mockRejectedValue(new Error("Storage error")) - + describe("getOrganizationMemberships", () => { + it("should return empty array", async () => { await authService.initialize() - - expect(mockLog).toHaveBeenCalledWith("[auth] Error handling credentials change:", expect.any(Error)) - }) - - it("should handle malformed JSON in credentials", async () => { - mockContext.secrets.get.mockResolvedValue("invalid-json{") - - await authService.initialize() - - expect(authService.getState()).toBe("logged-out") - expect(mockLog).toHaveBeenCalledWith("[auth] Failed to parse stored credentials:", expect.any(Error)) - }) - - it("should handle invalid credentials schema", async () => { - mockContext.secrets.get.mockResolvedValue(JSON.stringify({ invalid: "data" })) - - await authService.initialize() - - expect(authService.getState()).toBe("logged-out") - expect(mockLog).toHaveBeenCalledWith("[auth] Invalid credentials format:", expect.any(Array)) - }) - - it("should handle missing authorization header in sign-in response", async () => { - const storedState = "valid-state" - mockContext.globalState.get.mockReturnValue(storedState) - - mockFetch.mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - response: { created_session_id: "session-123" }, - }), - headers: { - get: () => null, // No authorization header - }, - }) - - await expect(authService.handleCallback("auth-code", storedState)).rejects.toThrow( - "Failed to handle Roo Code Cloud callback", - ) + const memberships = await authService.getOrganizationMemberships() + expect(memberships).toEqual([]) }) }) - describe("timer integration", () => { - it("should stop timer on logged-out transition", async () => { - await authService.initialize() - - expect(mockTimer.stop).toHaveBeenCalled() - }) - - it("should start timer on attempting-session transition", async () => { - const credentials = { - clientToken: "test-token", - sessionId: "test-session", - } - mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials)) - - await authService.initialize() - - expect(mockTimer.start).toHaveBeenCalled() + describe("broadcast", () => { + it("should be a no-op", () => { + authService.broadcast() + // Should not throw }) }) - describe("auth credentials key scoping", () => { - it("should use default key when getClerkBaseUrl returns production URL", async () => { - // Mock getClerkBaseUrl to return production URL - vi.mocked(getClerkBaseUrl).mockReturnValue("https://clerk.roocode.com") - - const service = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog) - const credentials = { - clientToken: "test-token", - sessionId: "test-session", - } - - await service.initialize() - await service["storeCredentials"](credentials) - - expect(mockContext.secrets.store).toHaveBeenCalledWith( - "clerk-auth-credentials", - JSON.stringify(credentials), - ) - }) - - it("should use scoped key when getClerkBaseUrl returns custom URL", async () => { - const customUrl = "https://custom.clerk.com" - // Mock getClerkBaseUrl to return custom URL - vi.mocked(getClerkBaseUrl).mockReturnValue(customUrl) - - const service = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog) - const credentials = { - clientToken: "test-token", - sessionId: "test-session", - } - - await service.initialize() - await service["storeCredentials"](credentials) - - expect(mockContext.secrets.store).toHaveBeenCalledWith( - `clerk-auth-credentials-${customUrl}`, - JSON.stringify(credentials), - ) - }) - - it("should load credentials using scoped key", async () => { - const customUrl = "https://custom.clerk.com" - vi.mocked(getClerkBaseUrl).mockReturnValue(customUrl) - - const service = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog) - const credentials = { - clientToken: "test-token", - sessionId: "test-session", - } - mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials)) - - await service.initialize() - const loadedCredentials = await service["loadCredentials"]() - - expect(mockContext.secrets.get).toHaveBeenCalledWith(`clerk-auth-credentials-${customUrl}`) - expect(loadedCredentials).toEqual(credentials) - }) - - it("should clear credentials using scoped key", async () => { - const customUrl = "https://custom.clerk.com" - vi.mocked(getClerkBaseUrl).mockReturnValue(customUrl) - - const service = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog) - - await service.initialize() - await service["clearCredentials"]() - - expect(mockContext.secrets.delete).toHaveBeenCalledWith(`clerk-auth-credentials-${customUrl}`) - }) - - it("should listen for changes on scoped key", async () => { - const customUrl = "https://custom.clerk.com" - vi.mocked(getClerkBaseUrl).mockReturnValue(customUrl) - - let onDidChangeCallback: (e: { key: string }) => void - - mockContext.secrets.onDidChange.mockImplementation((callback: (e: { key: string }) => void) => { - onDidChangeCallback = callback - return { dispose: vi.fn() } - }) - - const service = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog) - await service.initialize() - - // Simulate credentials change event with scoped key - const newCredentials = { - clientToken: "new-token", - sessionId: "new-session", - } - mockContext.secrets.get.mockResolvedValue(JSON.stringify(newCredentials)) - - const authStateChangedSpy = vi.fn() - service.on("auth-state-changed", authStateChangedSpy) - - onDidChangeCallback!({ key: `clerk-auth-credentials-${customUrl}` }) - await new Promise((resolve) => setTimeout(resolve, 0)) // Wait for async handling - - expect(authStateChangedSpy).toHaveBeenCalled() - }) - - it("should not respond to changes on different scoped keys", async () => { - const customUrl = "https://custom.clerk.com" - vi.mocked(getClerkBaseUrl).mockReturnValue(customUrl) - - let onDidChangeCallback: (e: { key: string }) => void - - mockContext.secrets.onDidChange.mockImplementation((callback: (e: { key: string }) => void) => { - onDidChangeCallback = callback - return { dispose: vi.fn() } - }) - - const service = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog) - await service.initialize() - - const authStateChangedSpy = vi.fn() - service.on("auth-state-changed", authStateChangedSpy) - - // Simulate credentials change event with different scoped key - onDidChangeCallback!({ - key: "clerk-auth-credentials-https://other.clerk.com", - }) - await new Promise((resolve) => setTimeout(resolve, 0)) // Wait for async handling - - expect(authStateChangedSpy).not.toHaveBeenCalled() - }) - - it("should not respond to changes on default key when using scoped key", async () => { - const customUrl = "https://custom.clerk.com" - vi.mocked(getClerkBaseUrl).mockReturnValue(customUrl) - - let onDidChangeCallback: (e: { key: string }) => void - - mockContext.secrets.onDidChange.mockImplementation((callback: (e: { key: string }) => void) => { - onDidChangeCallback = callback - return { dispose: vi.fn() } - }) - - const service = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog) - await service.initialize() - - const authStateChangedSpy = vi.fn() - service.on("auth-state-changed", authStateChangedSpy) - - // Simulate credentials change event with default key - onDidChangeCallback!({ key: "clerk-auth-credentials" }) - await new Promise((resolve) => setTimeout(resolve, 0)) // Wait for async handling - - expect(authStateChangedSpy).not.toHaveBeenCalled() + describe("dispose", () => { + it("should remove all listeners", () => { + const listener = vi.fn() + authService.on("auth-state-changed" as any, listener) + authService.dispose() + // After dispose, emitting should not reach the listener + // This is hard to test directly, but dispose should not throw }) }) }) diff --git a/packages/cloud/src/retry-queue/__tests__/RetryQueue.test.ts b/packages/cloud/src/retry-queue/__tests__/RetryQueue.test.ts index becee719c1..a6a55221c0 100644 --- a/packages/cloud/src/retry-queue/__tests__/RetryQueue.test.ts +++ b/packages/cloud/src/retry-queue/__tests__/RetryQueue.test.ts @@ -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 - - 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) }) }) }) diff --git a/src/package.json b/src/package.json index 290cb07840..a2736b9c8f 100644 --- a/src/package.json +++ b/src/package.json @@ -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",