diff --git a/apps/web-roo-code/package.json b/apps/web-roo-code/package.json index fa59cacccb..6aa808c9c2 100644 --- a/apps/web-roo-code/package.json +++ b/apps/web-roo-code/package.json @@ -22,7 +22,7 @@ "embla-carousel-auto-scroll": "^8.6.0", "embla-carousel-autoplay": "^8.6.0", "embla-carousel-react": "^8.6.0", - "framer-motion": "^12.15.0", + "framer-motion": "12.15.0", "lucide-react": "^0.518.0", "next": "^15.2.5", "next-themes": "^0.4.6", diff --git a/package.json b/package.json index cdc8a1abca..f7f351d3fc 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,7 @@ "knip": "knip --include files", "update-contributors": "node scripts/update-contributors.js", "evals": "dotenvx run -f packages/evals/.env.development packages/evals/.env.local -- docker compose -f packages/evals/docker-compose.yml --profile server --profile runner up --build --scale runner=0", - "npm:publish:types": "pnpm --filter @roo-code/types npm:publish", - "link-workspace-packages": "tsx scripts/link-packages.ts", - "unlink-workspace-packages": "tsx scripts/link-packages.ts --unlink" + "npm:publish:types": "pnpm --filter @roo-code/types npm:publish" }, "devDependencies": { "@changesets/cli": "^2.27.10", @@ -47,7 +45,7 @@ "prettier": "^3.4.2", "rimraf": "^6.0.1", "tsx": "^4.19.3", - "turbo": "^2.5.3", + "turbo": "^2.5.6", "typescript": "^5.4.5" }, "lint-staged": { diff --git a/packages/cloud/eslint.config.mjs b/packages/cloud/eslint.config.mjs new file mode 100644 index 0000000000..c603a68f12 --- /dev/null +++ b/packages/cloud/eslint.config.mjs @@ -0,0 +1,20 @@ +import { config } from "@roo-code/config-eslint/base" +import globals from "globals" + +/** @type {import("eslint").Linter.Config} */ +export default [ + ...config, + { + files: ["**/*.cjs"], + languageOptions: { + globals: { + ...globals.node, + ...globals.commonjs, + }, + sourceType: "commonjs", + }, + rules: { + "@typescript-eslint/no-require-imports": "off", + }, + }, +] diff --git a/packages/cloud/package.json b/packages/cloud/package.json new file mode 100644 index 0000000000..2d4456d273 --- /dev/null +++ b/packages/cloud/package.json @@ -0,0 +1,29 @@ +{ + "name": "@roo-code/cloud", + "description": "Roo Code Cloud services.", + "version": "0.0.0", + "type": "module", + "exports": "./src/index.ts", + "scripts": { + "lint": "eslint src --ext=ts --max-warnings=0", + "check-types": "tsc --noEmit", + "test": "vitest run", + "clean": "rimraf .turbo" + }, + "dependencies": { + "@roo-code/types": "workspace:^", + "ioredis": "^5.6.1", + "jwt-decode": "^4.0.0", + "p-wait-for": "^5.0.2", + "socket.io-client": "^4.8.1", + "zod": "^3.25.76" + }, + "devDependencies": { + "@roo-code/config-eslint": "workspace:^", + "@roo-code/config-typescript": "workspace:^", + "@types/node": "^24.1.0", + "@types/vscode": "^1.102.0", + "globals": "^16.3.0", + "vitest": "^3.2.4" + } +} diff --git a/packages/cloud/src/CloudAPI.ts b/packages/cloud/src/CloudAPI.ts new file mode 100644 index 0000000000..d1c3f89c2b --- /dev/null +++ b/packages/cloud/src/CloudAPI.ts @@ -0,0 +1,137 @@ +import { z } from "zod" + +import { type AuthService, type ShareVisibility, type ShareResponse, shareResponseSchema } from "@roo-code/types" + +import { getRooCodeApiUrl } from "./config.js" +import { getUserAgent } from "./utils.js" +import { AuthenticationError, CloudAPIError, NetworkError, TaskNotFoundError } from "./errors.js" + +interface CloudAPIRequestOptions extends Omit { + timeout?: number + headers?: Record +} + +export class CloudAPI { + private authService: AuthService + private log: (...args: unknown[]) => void + private baseUrl: string + + constructor(authService: AuthService, log?: (...args: unknown[]) => void) { + this.authService = authService + this.log = log || console.log + this.baseUrl = getRooCodeApiUrl() + } + + private async request( + endpoint: string, + options: CloudAPIRequestOptions & { + parseResponse?: (data: unknown) => T + } = {}, + ): Promise { + const { timeout = 30_000, parseResponse, headers = {}, ...fetchOptions } = options + + const sessionToken = this.authService.getSessionToken() + + if (!sessionToken) { + throw new AuthenticationError() + } + + const url = `${this.baseUrl}${endpoint}` + + const requestHeaders = { + "Content-Type": "application/json", + Authorization: `Bearer ${sessionToken}`, + "User-Agent": getUserAgent(), + ...headers, + } + + try { + const response = await fetch(url, { + ...fetchOptions, + headers: requestHeaders, + signal: AbortSignal.timeout(timeout), + }) + + if (!response.ok) { + await this.handleErrorResponse(response, endpoint) + } + + const data = await response.json() + + if (parseResponse) { + return parseResponse(data) + } + + return data as T + } catch (error) { + if (error instanceof TypeError && error.message.includes("fetch")) { + throw new NetworkError(`Network error while calling ${endpoint}`) + } + + if (error instanceof CloudAPIError) { + throw error + } + + if (error instanceof Error && error.name === "AbortError") { + throw new CloudAPIError(`Request to ${endpoint} timed out`, undefined, undefined) + } + + throw new CloudAPIError( + `Unexpected error while calling ${endpoint}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + private async handleErrorResponse(response: Response, endpoint: string): Promise { + let responseBody: unknown + + try { + responseBody = await response.json() + } catch { + responseBody = await response.text() + } + + switch (response.status) { + case 401: + throw new AuthenticationError() + case 404: + if (endpoint.includes("/share")) { + throw new TaskNotFoundError() + } + throw new CloudAPIError(`Resource not found: ${endpoint}`, 404, responseBody) + default: + throw new CloudAPIError( + `HTTP ${response.status}: ${response.statusText}`, + response.status, + responseBody, + ) + } + } + + async shareTask(taskId: string, visibility: ShareVisibility = "organization"): Promise { + this.log(`[CloudAPI] Sharing task ${taskId} with visibility: ${visibility}`) + + const response = await this.request("/api/extension/share", { + method: "POST", + body: JSON.stringify({ taskId, visibility }), + parseResponse: (data) => shareResponseSchema.parse(data), + }) + + this.log("[CloudAPI] Share response:", response) + return response + } + + async bridgeConfig() { + return this.request("/api/extension/bridge/config", { + method: "GET", + parseResponse: (data) => + z + .object({ + userId: z.string(), + socketBridgeUrl: z.string(), + token: z.string(), + }) + .parse(data), + }) + } +} diff --git a/packages/cloud/src/CloudService.ts b/packages/cloud/src/CloudService.ts new file mode 100644 index 0000000000..8c8320cba7 --- /dev/null +++ b/packages/cloud/src/CloudService.ts @@ -0,0 +1,363 @@ +import type { Disposable, ExtensionContext } from "vscode" +import EventEmitter from "events" + +import type { + TelemetryEvent, + ClineMessage, + CloudServiceEvents, + AuthService, + SettingsService, + CloudUserInfo, + OrganizationAllowList, + OrganizationSettings, + ShareVisibility, + UserSettingsConfig, + UserSettingsData, + UserFeatures, +} from "@roo-code/types" + +import { TaskNotFoundError } from "./errors.js" +import { WebAuthService } from "./WebAuthService.js" +import { StaticTokenAuthService } from "./StaticTokenAuthService.js" +import { CloudSettingsService } from "./CloudSettingsService.js" +import { StaticSettingsService } from "./StaticSettingsService.js" +import { CloudTelemetryClient as TelemetryClient } from "./TelemetryClient.js" +import { CloudShareService } from "./CloudShareService.js" +import { CloudAPI } from "./CloudAPI.js" + +type AuthStateChangedPayload = CloudServiceEvents["auth-state-changed"][0] +type AuthUserInfoPayload = CloudServiceEvents["user-info"][0] +type SettingsPayload = CloudServiceEvents["settings-updated"][0] + +export class CloudService extends EventEmitter implements Disposable { + private static _instance: CloudService | null = null + + private context: ExtensionContext + + private authStateListener: (data: AuthStateChangedPayload) => void + private authUserInfoListener: (data: AuthUserInfoPayload) => void + private settingsListener: (data: SettingsPayload) => void + + private isInitialized = false + private log: (...args: unknown[]) => void + + /** + * Services + */ + + private _authService: AuthService | null = null + + public get authService() { + return this._authService + } + + private _settingsService: SettingsService | null = null + + public get settingsService() { + return this._settingsService + } + + private _telemetryClient: TelemetryClient | null = null + + public get telemetryClient() { + return this._telemetryClient + } + + private _shareService: CloudShareService | null = null + + public get shareService() { + return this._shareService + } + + private _cloudAPI: CloudAPI | null = null + + public get cloudAPI() { + return this._cloudAPI + } + + private constructor(context: ExtensionContext, log?: (...args: unknown[]) => void) { + super() + + this.context = context + this.log = log || console.log + + this.authStateListener = (data: AuthStateChangedPayload) => { + this.emit("auth-state-changed", data) + } + + this.authUserInfoListener = (data: AuthUserInfoPayload) => { + this.emit("user-info", data) + } + + this.settingsListener = (data: SettingsPayload) => { + this.emit("settings-updated", data) + } + } + + public async initialize(): Promise { + if (this.isInitialized) { + return + } + + try { + // For testing you can create a token with: + // `pnpm --filter @roo-code-cloud/roomote-cli development auth job-token --job-id 1 --user-id user_2xmBhejNeDTwanM8CgIOnMgVxzC --org-id org_2wbhchVXZMQl8OS1yt0mrDazCpW` + // The token will last for 1 hour. + const cloudToken = process.env.ROO_CODE_CLOUD_TOKEN + + if (cloudToken && cloudToken.length > 0) { + this._authService = new StaticTokenAuthService(this.context, cloudToken, this.log) + } else { + this._authService = new WebAuthService(this.context, this.log) + } + + this._authService.on("auth-state-changed", this.authStateListener) + this._authService.on("user-info", this.authUserInfoListener) + await this._authService.initialize() + + // Check for static settings environment variable. + const staticOrgSettings = process.env.ROO_CODE_CLOUD_ORG_SETTINGS + + if (staticOrgSettings && staticOrgSettings.length > 0) { + this._settingsService = new StaticSettingsService(staticOrgSettings, this.log) + } else { + const cloudSettingsService = new CloudSettingsService(this.context, this._authService, this.log) + + cloudSettingsService.on("settings-updated", this.settingsListener) + await cloudSettingsService.initialize() + + this._settingsService = cloudSettingsService + } + + this._cloudAPI = new CloudAPI(this._authService, this.log) + + this._telemetryClient = new TelemetryClient(this._authService, this._settingsService) + + this._shareService = new CloudShareService(this._cloudAPI, this._settingsService, this.log) + + this.isInitialized = true + } catch (error) { + this.log("[CloudService] Failed to initialize:", error) + throw new Error(`Failed to initialize CloudService: ${error}`) + } + } + + // AuthService + + public async login(): Promise { + this.ensureInitialized() + return this.authService!.login() + } + + public async logout(): Promise { + this.ensureInitialized() + return this.authService!.logout() + } + + public isAuthenticated(): boolean { + this.ensureInitialized() + return this.authService!.isAuthenticated() + } + + public hasActiveSession(): boolean { + this.ensureInitialized() + return this.authService!.hasActiveSession() + } + + public hasOrIsAcquiringActiveSession(): boolean { + this.ensureInitialized() + return this.authService!.hasOrIsAcquiringActiveSession() + } + + public getUserInfo(): CloudUserInfo | null { + this.ensureInitialized() + return this.authService!.getUserInfo() + } + + public getOrganizationId(): string | null { + this.ensureInitialized() + const userInfo = this.authService!.getUserInfo() + return userInfo?.organizationId || null + } + + public getOrganizationName(): string | null { + this.ensureInitialized() + const userInfo = this.authService!.getUserInfo() + return userInfo?.organizationName || null + } + + public getOrganizationRole(): string | null { + this.ensureInitialized() + const userInfo = this.authService!.getUserInfo() + return userInfo?.organizationRole || null + } + + public hasStoredOrganizationId(): boolean { + this.ensureInitialized() + return this.authService!.getStoredOrganizationId() !== null + } + + public getStoredOrganizationId(): string | null { + this.ensureInitialized() + return this.authService!.getStoredOrganizationId() + } + + public getAuthState(): string { + this.ensureInitialized() + return this.authService!.getState() + } + + public async handleAuthCallback( + code: string | null, + state: string | null, + organizationId?: string | null, + ): Promise { + this.ensureInitialized() + return this.authService!.handleCallback(code, state, organizationId) + } + + // SettingsService + + public getAllowList(): OrganizationAllowList { + this.ensureInitialized() + return this.settingsService!.getAllowList() + } + + public getOrganizationSettings(): OrganizationSettings | undefined { + this.ensureInitialized() + return this.settingsService!.getSettings() + } + + public getUserSettings(): UserSettingsData | undefined { + this.ensureInitialized() + return this.settingsService!.getUserSettings() + } + + public getUserFeatures(): UserFeatures { + this.ensureInitialized() + return this.settingsService!.getUserFeatures() + } + + public getUserSettingsConfig(): UserSettingsConfig { + this.ensureInitialized() + return this.settingsService!.getUserSettingsConfig() + } + + public async updateUserSettings(settings: Partial): Promise { + this.ensureInitialized() + return this.settingsService!.updateUserSettings(settings) + } + + // TelemetryClient + + public captureEvent(event: TelemetryEvent): void { + this.ensureInitialized() + this.telemetryClient!.capture(event) + } + + // ShareService + + public async shareTask( + taskId: string, + visibility: ShareVisibility = "organization", + clineMessages?: ClineMessage[], + ) { + this.ensureInitialized() + + try { + return await this.shareService!.shareTask(taskId, visibility) + } catch (error) { + if (error instanceof TaskNotFoundError && clineMessages) { + // Backfill messages and retry. + await this.telemetryClient!.backfillMessages(clineMessages, taskId) + return await this.shareService!.shareTask(taskId, visibility) + } + + throw error + } + } + + public async canShareTask(): Promise { + this.ensureInitialized() + return this.shareService!.canShareTask() + } + + // Lifecycle + + public dispose(): void { + if (this.authService) { + this.authService.off("auth-state-changed", this.authStateListener) + this.authService.off("user-info", this.authUserInfoListener) + } + + if (this.settingsService) { + if (this.settingsService instanceof CloudSettingsService) { + this.settingsService.off("settings-updated", this.settingsListener) + } + + this.settingsService.dispose() + } + + this.isInitialized = false + } + + private ensureInitialized(): void { + if (!this.isInitialized) { + throw new Error("CloudService not initialized.") + } + } + + static get instance(): CloudService { + if (!this._instance) { + throw new Error("CloudService not initialized") + } + + return this._instance + } + + static async createInstance( + context: ExtensionContext, + log?: (...args: unknown[]) => void, + eventHandlers?: Partial<{ + [K in keyof CloudServiceEvents]: (...args: CloudServiceEvents[K]) => void + }>, + ): Promise { + if (this._instance) { + throw new Error("CloudService instance already created") + } + + this._instance = new CloudService(context, log) + + await this._instance.initialize() + + if (eventHandlers) { + for (const [event, handler] of Object.entries(eventHandlers)) { + if (handler) { + this._instance.on( + event as keyof CloudServiceEvents, + handler as (...args: CloudServiceEvents[keyof CloudServiceEvents]) => void, + ) + } + } + } + + await this._instance.authService?.broadcast() + + return this._instance + } + + static hasInstance(): boolean { + return this._instance !== null && this._instance.isInitialized + } + + static resetInstance(): void { + if (this._instance) { + this._instance.dispose() + this._instance = null + } + } + + static isEnabled(): boolean { + return !!this._instance?.isAuthenticated() + } +} diff --git a/packages/cloud/src/CloudSettingsService.ts b/packages/cloud/src/CloudSettingsService.ts new file mode 100644 index 0000000000..4117dbb0bd --- /dev/null +++ b/packages/cloud/src/CloudSettingsService.ts @@ -0,0 +1,282 @@ +import EventEmitter from "events" + +import type { ExtensionContext } from "vscode" + +import { z } from "zod" + +import { + type SettingsService, + type SettingsServiceEvents, + type AuthService, + type AuthState, + type UserFeatures, + type UserSettingsConfig, + type UserSettingsData, + OrganizationAllowList, + OrganizationSettings, + organizationSettingsSchema, + userSettingsDataSchema, + ORGANIZATION_ALLOW_ALL, +} from "@roo-code/types" + +import { getRooCodeApiUrl } from "./config.js" +import { RefreshTimer } from "./RefreshTimer.js" + +const ORGANIZATION_SETTINGS_CACHE_KEY = "organization-settings" +const USER_SETTINGS_CACHE_KEY = "user-settings" + +const parseExtensionSettingsResponse = (data: unknown) => { + const shapeResult = z.object({ organization: z.unknown(), user: z.unknown() }).safeParse(data) + + if (!shapeResult.success) { + return { success: false, error: shapeResult.error } as const + } + + const orgResult = organizationSettingsSchema.safeParse(shapeResult.data.organization) + + if (!orgResult.success) { + return { success: false, error: orgResult.error } as const + } + + const userResult = userSettingsDataSchema.safeParse(shapeResult.data.user) + + if (!userResult.success) { + return { success: false, error: userResult.error } as const + } + + return { + success: true, + data: { organization: orgResult.data, user: userResult.data }, + } as const +} + +export class CloudSettingsService extends EventEmitter implements SettingsService { + private context: ExtensionContext + private authService: AuthService + private settings: OrganizationSettings | undefined = undefined + private userSettings: UserSettingsData | undefined = undefined + private timer: RefreshTimer + private log: (...args: unknown[]) => void + + constructor(context: ExtensionContext, authService: AuthService, log?: (...args: unknown[]) => void) { + super() + + this.context = context + this.authService = authService + this.log = log || console.log + + this.timer = new RefreshTimer({ + callback: async () => { + return await this.fetchSettings() + }, + successInterval: 30000, + initialBackoffMs: 1000, + maxBackoffMs: 30000, + }) + } + + public async initialize(): Promise { + this.loadCachedSettings() + + // Clear cached settings if we have missed a log out. + if (this.authService.getState() == "logged-out" && (this.settings || this.userSettings)) { + await this.removeSettings() + } + + this.authService.on("auth-state-changed", async (data: { state: AuthState; previousState: AuthState }) => { + try { + if (data.state === "active-session") { + this.timer.start() + } else if (data.previousState === "active-session") { + this.timer.stop() + + if (data.state === "logged-out") { + await this.removeSettings() + } + } + } catch (error) { + this.log(`[cloud-settings] error processing auth-state-changed: ${error}`, error) + } + }) + + if (this.authService.hasActiveSession()) { + this.timer.start() + } + } + + private async fetchSettings(): Promise { + const token = this.authService.getSessionToken() + + if (!token) { + return false + } + + try { + const response = await fetch(`${getRooCodeApiUrl()}/api/extension-settings`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + + if (!response.ok) { + this.log("[cloud-settings] Failed to fetch extension settings:", response.status, response.statusText) + return false + } + + const data = await response.json() + const result = parseExtensionSettingsResponse(data) + + if (!result.success) { + this.log("[cloud-settings] Invalid extension settings format:", result.error) + return false + } + + const { organization: newOrgSettings, user: newUserSettings } = result.data + + let orgChanged = false + let userChanged = false + + // Check for organization settings changes + if (!this.settings || this.settings.version !== newOrgSettings.version) { + this.settings = newOrgSettings + orgChanged = true + } + + // Check for user settings changes + if (!this.userSettings || this.userSettings.version !== newUserSettings.version) { + this.userSettings = newUserSettings + userChanged = true + } + + // Emit a single event if either settings changed + if (orgChanged || userChanged) { + this.emit("settings-updated", {} as Record) + } + + const hasChanges = orgChanged || userChanged + + if (hasChanges) { + await this.cacheSettings() + } + + return true + } catch (error) { + this.log("[cloud-settings] Error fetching extension settings:", error) + return false + } + } + + private async cacheSettings(): Promise { + // Store settings in separate globalState values + if (this.settings) { + await this.context.globalState.update(ORGANIZATION_SETTINGS_CACHE_KEY, this.settings) + } + + if (this.userSettings) { + await this.context.globalState.update(USER_SETTINGS_CACHE_KEY, this.userSettings) + } + } + + private loadCachedSettings(): void { + // Load settings from separate globalState values + this.settings = this.context.globalState.get(ORGANIZATION_SETTINGS_CACHE_KEY) + this.userSettings = this.context.globalState.get(USER_SETTINGS_CACHE_KEY) + } + + public getAllowList(): OrganizationAllowList { + return this.settings?.allowList || ORGANIZATION_ALLOW_ALL + } + + public getSettings(): OrganizationSettings | undefined { + return this.settings + } + + public getUserSettings(): UserSettingsData | undefined { + return this.userSettings + } + + public getUserFeatures(): UserFeatures { + return this.userSettings?.features || {} + } + + public getUserSettingsConfig(): UserSettingsConfig { + return this.userSettings?.settings || {} + } + + public async updateUserSettings(settings: Partial): Promise { + const token = this.authService.getSessionToken() + + if (!token) { + this.log("[cloud-settings] No session token available for updating user settings") + return false + } + + try { + const currentVersion = this.userSettings?.version + const requestBody: { + settings: Partial + version?: number + } = { + settings, + } + + // Include current version for optimistic locking if we have cached settings + if (currentVersion !== undefined) { + requestBody.version = currentVersion + } + + const response = await fetch(`${getRooCodeApiUrl()}/api/user-settings`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(requestBody), + }) + + if (!response.ok) { + if (response.status === 409) { + this.log( + "[cloud-settings] Version conflict when updating user settings - settings may have been updated elsewhere", + ) + } else { + this.log("[cloud-settings] Failed to update user settings:", response.status, response.statusText) + } + return false + } + + const updatedUserSettings = await response.json() + const result = userSettingsDataSchema.safeParse(updatedUserSettings) + + if (!result.success) { + this.log("[cloud-settings] Invalid user settings response format:", result.error) + return false + } + + if (!this.userSettings || result.data.version > this.userSettings.version) { + this.userSettings = result.data + await this.cacheSettings() + this.emit("settings-updated", {} as Record) + } + + return true + } catch (error) { + this.log("[cloud-settings] Error updating user settings:", error) + return false + } + } + + private async removeSettings(): Promise { + this.settings = undefined + this.userSettings = undefined + + // Clear both cache keys + await this.context.globalState.update(ORGANIZATION_SETTINGS_CACHE_KEY, undefined) + await this.context.globalState.update(USER_SETTINGS_CACHE_KEY, undefined) + } + + public dispose(): void { + this.removeAllListeners() + this.timer.stop() + } +} diff --git a/packages/cloud/src/CloudShareService.ts b/packages/cloud/src/CloudShareService.ts new file mode 100644 index 0000000000..dfed068f24 --- /dev/null +++ b/packages/cloud/src/CloudShareService.ts @@ -0,0 +1,50 @@ +import type { SettingsService, ShareResponse, ShareVisibility } from "@roo-code/types" + +import { importVscode } from "./importVscode.js" +import type { CloudAPI } from "./CloudAPI.js" + +export class CloudShareService { + private cloudAPI: CloudAPI + private settingsService: SettingsService + private log: (...args: unknown[]) => void + + constructor(cloudAPI: CloudAPI, settingsService: SettingsService, log?: (...args: unknown[]) => void) { + this.cloudAPI = cloudAPI + this.settingsService = settingsService + this.log = log || console.log + } + + async shareTask(taskId: string, visibility: ShareVisibility = "organization"): Promise { + try { + const response = await this.cloudAPI.shareTask(taskId, visibility) + + if (response.success && response.shareUrl) { + const vscode = await importVscode() + + if (vscode?.env?.clipboard?.writeText) { + try { + await vscode.env.clipboard.writeText(response.shareUrl) + } catch (copyErr) { + this.log("[ShareService] Clipboard write failed (non-fatal):", copyErr) + } + } else { + this.log("[ShareService] VS Code clipboard unavailable; running outside extension host.") + } + } + + return response + } catch (error) { + this.log("[ShareService] Error sharing task:", error) + throw error + } + } + + async canShareTask(): Promise { + try { + return !!this.settingsService.getSettings()?.cloudSettings?.enableTaskSharing + } catch (error) { + this.log("[ShareService] Error checking if task can be shared:", error) + return false + } + } +} diff --git a/packages/cloud/src/RefreshTimer.ts b/packages/cloud/src/RefreshTimer.ts new file mode 100644 index 0000000000..e7294222d7 --- /dev/null +++ b/packages/cloud/src/RefreshTimer.ts @@ -0,0 +1,154 @@ +/** + * RefreshTimer - A utility for executing a callback with configurable retry behavior + * + * This timer executes a callback function and schedules the next execution based on the result: + * - If the callback succeeds (returns true), it schedules the next attempt after a fixed interval + * - If the callback fails (returns false), it uses exponential backoff up to a maximum interval + */ + +/** + * Configuration options for the RefreshTimer + */ +export interface RefreshTimerOptions { + /** + * The callback function to execute + * Should return a Promise that resolves to a boolean indicating success (true) or failure (false) + */ + callback: () => Promise + + /** + * Time in milliseconds to wait before next attempt after success + * @default 50000 (50 seconds) + */ + successInterval?: number + + /** + * Initial backoff time in milliseconds for the first failure + * @default 1000 (1 second) + */ + initialBackoffMs?: number + + /** + * Maximum backoff time in milliseconds + * @default 300000 (5 minutes) + */ + maxBackoffMs?: number +} + +/** + * A timer utility that executes a callback with configurable retry behavior + */ +export class RefreshTimer { + private callback: () => Promise + private successInterval: number + private initialBackoffMs: number + private maxBackoffMs: number + private currentBackoffMs: number + private attemptCount: number + private timerId: NodeJS.Timeout | null + private isRunning: boolean + + /** + * Creates a new RefreshTimer + * + * @param options Configuration options for the timer + */ + constructor(options: RefreshTimerOptions) { + this.callback = options.callback + this.successInterval = options.successInterval ?? 50000 // 50 seconds + this.initialBackoffMs = options.initialBackoffMs ?? 1000 // 1 second + this.maxBackoffMs = options.maxBackoffMs ?? 300000 // 5 minutes + this.currentBackoffMs = this.initialBackoffMs + this.attemptCount = 0 + this.timerId = null + this.isRunning = false + } + + /** + * Starts the timer and executes the callback immediately + */ + public start(): void { + if (this.isRunning) { + return + } + + this.isRunning = true + + // Execute the callback immediately + this.executeCallback() + } + + /** + * Stops the timer and cancels any pending execution + */ + public stop(): void { + if (!this.isRunning) { + return + } + + if (this.timerId) { + clearTimeout(this.timerId) + this.timerId = null + } + + this.isRunning = false + } + + /** + * Resets the backoff state and attempt count + * Does not affect whether the timer is running + */ + public reset(): void { + this.currentBackoffMs = this.initialBackoffMs + this.attemptCount = 0 + } + + /** + * Schedules the next attempt based on the success/failure of the current attempt + * + * @param wasSuccessful Whether the current attempt was successful + */ + private scheduleNextAttempt(wasSuccessful: boolean): void { + if (!this.isRunning) { + return + } + + if (wasSuccessful) { + // Reset backoff on success + this.currentBackoffMs = this.initialBackoffMs + this.attemptCount = 0 + + this.timerId = setTimeout(() => this.executeCallback(), this.successInterval) + } else { + // Increment attempt count + this.attemptCount++ + + // Calculate backoff time with exponential increase + // Formula: initialBackoff * 2^(attemptCount - 1) + this.currentBackoffMs = Math.min( + this.initialBackoffMs * Math.pow(2, this.attemptCount - 1), + this.maxBackoffMs, + ) + + this.timerId = setTimeout(() => this.executeCallback(), this.currentBackoffMs) + } + } + + /** + * Executes the callback and handles the result + */ + private async executeCallback(): Promise { + if (!this.isRunning) { + return + } + + try { + const result = await this.callback() + + this.scheduleNextAttempt(result) + } catch (_error) { + // Treat errors as failed attempts + this.scheduleNextAttempt(false) + } + } +} diff --git a/packages/cloud/src/StaticSettingsService.ts b/packages/cloud/src/StaticSettingsService.ts new file mode 100644 index 0000000000..29d8071703 --- /dev/null +++ b/packages/cloud/src/StaticSettingsService.ts @@ -0,0 +1,78 @@ +import { + type SettingsService, + type UserFeatures, + type UserSettingsConfig, + type UserSettingsData, + OrganizationAllowList, + OrganizationSettings, + organizationSettingsSchema, + ORGANIZATION_ALLOW_ALL, +} from "@roo-code/types" + +export class StaticSettingsService implements SettingsService { + private settings: OrganizationSettings + private log: (...args: unknown[]) => void + + constructor(envValue: string, log?: (...args: unknown[]) => void) { + this.log = log || console.log + this.settings = this.parseEnvironmentSettings(envValue) + } + + private parseEnvironmentSettings(envValue: string): OrganizationSettings { + try { + const decodedValue = Buffer.from(envValue, "base64").toString("utf-8") + const parsedJson = JSON.parse(decodedValue) + return organizationSettingsSchema.parse(parsedJson) + } catch (error) { + this.log( + `[StaticSettingsService] failed to parse static settings: ${error instanceof Error ? error.message : String(error)}`, + error, + ) + + throw new Error("Failed to parse static settings", { cause: error }) + } + } + + public getAllowList(): OrganizationAllowList { + return this.settings?.allowList || ORGANIZATION_ALLOW_ALL + } + + public getSettings(): OrganizationSettings | undefined { + return this.settings + } + + /** + * Returns static user settings with roomoteControlEnabled and extensionBridgeEnabled as true + */ + public getUserSettings(): UserSettingsData | undefined { + return { + features: { + roomoteControlEnabled: true, + }, + settings: { + extensionBridgeEnabled: true, + }, + version: 1, + } + } + + public getUserFeatures(): UserFeatures { + return { + roomoteControlEnabled: true, + } + } + + public getUserSettingsConfig(): UserSettingsConfig { + return { + extensionBridgeEnabled: true, + } + } + + public async updateUserSettings(_settings: Partial): Promise { + throw new Error("User settings updates are not supported in static mode") + } + + public dispose(): void { + // No resources to clean up for static settings. + } +} diff --git a/packages/cloud/src/StaticTokenAuthService.ts b/packages/cloud/src/StaticTokenAuthService.ts new file mode 100644 index 0000000000..6630a4a2e0 --- /dev/null +++ b/packages/cloud/src/StaticTokenAuthService.ts @@ -0,0 +1,93 @@ +import EventEmitter from "events" + +import { jwtDecode } from "jwt-decode" +import type { ExtensionContext } from "vscode" + +import type { JWTPayload, CloudUserInfo, AuthService, AuthServiceEvents, AuthState } from "@roo-code/types" + +export class StaticTokenAuthService extends EventEmitter implements AuthService { + private state: AuthState = "active-session" + private token: string + private log: (...args: unknown[]) => void + private userInfo: CloudUserInfo + + constructor(context: ExtensionContext, token: string, log?: (...args: unknown[]) => void) { + super() + + this.token = token + this.log = log || console.log + + this.log("[auth] Using StaticTokenAuthService") + + let payload + + try { + payload = jwtDecode(token) + } catch (error) { + this.log("[auth] Failed to parse JWT:", error) + } + + this.userInfo = { + id: payload?.r?.u || payload?.sub || undefined, + organizationId: payload?.r?.o || undefined, + extensionBridgeEnabled: true, + } + } + + public async initialize(): Promise { + this.state = "active-session" + } + + public broadcast(): void { + this.emit("auth-state-changed", { + state: this.state, + previousState: "initializing", + }) + + this.emit("user-info", { userInfo: this.userInfo }) + } + + public async login(): Promise { + throw new Error("Authentication methods are disabled in StaticTokenAuthService") + } + + public async logout(): Promise { + throw new Error("Authentication methods are disabled in StaticTokenAuthService") + } + + public async handleCallback( + _code: string | null, + _state: string | null, + _organizationId?: string | null, + ): Promise { + throw new Error("Authentication methods are disabled in StaticTokenAuthService") + } + + public getState(): AuthState { + return this.state + } + + public getSessionToken(): string | undefined { + return this.token + } + + public isAuthenticated(): boolean { + return true + } + + public hasActiveSession(): boolean { + return true + } + + public hasOrIsAcquiringActiveSession(): boolean { + return true + } + + public getUserInfo(): CloudUserInfo | null { + return this.userInfo + } + + public getStoredOrganizationId(): string | null { + return this.userInfo?.organizationId || null + } +} diff --git a/packages/cloud/src/TelemetryClient.ts b/packages/cloud/src/TelemetryClient.ts new file mode 100644 index 0000000000..4be44720ea --- /dev/null +++ b/packages/cloud/src/TelemetryClient.ts @@ -0,0 +1,246 @@ +import { + type TelemetryClient, + type TelemetryEvent, + type ClineMessage, + type AuthService, + type SettingsService, + TelemetryEventName, + rooCodeTelemetryEventSchema, + TelemetryPropertiesProvider, + TelemetryEventSubscription, +} from "@roo-code/types" + +import { getRooCodeApiUrl } from "./config.js" + +abstract class BaseTelemetryClient implements TelemetryClient { + protected providerRef: WeakRef | null = null + protected telemetryEnabled: boolean = false + + constructor( + public readonly subscription?: TelemetryEventSubscription, + protected readonly debug = false, + ) {} + + protected isEventCapturable(eventName: TelemetryEventName): boolean { + if (!this.subscription) { + return true + } + + return this.subscription.type === "include" + ? this.subscription.events.includes(eventName) + : !this.subscription.events.includes(eventName) + } + + /** + * Determines if a specific property should be included in telemetry events + * Override in subclasses to filter specific properties + */ + protected isPropertyCapturable(_propertyName: string): boolean { + return true + } + + protected async getEventProperties(event: TelemetryEvent): Promise { + let providerProperties: TelemetryEvent["properties"] = {} + const provider = this.providerRef?.deref() + + if (provider) { + try { + // Get properties from the provider + providerProperties = await provider.getTelemetryProperties() + } catch (error) { + // Log error but continue with capturing the event. + console.error( + `Error getting telemetry properties: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + // Merge provider properties with event-specific properties. + // Event properties take precedence in case of conflicts. + const mergedProperties = { + ...providerProperties, + ...(event.properties || {}), + } + + // Filter out properties that shouldn't be captured by this client + return Object.fromEntries(Object.entries(mergedProperties).filter(([key]) => this.isPropertyCapturable(key))) + } + + public abstract capture(event: TelemetryEvent): Promise + + public setProvider(provider: TelemetryPropertiesProvider): void { + this.providerRef = new WeakRef(provider) + } + + public abstract updateTelemetryState(didUserOptIn: boolean): void + + public isTelemetryEnabled(): boolean { + return this.telemetryEnabled + } + + public abstract shutdown(): Promise +} + +export class CloudTelemetryClient extends BaseTelemetryClient { + constructor( + private authService: AuthService, + private settingsService: SettingsService, + debug = false, + ) { + super( + { + type: "exclude", + events: [TelemetryEventName.TASK_CONVERSATION_MESSAGE], + }, + debug, + ) + } + + private async fetch(path: string, options: RequestInit) { + if (!this.authService.isAuthenticated()) { + return + } + + const token = this.authService.getSessionToken() + + if (!token) { + console.error(`[TelemetryClient#fetch] Unauthorized: No session token available.`) + return + } + + const response = await fetch(`${getRooCodeApiUrl()}/api/${path}`, { + ...options, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }) + + if (!response.ok) { + console.error( + `[TelemetryClient#fetch] ${options.method} ${path} -> ${response.status} ${response.statusText}`, + ) + } + } + + public override async capture(event: TelemetryEvent) { + if (!this.isTelemetryEnabled() || !this.isEventCapturable(event.event)) { + if (this.debug) { + console.info(`[TelemetryClient#capture] Skipping event: ${event.event}`) + } + + return + } + + const payload = { + type: event.event, + properties: await this.getEventProperties(event), + } + + if (this.debug) { + console.info(`[TelemetryClient#capture] ${JSON.stringify(payload)}`) + } + + const result = rooCodeTelemetryEventSchema.safeParse(payload) + + if (!result.success) { + console.error( + `[TelemetryClient#capture] Invalid telemetry event: ${result.error.message} - ${JSON.stringify(payload)}`, + ) + + return + } + + try { + await this.fetch(`events`, { + method: "POST", + body: JSON.stringify(result.data), + }) + } catch (error) { + console.error(`[TelemetryClient#capture] Error sending telemetry event: ${error}`) + } + } + + public async backfillMessages(messages: ClineMessage[], taskId: string): Promise { + if (!this.authService.isAuthenticated()) { + if (this.debug) { + console.info(`[TelemetryClient#backfillMessages] Skipping: Not authenticated`) + } + return + } + + const token = this.authService.getSessionToken() + + if (!token) { + console.error(`[TelemetryClient#backfillMessages] Unauthorized: No session token available.`) + return + } + + try { + const mergedProperties = await this.getEventProperties({ + event: TelemetryEventName.TASK_MESSAGE, + properties: { taskId }, + }) + + const formData = new FormData() + formData.append("taskId", taskId) + formData.append("properties", JSON.stringify(mergedProperties)) + + formData.append( + "file", + new File([JSON.stringify(messages)], "task.json", { + type: "application/json", + }), + ) + + if (this.debug) { + console.info( + `[TelemetryClient#backfillMessages] Uploading ${messages.length} messages for task ${taskId}`, + ) + } + + // Custom fetch for multipart - don't set Content-Type header (let browser set it) + const response = await fetch(`${getRooCodeApiUrl()}/api/events/backfill`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + // Note: No Content-Type header - browser will set multipart/form-data with boundary + }, + body: formData, + }) + + if (!response.ok) { + console.error( + `[TelemetryClient#backfillMessages] POST events/backfill -> ${response.status} ${response.statusText}`, + ) + } else if (this.debug) { + console.info(`[TelemetryClient#backfillMessages] Successfully uploaded messages for task ${taskId}`) + } + } catch (error) { + console.error(`[TelemetryClient#backfillMessages] Error uploading messages: ${error}`) + } + } + + public override updateTelemetryState(_didUserOptIn: boolean) {} + + public override isTelemetryEnabled(): boolean { + return true + } + + protected override isEventCapturable(eventName: TelemetryEventName): boolean { + // Ensure that this event type is supported by the telemetry client + if (!super.isEventCapturable(eventName)) { + return false + } + + // Only record message telemetry if a cloud account is present and explicitly configured to record messages + if (eventName === TelemetryEventName.TASK_MESSAGE) { + return this.settingsService.getSettings()?.cloudSettings?.recordTaskMessages || false + } + + // Other telemetry types are capturable at this point + return true + } + + public override async shutdown() {} +} diff --git a/packages/cloud/src/WebAuthService.ts b/packages/cloud/src/WebAuthService.ts new file mode 100644 index 0000000000..cb0e087547 --- /dev/null +++ b/packages/cloud/src/WebAuthService.ts @@ -0,0 +1,727 @@ +import crypto from "crypto" +import EventEmitter from "events" + +import type { ExtensionContext } from "vscode" +import { z } from "zod" + +import type { + CloudUserInfo, + CloudOrganizationMembership, + AuthService, + AuthServiceEvents, + AuthState, +} from "@roo-code/types" + +import { getClerkBaseUrl, getRooCodeApiUrl, PRODUCTION_CLERK_BASE_URL } from "./config.js" +import { getUserAgent } from "./utils.js" +import { importVscode } from "./importVscode.js" +import { InvalidClientTokenError } from "./errors.js" +import { RefreshTimer } from "./RefreshTimer.js" + +const AUTH_STATE_KEY = "clerk-auth-state" + +/** + * AuthCredentials + */ + +const authCredentialsSchema = z.object({ + clientToken: z.string().min(1, "Client token cannot be empty"), + sessionId: z.string().min(1, "Session ID cannot be empty"), + organizationId: z.string().nullable().optional(), +}) + +type AuthCredentials = z.infer + +/** + * Clerk Schemas + */ + +const clerkSignInResponseSchema = z.object({ + response: z.object({ + created_session_id: z.string(), + }), +}) + +const clerkCreateSessionTokenResponseSchema = z.object({ + jwt: z.string(), +}) + +const clerkMeResponseSchema = z.object({ + response: z.object({ + id: z.string().optional(), + first_name: z.string().nullish(), + last_name: z.string().nullish(), + image_url: z.string().optional(), + primary_email_address_id: z.string().optional(), + email_addresses: z + .array( + z.object({ + id: z.string(), + email_address: z.string(), + }), + ) + .optional(), + public_metadata: z.record(z.any()).optional(), + }), +}) + +const clerkOrganizationMembershipsSchema = z.object({ + response: z.array( + z.object({ + id: z.string(), + role: z.string(), + permissions: z.array(z.string()).optional(), + created_at: z.number().optional(), + updated_at: z.number().optional(), + organization: z.object({ + id: z.string(), + name: z.string(), + slug: z.string().optional(), + image_url: z.string().optional(), + has_image: z.boolean().optional(), + created_at: z.number().optional(), + updated_at: z.number().optional(), + }), + }), + ), +}) + +export class WebAuthService extends EventEmitter implements AuthService { + private context: ExtensionContext + private timer: RefreshTimer + private state: AuthState = "initializing" + private log: (...args: unknown[]) => void + private readonly authCredentialsKey: string + + private credentials: AuthCredentials | null = null + private sessionToken: string | null = null + private userInfo: CloudUserInfo | null = null + private isFirstRefreshAttempt: boolean = false + + constructor(context: ExtensionContext, log?: (...args: unknown[]) => void) { + super() + + this.context = context + this.log = log || console.log + + this.log("[auth] Using WebAuthService") + + // Calculate auth credentials key based on Clerk base URL. + const clerkBaseUrl = getClerkBaseUrl() + + if (clerkBaseUrl !== PRODUCTION_CLERK_BASE_URL) { + this.authCredentialsKey = `clerk-auth-credentials-${clerkBaseUrl}` + } else { + this.authCredentialsKey = "clerk-auth-credentials" + } + + this.timer = new RefreshTimer({ + callback: async () => { + await this.refreshSession() + return true + }, + successInterval: 50_000, + initialBackoffMs: 1_000, + maxBackoffMs: 300_000, + }) + } + + private changeState(newState: AuthState): void { + const previousState = this.state + this.state = newState + this.emit("auth-state-changed", { state: newState, previousState }) + } + + private async handleCredentialsChange(): Promise { + try { + const credentials = await this.loadCredentials() + + if (credentials) { + if ( + this.credentials === null || + this.credentials.clientToken !== credentials.clientToken || + this.credentials.sessionId !== credentials.sessionId + ) { + this.transitionToAttemptingSession(credentials) + } + } else { + if (this.state !== "logged-out") { + this.transitionToLoggedOut() + } + } + } catch (error) { + this.log("[auth] Error handling credentials change:", error) + } + } + + private transitionToLoggedOut(): void { + this.timer.stop() + + this.credentials = null + this.sessionToken = null + this.userInfo = null + + this.changeState("logged-out") + + this.log("[auth] Transitioned to logged-out state") + } + + private transitionToAttemptingSession(credentials: AuthCredentials): void { + this.credentials = credentials + + this.sessionToken = null + this.userInfo = null + this.isFirstRefreshAttempt = true + + this.changeState("attempting-session") + + this.timer.start() + + this.log("[auth] Transitioned to attempting-session state") + } + + private transitionToInactiveSession(): void { + this.sessionToken = null + this.userInfo = null + + this.changeState("inactive-session") + + this.log("[auth] Transitioned to inactive-session state") + } + + /** + * Initialize the auth state + * + * This method loads tokens from storage and determines the current auth state. + * It also starts the refresh timer if we have an active session. + */ + public async initialize(): Promise { + if (this.state !== "initializing") { + this.log("[auth] initialize() called after already initialized") + return + } + + await this.handleCredentialsChange() + + this.context.subscriptions.push( + this.context.secrets.onDidChange((e) => { + if (e.key === this.authCredentialsKey) { + this.handleCredentialsChange() + } + }), + ) + } + + public broadcast(): void {} + + private async storeCredentials(credentials: AuthCredentials): Promise { + await this.context.secrets.store(this.authCredentialsKey, JSON.stringify(credentials)) + } + + private async loadCredentials(): Promise { + const credentialsJson = await this.context.secrets.get(this.authCredentialsKey) + if (!credentialsJson) return null + + try { + const parsedJson = JSON.parse(credentialsJson) + const credentials = authCredentialsSchema.parse(parsedJson) + + // Migration: If no organizationId but we have userInfo, add it + if (credentials.organizationId === undefined && this.userInfo?.organizationId) { + credentials.organizationId = this.userInfo.organizationId + await this.storeCredentials(credentials) + this.log("[auth] Migrated credentials with organizationId") + } + + return credentials + } catch (error) { + if (error instanceof z.ZodError) { + this.log("[auth] Invalid credentials format:", error.errors) + } else { + this.log("[auth] Failed to parse stored credentials:", error) + } + return null + } + } + + private async clearCredentials(): Promise { + await this.context.secrets.delete(this.authCredentialsKey) + } + + /** + * Start the login process + * + * This method initiates the authentication flow by generating a state parameter + * and opening the browser to the authorization URL. + */ + public async login(): Promise { + try { + const vscode = await importVscode() + + if (!vscode) { + throw new Error("VS Code API not available") + } + + // Generate a cryptographically random state parameter. + const state = crypto.randomBytes(16).toString("hex") + await this.context.globalState.update(AUTH_STATE_KEY, state) + const packageJSON = this.context.extension?.packageJSON + const publisher = packageJSON?.publisher ?? "RooVeterinaryInc" + const name = packageJSON?.name ?? "roo-cline" + const params = new URLSearchParams({ + state, + auth_redirect: `${vscode.env.uriScheme}://${publisher}.${name}`, + }) + const url = `${getRooCodeApiUrl()}/extension/sign-in?${params.toString()}` + await vscode.env.openExternal(vscode.Uri.parse(url)) + } catch (error) { + this.log(`[auth] Error initiating Roo Code Cloud auth: ${error}`) + throw new Error(`Failed to initiate Roo Code Cloud authentication: ${error}`) + } + } + + /** + * Handle the callback from Roo Code Cloud + * + * This method is called when the user is redirected back to the extension + * after authenticating with Roo Code Cloud. + * + * @param code The authorization code from the callback + * @param state The state parameter from the callback + * @param organizationId The organization ID from the callback (null for personal accounts) + */ + public async handleCallback( + code: string | null, + state: string | null, + organizationId?: string | null, + ): Promise { + if (!code || !state) { + const vscode = await importVscode() + + if (vscode) { + vscode.window.showInformationMessage("Invalid Roo Code Cloud sign in url") + } + + return + } + + try { + // Validate state parameter to prevent CSRF attacks. + const storedState = this.context.globalState.get(AUTH_STATE_KEY) + + if (state !== storedState) { + this.log("[auth] State mismatch in callback") + throw new Error("Invalid state parameter. Authentication request may have been tampered with.") + } + + const credentials = await this.clerkSignIn(code) + + // Set organizationId (null for personal accounts) + credentials.organizationId = organizationId || null + + await this.storeCredentials(credentials) + + const vscode = await importVscode() + + if (vscode) { + vscode.window.showInformationMessage("Successfully authenticated with Roo Code Cloud") + } + + this.log("[auth] Successfully authenticated with Roo Code Cloud") + } catch (error) { + this.log(`[auth] Error handling Roo Code Cloud callback: ${error}`) + this.changeState("logged-out") + throw new Error(`Failed to handle Roo Code Cloud callback: ${error}`) + } + } + + /** + * Log out + * + * This method removes all stored tokens and stops the refresh timer. + */ + public async logout(): Promise { + const oldCredentials = this.credentials + + try { + // Clear credentials from storage - onDidChange will handle state transitions + await this.clearCredentials() + await this.context.globalState.update(AUTH_STATE_KEY, undefined) + + if (oldCredentials) { + try { + await this.clerkLogout(oldCredentials) + } catch (error) { + this.log("[auth] Error calling clerkLogout:", error) + } + } + + const vscode = await importVscode() + + if (vscode) { + vscode.window.showInformationMessage("Logged out from Roo Code Cloud") + } + + this.log("[auth] Logged out from Roo Code Cloud") + } catch (error) { + this.log(`[auth] Error logging out from Roo Code Cloud: ${error}`) + throw new Error(`Failed to log out from Roo Code Cloud: ${error}`) + } + } + + public getState(): AuthState { + return this.state + } + + public getSessionToken(): string | undefined { + if (this.state === "active-session" && this.sessionToken) { + return this.sessionToken + } + + return + } + + /** + * Check if the user is authenticated + * + * @returns True if the user is authenticated (has an active, attempting, or inactive session) + */ + public isAuthenticated(): boolean { + return ( + this.state === "active-session" || this.state === "attempting-session" || this.state === "inactive-session" + ) + } + + public hasActiveSession(): boolean { + return this.state === "active-session" + } + + /** + * Check if the user has an active session or is currently attempting to acquire one + * + * @returns True if the user has an active session or is attempting to get one + */ + public hasOrIsAcquiringActiveSession(): boolean { + return this.state === "active-session" || this.state === "attempting-session" + } + + /** + * Refresh the session + * + * This method refreshes the session token using the client token. + */ + private async refreshSession(): Promise { + if (!this.credentials) { + this.log("[auth] Cannot refresh session: missing credentials") + return + } + + try { + const previousState = this.state + this.sessionToken = await this.clerkCreateSessionToken() + + if (previousState !== "active-session") { + this.changeState("active-session") + this.log("[auth] Transitioned to active-session state") + this.fetchUserInfo() + } else { + this.state = "active-session" + } + } catch (error) { + if (error instanceof InvalidClientTokenError) { + this.log("[auth] Invalid/Expired client token: clearing credentials") + this.clearCredentials() + } else if (this.isFirstRefreshAttempt && this.state === "attempting-session") { + this.isFirstRefreshAttempt = false + this.transitionToInactiveSession() + } + this.log("[auth] Failed to refresh session", error) + throw error + } + } + + private async fetchUserInfo(): Promise { + if (!this.credentials) { + return + } + + this.userInfo = await this.clerkMe() + this.emit("user-info", { userInfo: this.userInfo }) + } + + /** + * Extract user information from the ID token + * + * @returns User information from ID token claims or null if no ID token available + */ + public getUserInfo(): CloudUserInfo | null { + return this.userInfo + } + + /** + * Get the stored organization ID from credentials + * + * @returns The stored organization ID, null for personal accounts or if no credentials exist + */ + public getStoredOrganizationId(): string | null { + return this.credentials?.organizationId || null + } + + private async clerkSignIn(ticket: string): Promise { + const formData = new URLSearchParams() + formData.append("strategy", "ticket") + formData.append("ticket", ticket) + + const response = await fetch(`${getClerkBaseUrl()}/v1/client/sign_ins`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": this.userAgent(), + }, + body: formData.toString(), + signal: AbortSignal.timeout(10000), + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const { + response: { created_session_id: sessionId }, + } = clerkSignInResponseSchema.parse(await response.json()) + + // 3. Extract the client token from the Authorization header. + const clientToken = response.headers.get("authorization") + + if (!clientToken) { + throw new Error("No authorization header found in the response") + } + + return authCredentialsSchema.parse({ clientToken, sessionId }) + } + + private async clerkCreateSessionToken(): Promise { + const formData = new URLSearchParams() + formData.append("_is_native", "1") + + // Handle 3 cases for organization_id: + // 1. Have an org id: organization_id=THE_ORG_ID + // 2. Have a personal account: organization_id= (empty string) + // 3. Don't know if you have an org id (old style credentials): don't send organization_id param at all + const organizationId = this.getStoredOrganizationId() + if (this.credentials?.organizationId !== undefined) { + // We have organization context info (either org id or personal account) + formData.append("organization_id", organizationId || "") + } + // If organizationId is undefined, don't send the param at all (old credentials) + + const response = await fetch(`${getClerkBaseUrl()}/v1/client/sessions/${this.credentials!.sessionId}/tokens`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Bearer ${this.credentials!.clientToken}`, + "User-Agent": this.userAgent(), + }, + body: formData.toString(), + signal: AbortSignal.timeout(10000), + }) + + if (response.status === 401 || response.status === 404) { + throw new InvalidClientTokenError() + } else if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const data = clerkCreateSessionTokenResponseSchema.parse(await response.json()) + + return data.jwt + } + + private async clerkMe(): Promise { + const response = await fetch(`${getClerkBaseUrl()}/v1/me`, { + headers: { + Authorization: `Bearer ${this.credentials!.clientToken}`, + "User-Agent": this.userAgent(), + }, + signal: AbortSignal.timeout(10000), + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const payload = await response.json() + const { response: userData } = clerkMeResponseSchema.parse(payload) + + const userInfo: CloudUserInfo = { + id: userData.id, + picture: userData.image_url, + } + + const names = [userData.first_name, userData.last_name].filter((name) => !!name) + userInfo.name = names.length > 0 ? names.join(" ") : undefined + const primaryEmailAddressId = userData.primary_email_address_id + const emailAddresses = userData.email_addresses + + if (primaryEmailAddressId && emailAddresses) { + userInfo.email = emailAddresses.find( + (email: { id: string }) => primaryEmailAddressId === email.id, + )?.email_address + } + + // Check for extension_bridge_enabled in user's public metadata + let extensionBridgeEnabled = false + if (userData.public_metadata?.extension_bridge_enabled === true) { + extensionBridgeEnabled = true + } + + // Fetch organization info if user is in organization context + try { + const storedOrgId = this.getStoredOrganizationId() + + if (this.credentials?.organizationId !== undefined) { + // We have organization context info + if (storedOrgId !== null) { + // User is in organization context - fetch user's memberships and filter + const orgMemberships = await this.clerkGetOrganizationMemberships() + const userMembership = this.findOrganizationMembership(orgMemberships, storedOrgId) + + if (userMembership) { + this.setUserOrganizationInfo(userInfo, userMembership) + + // Check organization public metadata for extension_bridge_enabled + // Organization setting takes precedence over user setting + if (await this.isExtensionBridgeEnabledForOrganization(storedOrgId)) { + extensionBridgeEnabled = true + } + + this.log("[auth] User in organization context:", { + id: userMembership.organization.id, + name: userMembership.organization.name, + role: userMembership.role, + }) + } else { + this.log("[auth] Warning: User not found in stored organization:", storedOrgId) + } + } else { + this.log("[auth] User in personal account context - not setting organization info") + } + } else { + // Old credentials without organization context - fetch organization info to determine context + const orgMemberships = await this.clerkGetOrganizationMemberships() + const primaryOrgMembership = this.findPrimaryOrganizationMembership(orgMemberships) + + if (primaryOrgMembership) { + this.setUserOrganizationInfo(userInfo, primaryOrgMembership) + + // Check organization public metadata for extension_bridge_enabled + if (await this.isExtensionBridgeEnabledForOrganization(primaryOrgMembership.organization.id)) { + extensionBridgeEnabled = true + } + + this.log("[auth] Legacy credentials: Found organization membership:", { + id: primaryOrgMembership.organization.id, + name: primaryOrgMembership.organization.name, + role: primaryOrgMembership.role, + }) + } else { + this.log("[auth] Legacy credentials: No organization memberships found") + } + } + } catch (error) { + this.log("[auth] Failed to fetch organization info:", error) + // Don't throw - organization info is optional + } + + // Set the extension bridge enabled flag + userInfo.extensionBridgeEnabled = extensionBridgeEnabled + + return userInfo + } + + private findOrganizationMembership( + memberships: CloudOrganizationMembership[], + organizationId: string, + ): CloudOrganizationMembership | undefined { + return memberships?.find((membership) => membership.organization.id === organizationId) + } + + private findPrimaryOrganizationMembership( + memberships: CloudOrganizationMembership[], + ): CloudOrganizationMembership | undefined { + return memberships && memberships.length > 0 ? memberships[0] : undefined + } + + private setUserOrganizationInfo(userInfo: CloudUserInfo, membership: CloudOrganizationMembership): void { + userInfo.organizationId = membership.organization.id + userInfo.organizationName = membership.organization.name + userInfo.organizationRole = membership.role + userInfo.organizationImageUrl = membership.organization.image_url + } + + private async clerkGetOrganizationMemberships(): Promise { + const response = await fetch(`${getClerkBaseUrl()}/v1/me/organization_memberships`, { + headers: { + Authorization: `Bearer ${this.credentials!.clientToken}`, + "User-Agent": this.userAgent(), + }, + signal: AbortSignal.timeout(10000), + }) + + return clerkOrganizationMembershipsSchema.parse(await response.json()).response + } + + private async getOrganizationMetadata( + organizationId: string, + ): Promise<{ public_metadata?: Record } | null> { + try { + const response = await fetch(`${getClerkBaseUrl()}/v1/organizations/${organizationId}`, { + headers: { + Authorization: `Bearer ${this.credentials!.clientToken}`, + "User-Agent": this.userAgent(), + }, + signal: AbortSignal.timeout(10000), + }) + + if (!response.ok) { + this.log(`[auth] Failed to fetch organization metadata: ${response.status} ${response.statusText}`) + return null + } + + const data = await response.json() + return data.response || data + } catch (error) { + this.log("[auth] Error fetching organization metadata:", error) + return null + } + } + + private async isExtensionBridgeEnabledForOrganization(organizationId: string): Promise { + const orgMetadata = await this.getOrganizationMetadata(organizationId) + return orgMetadata?.public_metadata?.extension_bridge_enabled === true + } + + private async clerkLogout(credentials: AuthCredentials): Promise { + const formData = new URLSearchParams() + formData.append("_is_native", "1") + + const response = await fetch(`${getClerkBaseUrl()}/v1/client/sessions/${credentials.sessionId}/remove`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Bearer ${credentials.clientToken}`, + "User-Agent": this.userAgent(), + }, + body: formData.toString(), + signal: AbortSignal.timeout(10000), + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + } + + private userAgent(): string { + return getUserAgent(this.context) + } +} diff --git a/packages/cloud/src/__mocks__/vscode.ts b/packages/cloud/src/__mocks__/vscode.ts new file mode 100644 index 0000000000..09384d195f --- /dev/null +++ b/packages/cloud/src/__mocks__/vscode.ts @@ -0,0 +1,59 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export const window = { + showInformationMessage: vi.fn(), + showErrorMessage: vi.fn(), +} + +export const env = { + openExternal: vi.fn(), +} + +export const Uri = { + parse: vi.fn((uri: string) => ({ toString: () => uri })), +} + +export interface ExtensionContext { + secrets: { + get: (key: string) => Promise + store: (key: string, value: string) => Promise + delete: (key: string) => Promise + onDidChange: (listener: (e: { key: string }) => void) => { + dispose: () => void + } + } + globalState: { + get: (key: string) => T | undefined + update: (key: string, value: any) => Promise + } + subscriptions: any[] + extension?: { + packageJSON?: { + version?: string + publisher?: string + name?: string + } + } +} + +// Mock implementation for tests +export const mockExtensionContext: ExtensionContext = { + secrets: { + get: vi.fn().mockResolvedValue(undefined), + store: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }), + }, + globalState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + }, + subscriptions: [], + extension: { + packageJSON: { + version: "1.0.0", + publisher: "RooVeterinaryInc", + name: "roo-cline", + }, + }, +} diff --git a/packages/cloud/src/__tests__/CloudService.integration.test.ts b/packages/cloud/src/__tests__/CloudService.integration.test.ts new file mode 100644 index 0000000000..2896b43554 --- /dev/null +++ b/packages/cloud/src/__tests__/CloudService.integration.test.ts @@ -0,0 +1,147 @@ +// npx vitest run src/__tests__/CloudService.integration.test.ts + +import type { ExtensionContext } from "vscode" + +import { CloudService } from "../CloudService.js" +import { StaticSettingsService } from "../StaticSettingsService.js" +import { CloudSettingsService } from "../CloudSettingsService.js" + +vi.mock("vscode", () => ({ + ExtensionContext: vi.fn(), + window: { + showInformationMessage: vi.fn(), + showErrorMessage: vi.fn(), + }, + env: { + openExternal: vi.fn(), + }, + Uri: { + parse: vi.fn(), + }, +})) + +describe("CloudService Integration - Settings Service Selection", () => { + let mockContext: ExtensionContext + + beforeEach(() => { + CloudService.resetInstance() + + mockContext = { + subscriptions: [], + workspaceState: { + get: vi.fn(), + update: vi.fn(), + keys: vi.fn().mockReturnValue([]), + }, + secrets: { + get: vi.fn(), + store: vi.fn(), + delete: vi.fn(), + onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }), + }, + globalState: { + get: vi.fn(), + update: vi.fn(), + setKeysForSync: vi.fn(), + keys: vi.fn().mockReturnValue([]), + }, + extensionUri: { scheme: "file", path: "/mock/path" }, + extensionPath: "/mock/path", + extensionMode: 1, + asAbsolutePath: vi.fn((relativePath: string) => `/mock/path/${relativePath}`), + storageUri: { scheme: "file", path: "/mock/storage" }, + extension: { + packageJSON: { + version: "1.0.0", + }, + }, + } as unknown as ExtensionContext + }) + + afterEach(() => { + CloudService.resetInstance() + delete process.env.ROO_CODE_CLOUD_ORG_SETTINGS + delete process.env.ROO_CODE_CLOUD_TOKEN + }) + + it("should use CloudSettingsService when no environment variable is set", async () => { + // Ensure no environment variables are set + delete process.env.ROO_CODE_CLOUD_ORG_SETTINGS + delete process.env.ROO_CODE_CLOUD_TOKEN + + const cloudService = await CloudService.createInstance(mockContext) + + // Access the private settingsService to check its type + const settingsService = (cloudService as unknown as { settingsService: unknown }).settingsService + expect(settingsService).toBeInstanceOf(CloudSettingsService) + }) + + it("should use StaticSettingsService when ROO_CODE_CLOUD_ORG_SETTINGS is set", async () => { + const validSettings = { + version: 1, + cloudSettings: { + recordTaskMessages: true, + enableTaskSharing: true, + taskShareExpirationDays: 30, + }, + defaultSettings: { + enableCheckpoints: true, + }, + allowList: { + allowAll: true, + providers: {}, + }, + } + + // Set the environment variable + process.env.ROO_CODE_CLOUD_ORG_SETTINGS = Buffer.from(JSON.stringify(validSettings)).toString("base64") + + const cloudService = await CloudService.createInstance(mockContext) + + // Access the private settingsService to check its type + const settingsService = (cloudService as unknown as { settingsService: unknown }).settingsService + expect(settingsService).toBeInstanceOf(StaticSettingsService) + + // Verify the settings are correctly loaded + expect(cloudService.getAllowList()).toEqual(validSettings.allowList) + }) + + it("should throw error when ROO_CODE_CLOUD_ORG_SETTINGS contains invalid data", async () => { + // Set invalid environment variable + process.env.ROO_CODE_CLOUD_ORG_SETTINGS = "invalid-base64-data" + + await expect(CloudService.createInstance(mockContext)).rejects.toThrow("Failed to initialize CloudService") + }) + + it("should prioritize static token auth when both environment variables are set", async () => { + const validSettings = { + version: 1, + cloudSettings: { + recordTaskMessages: true, + enableTaskSharing: true, + taskShareExpirationDays: 30, + }, + defaultSettings: { + enableCheckpoints: true, + }, + allowList: { + allowAll: true, + providers: {}, + }, + } + + // Set both environment variables + process.env.ROO_CODE_CLOUD_TOKEN = "test-token" + process.env.ROO_CODE_CLOUD_ORG_SETTINGS = Buffer.from(JSON.stringify(validSettings)).toString("base64") + + const cloudService = await CloudService.createInstance(mockContext) + + // Should use StaticSettingsService for settings + const settingsService = (cloudService as unknown as { settingsService: unknown }).settingsService + expect(settingsService).toBeInstanceOf(StaticSettingsService) + + // Should use StaticTokenAuthService for auth (from the existing logic) + expect(cloudService.isAuthenticated()).toBe(true) + expect(cloudService.hasActiveSession()).toBe(true) + }) +}) diff --git a/packages/cloud/src/__tests__/CloudService.test.ts b/packages/cloud/src/__tests__/CloudService.test.ts new file mode 100644 index 0000000000..a9df02d664 --- /dev/null +++ b/packages/cloud/src/__tests__/CloudService.test.ts @@ -0,0 +1,600 @@ +// npx vitest run src/__tests__/CloudService.test.ts + +import * as vscode from "vscode" + +import type { ClineMessage } from "@roo-code/types" + +import { TaskNotFoundError } from "../errors.js" +import { CloudService } from "../CloudService.js" +import { WebAuthService } from "../WebAuthService.js" +import { CloudSettingsService } from "../CloudSettingsService.js" +import { CloudShareService } from "../CloudShareService.js" +import { CloudTelemetryClient as TelemetryClient } from "../TelemetryClient.js" + +vi.mock("vscode", () => ({ + ExtensionContext: vi.fn(), + window: { + showInformationMessage: vi.fn(), + showErrorMessage: vi.fn(), + }, + env: { + openExternal: vi.fn(), + }, + Uri: { + parse: vi.fn(), + }, +})) + +vi.mock("../WebAuthService") + +vi.mock("../CloudSettingsService") + +vi.mock("../CloudShareService") + +vi.mock("../TelemetryClient") + +describe("CloudService", () => { + let mockContext: vscode.ExtensionContext + + let mockAuthService: { + initialize: ReturnType + broadcast: ReturnType + login: ReturnType + logout: ReturnType + isAuthenticated: ReturnType + hasActiveSession: ReturnType + hasOrIsAcquiringActiveSession: ReturnType + getUserInfo: ReturnType + getState: ReturnType + getSessionToken: ReturnType + handleCallback: ReturnType + getStoredOrganizationId: ReturnType + on: ReturnType + off: ReturnType + once: ReturnType + emit: ReturnType + } + + let mockSettingsService: { + initialize: ReturnType + getSettings: ReturnType + getAllowList: ReturnType + dispose: ReturnType + on: ReturnType + off: ReturnType + } + + let mockShareService: { + shareTask: ReturnType + canShareTask: ReturnType + } + + let mockTelemetryClient: { + backfillMessages: ReturnType + } + + beforeEach(() => { + CloudService.resetInstance() + + mockContext = { + subscriptions: [], + workspaceState: { + get: vi.fn(), + update: vi.fn(), + keys: vi.fn().mockReturnValue([]), + }, + secrets: { + get: vi.fn(), + store: vi.fn(), + delete: vi.fn(), + onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }), + }, + globalState: { + get: vi.fn(), + update: vi.fn(), + setKeysForSync: vi.fn(), + keys: vi.fn().mockReturnValue([]), + }, + extensionUri: { scheme: "file", path: "/mock/path" }, + extensionPath: "/mock/path", + extensionMode: 1, + asAbsolutePath: vi.fn((relativePath: string) => `/mock/path/${relativePath}`), + storageUri: { scheme: "file", path: "/mock/storage" }, + extension: { + packageJSON: { + version: "1.0.0", + }, + }, + } as unknown as vscode.ExtensionContext + + mockAuthService = { + initialize: vi.fn().mockResolvedValue(undefined), + broadcast: vi.fn(), + login: vi.fn(), + logout: vi.fn(), + isAuthenticated: vi.fn().mockReturnValue(false), + hasActiveSession: vi.fn().mockReturnValue(false), + hasOrIsAcquiringActiveSession: vi.fn().mockReturnValue(false), + getUserInfo: vi.fn(), + getState: vi.fn().mockReturnValue("logged-out"), + getSessionToken: vi.fn(), + handleCallback: vi.fn(), + getStoredOrganizationId: vi.fn().mockReturnValue(null), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + emit: vi.fn(), + } + + mockSettingsService = { + initialize: vi.fn(), + getSettings: vi.fn(), + getAllowList: vi.fn(), + dispose: vi.fn(), + on: vi.fn(), + off: vi.fn(), + } + + mockShareService = { + shareTask: vi.fn(), + canShareTask: vi.fn().mockResolvedValue(true), + } + + mockTelemetryClient = { + backfillMessages: vi.fn().mockResolvedValue(undefined), + } + + vi.mocked(WebAuthService).mockImplementation(() => mockAuthService as unknown as WebAuthService) + + vi.mocked(CloudSettingsService).mockImplementation(() => mockSettingsService as unknown as CloudSettingsService) + + vi.mocked(CloudShareService).mockImplementation(() => mockShareService as unknown as CloudShareService) + + vi.mocked(TelemetryClient).mockImplementation(() => mockTelemetryClient as unknown as TelemetryClient) + }) + + afterEach(() => { + vi.clearAllMocks() + CloudService.resetInstance() + }) + + describe("createInstance", () => { + it("should create and initialize CloudService instance", async () => { + const mockLog = vi.fn() + + const cloudService = await CloudService.createInstance(mockContext, mockLog) + + expect(cloudService).toBeInstanceOf(CloudService) + expect(WebAuthService).toHaveBeenCalledWith(mockContext, expect.any(Function)) + expect(CloudSettingsService).toHaveBeenCalledWith(mockContext, mockAuthService, expect.any(Function)) + }) + + it("should set up event listeners for CloudSettingsService", async () => { + const mockLog = vi.fn() + + await CloudService.createInstance(mockContext, mockLog) + + expect(mockSettingsService.on).toHaveBeenCalledWith("settings-updated", expect.any(Function)) + }) + + it("should throw error if instance already exists", async () => { + await CloudService.createInstance(mockContext) + + await expect(CloudService.createInstance(mockContext)).rejects.toThrow( + "CloudService instance already created", + ) + }) + }) + + describe("authentication methods", () => { + let cloudService: CloudService + + beforeEach(async () => { + cloudService = await CloudService.createInstance(mockContext) + }) + + it("should delegate login to AuthService", async () => { + await cloudService.login() + expect(mockAuthService.login).toHaveBeenCalled() + }) + + it("should delegate logout to AuthService", async () => { + await cloudService.logout() + expect(mockAuthService.logout).toHaveBeenCalled() + }) + + it("should delegate isAuthenticated to AuthService", () => { + const result = cloudService.isAuthenticated() + expect(mockAuthService.isAuthenticated).toHaveBeenCalled() + expect(result).toBe(false) + }) + + it("should delegate hasActiveSession to AuthService", () => { + const result = cloudService.hasActiveSession() + expect(mockAuthService.hasActiveSession).toHaveBeenCalled() + expect(result).toBe(false) + }) + + it("should delegate getUserInfo to AuthService", async () => { + await cloudService.getUserInfo() + expect(mockAuthService.getUserInfo).toHaveBeenCalled() + }) + + it("should return organization ID from user info", () => { + const mockUserInfo = { + name: "Test User", + email: "test@example.com", + organizationId: "org_123", + organizationName: "Test Org", + organizationRole: "admin", + } + mockAuthService.getUserInfo.mockReturnValue(mockUserInfo) + + const result = cloudService.getOrganizationId() + expect(mockAuthService.getUserInfo).toHaveBeenCalled() + expect(result).toBe("org_123") + }) + + it("should return null when no organization ID available", () => { + mockAuthService.getUserInfo.mockReturnValue(null) + + const result = cloudService.getOrganizationId() + expect(result).toBe(null) + }) + + it("should return organization name from user info", () => { + const mockUserInfo = { + name: "Test User", + email: "test@example.com", + organizationId: "org_123", + organizationName: "Test Org", + organizationRole: "admin", + } + mockAuthService.getUserInfo.mockReturnValue(mockUserInfo) + + const result = cloudService.getOrganizationName() + expect(mockAuthService.getUserInfo).toHaveBeenCalled() + expect(result).toBe("Test Org") + }) + + it("should return null when no organization name available", () => { + mockAuthService.getUserInfo.mockReturnValue(null) + + const result = cloudService.getOrganizationName() + expect(result).toBe(null) + }) + + it("should return organization role from user info", () => { + const mockUserInfo = { + name: "Test User", + email: "test@example.com", + organizationId: "org_123", + organizationName: "Test Org", + organizationRole: "admin", + } + mockAuthService.getUserInfo.mockReturnValue(mockUserInfo) + + const result = cloudService.getOrganizationRole() + expect(mockAuthService.getUserInfo).toHaveBeenCalled() + expect(result).toBe("admin") + }) + + it("should return null when no organization role available", () => { + mockAuthService.getUserInfo.mockReturnValue(null) + + const result = cloudService.getOrganizationRole() + expect(result).toBe(null) + }) + + it("should delegate getAuthState to AuthService", () => { + const result = cloudService.getAuthState() + expect(mockAuthService.getState).toHaveBeenCalled() + expect(result).toBe("logged-out") + }) + + it("should delegate handleAuthCallback to AuthService", async () => { + await cloudService.handleAuthCallback("code", "state") + expect(mockAuthService.handleCallback).toHaveBeenCalledWith("code", "state", undefined) + }) + + it("should delegate handleAuthCallback with organizationId to AuthService", async () => { + await cloudService.handleAuthCallback("code", "state", "org_123") + expect(mockAuthService.handleCallback).toHaveBeenCalledWith("code", "state", "org_123") + }) + + it("should return stored organization ID from AuthService", () => { + mockAuthService.getStoredOrganizationId.mockReturnValue("org_456") + + const result = cloudService.getStoredOrganizationId() + expect(mockAuthService.getStoredOrganizationId).toHaveBeenCalled() + expect(result).toBe("org_456") + }) + + it("should return null when no stored organization ID available", () => { + mockAuthService.getStoredOrganizationId.mockReturnValue(null) + + const result = cloudService.getStoredOrganizationId() + expect(result).toBe(null) + }) + + it("should return true when stored organization ID exists", () => { + mockAuthService.getStoredOrganizationId.mockReturnValue("org_789") + + const result = cloudService.hasStoredOrganizationId() + expect(result).toBe(true) + }) + + it("should return false when no stored organization ID exists", () => { + mockAuthService.getStoredOrganizationId.mockReturnValue(null) + + const result = cloudService.hasStoredOrganizationId() + expect(result).toBe(false) + }) + }) + + describe("organization settings methods", () => { + let cloudService: CloudService + + beforeEach(async () => { + cloudService = await CloudService.createInstance(mockContext) + }) + + it("should delegate getAllowList to SettingsService", () => { + cloudService.getAllowList() + expect(mockSettingsService.getAllowList).toHaveBeenCalled() + }) + }) + + describe("error handling", () => { + it("should throw error when accessing methods before initialization", () => { + expect(() => CloudService.instance.login()).toThrow("CloudService not initialized") + }) + + it("should throw error when accessing instance before creation", () => { + expect(() => CloudService.instance).toThrow("CloudService not initialized") + }) + }) + + describe("hasInstance", () => { + it("should return false when no instance exists", () => { + expect(CloudService.hasInstance()).toBe(false) + }) + + it("should return true when instance exists and is initialized", async () => { + await CloudService.createInstance(mockContext) + expect(CloudService.hasInstance()).toBe(true) + }) + }) + + describe("dispose", () => { + it("should dispose of all services and clean up", async () => { + const cloudService = await CloudService.createInstance(mockContext) + cloudService.dispose() + + expect(mockSettingsService.dispose).toHaveBeenCalled() + }) + + it("should remove event listeners from CloudSettingsService", async () => { + // Create a mock that will pass the instanceof check + const mockCloudSettingsService = Object.create(CloudSettingsService.prototype) + Object.assign(mockCloudSettingsService, { + initialize: vi.fn(), + getSettings: vi.fn(), + getAllowList: vi.fn(), + dispose: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }) + + // Override the mock to return our properly typed instance + vi.mocked(CloudSettingsService).mockImplementation(() => mockCloudSettingsService) + + const cloudService = await CloudService.createInstance(mockContext) + + // Verify the listener was added + expect(mockCloudSettingsService.on).toHaveBeenCalledWith("settings-updated", expect.any(Function)) + + // Get the listener function that was registered + const registeredListener = mockCloudSettingsService.on.mock.calls.find( + (call: unknown[]) => call[0] === "settings-updated", + )?.[1] + + cloudService.dispose() + + // Verify the listener was removed with the same function + expect(mockCloudSettingsService.off).toHaveBeenCalledWith("settings-updated", registeredListener) + }) + + it("should handle disposal when using StaticSettingsService", async () => { + // Reset the instance first + CloudService.resetInstance() + + // Mock a StaticSettingsService (which doesn't extend CloudSettingsService) + const mockStaticSettingsService = { + initialize: vi.fn(), + getSettings: vi.fn(), + getAllowList: vi.fn(), + dispose: vi.fn(), + on: vi.fn(), // Add on method to avoid initialization error + off: vi.fn(), // Add off method for disposal + } + + // Override the mock to return a service that won't pass instanceof check + vi.mocked(CloudSettingsService).mockImplementation( + () => mockStaticSettingsService as unknown as CloudSettingsService, + ) + + // This should not throw even though the service doesn't pass instanceof check + const _cloudService = await CloudService.createInstance(mockContext) + + // Should not throw when disposing + expect(() => _cloudService.dispose()).not.toThrow() + + // Should still call dispose on the settings service + expect(mockStaticSettingsService.dispose).toHaveBeenCalled() + // Should NOT call off method since it's not a CloudSettingsService instance + expect(mockStaticSettingsService.off).not.toHaveBeenCalled() + }) + }) + + describe("settings event handling", () => { + let _cloudService: CloudService + + beforeEach(async () => { + _cloudService = await CloudService.createInstance(mockContext) + }) + + it("should emit settings-updated event when settings are updated", async () => { + const settingsListener = vi.fn() + _cloudService.on("settings-updated", settingsListener) + + // Get the settings listener that was registered with the settings service + const serviceSettingsListener = mockSettingsService.on.mock.calls.find( + (call: string[]) => call[0] === "settings-updated", + )?.[1] + + expect(serviceSettingsListener).toBeDefined() + + // Simulate settings update event + const settingsData = { + settings: { + version: 2, + defaultSettings: {}, + allowList: { allowAll: true, providers: {} }, + }, + previousSettings: { + version: 1, + defaultSettings: {}, + allowList: { allowAll: true, providers: {} }, + }, + } + serviceSettingsListener(settingsData) + + expect(settingsListener).toHaveBeenCalledWith(settingsData) + }) + }) + + describe("shareTask with ClineMessage retry logic", () => { + let cloudService: CloudService + + beforeEach(async () => { + // Reset mocks for shareTask tests + vi.clearAllMocks() + + // Reset authentication state for shareTask tests + mockAuthService.isAuthenticated.mockReturnValue(true) + mockAuthService.hasActiveSession.mockReturnValue(true) + mockAuthService.hasOrIsAcquiringActiveSession.mockReturnValue(true) + mockAuthService.getState.mockReturnValue("active") + + cloudService = await CloudService.createInstance(mockContext) + }) + + it("should call shareTask without retry when successful", async () => { + const taskId = "test-task-id" + const visibility = "organization" + const clineMessages: ClineMessage[] = [ + { + ts: Date.now(), + type: "say", + say: "text", + text: "Hello world", + }, + ] + + const expectedResult = { + success: true, + shareUrl: "https://example.com/share/123", + } + mockShareService.shareTask.mockResolvedValue(expectedResult) + + const result = await cloudService.shareTask(taskId, visibility, clineMessages) + + expect(mockShareService.shareTask).toHaveBeenCalledTimes(1) + expect(mockShareService.shareTask).toHaveBeenCalledWith(taskId, visibility) + expect(mockTelemetryClient.backfillMessages).not.toHaveBeenCalled() + expect(result).toEqual(expectedResult) + }) + + it("should retry with backfill when TaskNotFoundError occurs", async () => { + const taskId = "test-task-id" + const visibility = "organization" + const clineMessages: ClineMessage[] = [ + { + ts: Date.now(), + type: "say", + say: "text", + text: "Hello world", + }, + ] + + const expectedResult = { + success: true, + shareUrl: "https://example.com/share/123", + } + + // First call throws TaskNotFoundError, second call succeeds + mockShareService.shareTask + .mockRejectedValueOnce(new TaskNotFoundError(taskId)) + .mockResolvedValueOnce(expectedResult) + + const result = await cloudService.shareTask(taskId, visibility, clineMessages) + + expect(mockShareService.shareTask).toHaveBeenCalledTimes(2) + expect(mockShareService.shareTask).toHaveBeenNthCalledWith(1, taskId, visibility) + expect(mockShareService.shareTask).toHaveBeenNthCalledWith(2, taskId, visibility) + expect(mockTelemetryClient.backfillMessages).toHaveBeenCalledTimes(1) + expect(mockTelemetryClient.backfillMessages).toHaveBeenCalledWith(clineMessages, taskId) + expect(result).toEqual(expectedResult) + }) + + it("should not retry when TaskNotFoundError occurs but no clineMessages provided", async () => { + const taskId = "test-task-id" + const visibility = "organization" + + const taskNotFoundError = new TaskNotFoundError(taskId) + mockShareService.shareTask.mockRejectedValue(taskNotFoundError) + + await expect(cloudService.shareTask(taskId, visibility)).rejects.toThrow(TaskNotFoundError) + + expect(mockShareService.shareTask).toHaveBeenCalledTimes(1) + expect(mockTelemetryClient.backfillMessages).not.toHaveBeenCalled() + }) + + it("should not retry when non-TaskNotFoundError occurs", async () => { + const taskId = "test-task-id" + const visibility = "organization" + const clineMessages: ClineMessage[] = [ + { + ts: Date.now(), + type: "say", + say: "text", + text: "Hello world", + }, + ] + + const genericError = new Error("Some other error") + mockShareService.shareTask.mockRejectedValue(genericError) + + await expect(cloudService.shareTask(taskId, visibility, clineMessages)).rejects.toThrow(genericError) + + expect(mockShareService.shareTask).toHaveBeenCalledTimes(1) + expect(mockTelemetryClient.backfillMessages).not.toHaveBeenCalled() + }) + + it("should work with default parameters", async () => { + const taskId = "test-task-id" + const expectedResult = { + success: true, + shareUrl: "https://example.com/share/123", + } + mockShareService.shareTask.mockResolvedValue(expectedResult) + + const result = await cloudService.shareTask(taskId) + + expect(mockShareService.shareTask).toHaveBeenCalledTimes(1) + expect(mockShareService.shareTask).toHaveBeenCalledWith(taskId, "organization") + expect(result).toEqual(expectedResult) + }) + }) +}) diff --git a/packages/cloud/src/__tests__/CloudSettingsService.parsing.test.ts b/packages/cloud/src/__tests__/CloudSettingsService.parsing.test.ts new file mode 100644 index 0000000000..22191ec90a --- /dev/null +++ b/packages/cloud/src/__tests__/CloudSettingsService.parsing.test.ts @@ -0,0 +1,172 @@ +// pnpm test src/__tests__/CloudSettingsService.parsing.test.ts + +import type { ExtensionContext } from "vscode" + +import type { AuthService } from "@roo-code/types" + +import { CloudSettingsService } from "../CloudSettingsService.js" + +describe("CloudSettingsService - Response Parsing", () => { + let mockContext: ExtensionContext + let mockAuthService: AuthService + let service: CloudSettingsService + + beforeEach(() => { + // Mock ExtensionContext + mockContext = { + globalState: { + get: vi.fn(), + update: vi.fn().mockResolvedValue(undefined), + }, + } as unknown as ExtensionContext + + // Mock AuthService with active session + mockAuthService = { + getState: vi.fn().mockReturnValue("active-session"), + hasActiveSession: vi.fn().mockReturnValue(true), + getSessionToken: vi.fn().mockReturnValue("test-token"), + on: vi.fn(), + removeListener: vi.fn(), + } as unknown as AuthService + + 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, + }, + } + + 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 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) + }) + + 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, + maxReadFileLine: 1000, + }, + 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: { + roomoteControlEnabled: true, + }, + settings: { + extensionBridgeEnabled: true, + }, + version: 1, + }, + } + + 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 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() + }) + + it("should handle invalid response gracefully", async () => { + // Mock invalid response + const mockResponse = { + organization: { + // Missing required fields + version: 1, + }, + user: { + // Missing required fields + version: 1, + }, + } + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResponse), + }) + + // Initialize the service + await service.initialize() + + // Settings should remain undefined due to validation failure + const orgSettings = service.getSettings() + const userSettings = service.getUserSettings() + + expect(orgSettings).toBeUndefined() + expect(userSettings).toBeUndefined() + }) +}) diff --git a/packages/cloud/src/__tests__/CloudSettingsService.test.ts b/packages/cloud/src/__tests__/CloudSettingsService.test.ts new file mode 100644 index 0000000000..49d61f85ce --- /dev/null +++ b/packages/cloud/src/__tests__/CloudSettingsService.test.ts @@ -0,0 +1,535 @@ +import type { ExtensionContext } from "vscode" + +import type { OrganizationSettings, 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 + } + let mockRefreshTimer: { + start: ReturnType + stop: ReturnType + } + 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() + + mockContext = { + globalState: { + get: vi.fn(), + update: vi.fn().mockResolvedValue(undefined), + }, + } as unknown as ExtensionContext + + mockAuthService = { + getState: vi.fn().mockReturnValue("logged-out"), + getSessionToken: vi.fn(), + hasActiveSession: vi.fn().mockReturnValue(false), + on: vi.fn(), + } + + mockRefreshTimer = { + start: vi.fn(), + stop: vi.fn(), + } + + 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) + }) + + afterEach(() => { + cloudSettingsService.dispose() + }) + + describe("constructor", () => { + it("should create CloudSettingsService with proper dependencies", () => { + expect(cloudSettingsService).toBeInstanceOf(CloudSettingsService) + expect(RefreshTimer).toHaveBeenCalledWith({ + callback: expect.any(Function), + successInterval: 30000, + initialBackoffMs: 1000, + maxBackoffMs: 30000, + }) + }) + + it("should use console.log as default logger when none provided", () => { + const service = new CloudSettingsService(mockContext, mockAuthService as unknown as AuthService) + expect(service).toBeInstanceOf(CloudSettingsService) + }) + }) + + 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") + + 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), + ) + }) + }) + + 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", () => { + 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 no settings available", () => { + const settings = cloudSettingsService.getSettings() + expect(settings).toBeUndefined() + }) + }) + + describe("dispose", () => { + it("should remove all listeners and stop timer", () => { + 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) + }) + }) +}) diff --git a/packages/cloud/src/__tests__/CloudShareService.test.ts b/packages/cloud/src/__tests__/CloudShareService.test.ts new file mode 100644 index 0000000000..d8a3820b92 --- /dev/null +++ b/packages/cloud/src/__tests__/CloudShareService.test.ts @@ -0,0 +1,318 @@ +/* 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", +})) + +vi.mock("../utils", () => ({ + getUserAgent: () => "Roo-Code 1.0.0", +})) + +describe("CloudShareService", () => { + let shareService: CloudShareService + let mockAuthService: AuthService + let mockSettingsService: SettingsService + let mockCloudAPI: CloudAPI + let mockLog: MockedFunction<(...args: unknown[]) => void> + + beforeEach(() => { + vi.clearAllMocks() + mockFetch.mockClear() + + mockLog = vi.fn() + mockAuthService = { + hasActiveSession: vi.fn(), + getSessionToken: vi.fn(), + isAuthenticated: vi.fn(), + } as any + + mockSettingsService = { + getSettings: vi.fn(), + } as any + + mockCloudAPI = new CloudAPI(mockAuthService, mockLog) + + shareService = new CloudShareService(mockCloudAPI, mockSettingsService, mockLog) + }) + + 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) + await expect(shareService.shareTask("task-123", "organization")).rejects.toThrow( + "HTTP 500: Internal Server Error", + ) + }) + + 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"), + }) + + 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") + } + }) + }) + + describe("canShareTask", () => { + it("should return true when authenticated and sharing is enabled", async () => { + ;(mockAuthService.isAuthenticated as any).mockReturnValue(true) + ;(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() + + 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__/RefreshTimer.test.ts b/packages/cloud/src/__tests__/RefreshTimer.test.ts new file mode 100644 index 0000000000..4cb5d8d803 --- /dev/null +++ b/packages/cloud/src/__tests__/RefreshTimer.test.ts @@ -0,0 +1,210 @@ +// npx vitest run src/__tests__/RefreshTimer.test.ts + +import type { Mock } from "vitest" + +import { RefreshTimer } from "../RefreshTimer.js" + +vi.useFakeTimers() + +describe("RefreshTimer", () => { + let mockCallback: Mock + let refreshTimer: RefreshTimer + + beforeEach(() => { + mockCallback = vi.fn() + mockCallback.mockResolvedValue(true) + }) + + afterEach(() => { + if (refreshTimer) { + refreshTimer.stop() + } + + vi.clearAllTimers() + vi.clearAllMocks() + }) + + it("should execute callback immediately when started", () => { + refreshTimer = new RefreshTimer({ + callback: mockCallback, + }) + + refreshTimer.start() + + expect(mockCallback).toHaveBeenCalledTimes(1) + }) + + it("should schedule next attempt after success interval when callback succeeds", async () => { + mockCallback.mockResolvedValue(true) + + refreshTimer = new RefreshTimer({ + callback: mockCallback, + successInterval: 50000, // 50 seconds + }) + + refreshTimer.start() + + // Fast-forward to execute the first callback + await Promise.resolve() + + expect(mockCallback).toHaveBeenCalledTimes(1) + + // Fast-forward 50 seconds + vi.advanceTimersByTime(50000) + + // Callback should be called again + expect(mockCallback).toHaveBeenCalledTimes(2) + }) + + it("should use exponential backoff when callback fails", async () => { + mockCallback.mockResolvedValue(false) + + refreshTimer = new RefreshTimer({ + callback: mockCallback, + initialBackoffMs: 1000, // 1 second + }) + + refreshTimer.start() + + // Fast-forward to execute the first callback + await Promise.resolve() + + expect(mockCallback).toHaveBeenCalledTimes(1) + + // Fast-forward 1 second + vi.advanceTimersByTime(1000) + + // Callback should be called again + expect(mockCallback).toHaveBeenCalledTimes(2) + + // Fast-forward to execute the second callback + await Promise.resolve() + + // Fast-forward 2 seconds + vi.advanceTimersByTime(2000) + + // Callback should be called again + expect(mockCallback).toHaveBeenCalledTimes(3) + + // Fast-forward to execute the third callback + await Promise.resolve() + }) + + it("should not exceed maximum backoff interval", async () => { + mockCallback.mockResolvedValue(false) + + refreshTimer = new RefreshTimer({ + callback: mockCallback, + initialBackoffMs: 1000, // 1 second + maxBackoffMs: 5000, // 5 seconds + }) + + refreshTimer.start() + + // Fast-forward through multiple failures to reach max backoff + await Promise.resolve() // First attempt + vi.advanceTimersByTime(1000) + + await Promise.resolve() // Second attempt (backoff = 2000ms) + vi.advanceTimersByTime(2000) + + await Promise.resolve() // Third attempt (backoff = 4000ms) + vi.advanceTimersByTime(4000) + + await Promise.resolve() // Fourth attempt (backoff would be 8000ms but max is 5000ms) + + // Should be capped at maxBackoffMs (no way to verify without logger) + }) + + it("should reset backoff after a successful attempt", async () => { + // First call fails, second succeeds, third fails + mockCallback.mockResolvedValueOnce(false).mockResolvedValueOnce(true).mockResolvedValueOnce(false) + + refreshTimer = new RefreshTimer({ + callback: mockCallback, + initialBackoffMs: 1000, + successInterval: 5000, + }) + + refreshTimer.start() + + // First attempt (fails) + await Promise.resolve() + + // Fast-forward 1 second + vi.advanceTimersByTime(1000) + + // Second attempt (succeeds) + await Promise.resolve() + + // Fast-forward 5 seconds + vi.advanceTimersByTime(5000) + + // Third attempt (fails) + await Promise.resolve() + + // Backoff should be reset to initial value (no way to verify without logger) + }) + + it("should handle errors in callback as failures", async () => { + mockCallback.mockRejectedValue(new Error("Test error")) + + refreshTimer = new RefreshTimer({ + callback: mockCallback, + initialBackoffMs: 1000, + }) + + refreshTimer.start() + + // Fast-forward to execute the callback + await Promise.resolve() + + // Error should be treated as a failure (no way to verify without logger) + }) + + it("should stop the timer and cancel pending executions", () => { + refreshTimer = new RefreshTimer({ + callback: mockCallback, + }) + + refreshTimer.start() + + // Stop the timer + refreshTimer.stop() + + // Fast-forward a long time + vi.advanceTimersByTime(1000000) + + // Callback should only have been called once (the initial call) + expect(mockCallback).toHaveBeenCalledTimes(1) + }) + + it("should reset the backoff state", async () => { + mockCallback.mockResolvedValue(false) + + refreshTimer = new RefreshTimer({ + callback: mockCallback, + initialBackoffMs: 1000, + }) + + refreshTimer.start() + + // Fast-forward through a few failures + await Promise.resolve() + vi.advanceTimersByTime(1000) + + await Promise.resolve() + vi.advanceTimersByTime(2000) + + // Reset the timer + refreshTimer.reset() + + // Stop and restart to trigger a new execution + refreshTimer.stop() + refreshTimer.start() + + await Promise.resolve() + + // Backoff should be back to initial value (no way to verify without logger) + }) +}) diff --git a/packages/cloud/src/__tests__/StaticSettingsService.test.ts b/packages/cloud/src/__tests__/StaticSettingsService.test.ts new file mode 100644 index 0000000000..f2c94a5f06 --- /dev/null +++ b/packages/cloud/src/__tests__/StaticSettingsService.test.ts @@ -0,0 +1,102 @@ +// npx vitest run src/__tests__/StaticSettingsService.test.ts + +import { StaticSettingsService } from "../StaticSettingsService.js" + +describe("StaticSettingsService", () => { + const validSettings = { + version: 1, + cloudSettings: { + recordTaskMessages: true, + enableTaskSharing: true, + taskShareExpirationDays: 30, + }, + defaultSettings: { + enableCheckpoints: true, + maxOpenTabsContext: 10, + }, + allowList: { + allowAll: false, + providers: { + anthropic: { + allowAll: true, + }, + }, + }, + } + + const validBase64 = Buffer.from(JSON.stringify(validSettings)).toString("base64") + + describe("constructor", () => { + it("should parse valid base64 encoded JSON settings", () => { + const service = new StaticSettingsService(validBase64) + expect(service.getSettings()).toEqual(validSettings) + }) + + it("should throw error for invalid base64", () => { + expect(() => new StaticSettingsService("invalid-base64!@#")).toThrow("Failed to parse static settings") + }) + + it("should throw error for invalid JSON", () => { + const invalidJson = Buffer.from("{ invalid json }").toString("base64") + expect(() => new StaticSettingsService(invalidJson)).toThrow("Failed to parse static settings") + }) + + it("should throw error for invalid schema", () => { + const invalidSettings = { invalid: "schema" } + const invalidBase64 = Buffer.from(JSON.stringify(invalidSettings)).toString("base64") + expect(() => new StaticSettingsService(invalidBase64)).toThrow("Failed to parse static settings") + }) + }) + + describe("getAllowList", () => { + it("should return the allow list from settings", () => { + const service = new StaticSettingsService(validBase64) + expect(service.getAllowList()).toEqual(validSettings.allowList) + }) + }) + + describe("getSettings", () => { + it("should return the parsed settings", () => { + const service = new StaticSettingsService(validBase64) + expect(service.getSettings()).toEqual(validSettings) + }) + }) + + describe("dispose", () => { + it("should be a no-op for static settings", () => { + const service = new StaticSettingsService(validBase64) + expect(() => service.dispose()).not.toThrow() + }) + }) + + describe("logging", () => { + it("should use provided logger for errors", () => { + const mockLog = vi.fn() + expect(() => new StaticSettingsService("invalid-base64!@#", mockLog)).toThrow() + + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining("[StaticSettingsService] failed to parse static settings:"), + expect.any(Error), + ) + }) + + it("should use console.log as default logger for errors", () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}) + expect(() => new StaticSettingsService("invalid-base64!@#")).toThrow() + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("[StaticSettingsService] failed to parse static settings:"), + expect.any(Error), + ) + + consoleSpy.mockRestore() + }) + + it("should not log anything for successful parsing", () => { + const mockLog = vi.fn() + new StaticSettingsService(validBase64, mockLog) + + expect(mockLog).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/cloud/src/__tests__/StaticTokenAuthService.spec.ts b/packages/cloud/src/__tests__/StaticTokenAuthService.spec.ts new file mode 100644 index 0000000000..a3756082ea --- /dev/null +++ b/packages/cloud/src/__tests__/StaticTokenAuthService.spec.ts @@ -0,0 +1,314 @@ +import type { ExtensionContext } from "vscode" + +import { StaticTokenAuthService } from "../StaticTokenAuthService.js" + +vi.mock("vscode", () => ({ + window: { + showInformationMessage: vi.fn(), + }, + env: { + openExternal: vi.fn(), + uriScheme: "vscode", + }, + Uri: { + parse: vi.fn(), + }, +})) + +describe("StaticTokenAuthService", () => { + let authService: StaticTokenAuthService + let mockContext: ExtensionContext + let mockLog: (...args: unknown[]) => void + const testToken = "test-static-token" + + // Job token (t:'cj') with userId and orgId - sub is CloudJob ID + const jobTokenWithOrg = + "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJyY2MiLCJzdWIiOiIzIiwiZXhwIjoxNzU2Mjc5NzU0LCJpYXQiOjE3NTYyNzU4NTQsIm5iZiI6MTc1NjI3NTgyNCwidiI6MSwiciI6eyJ1IjoidXNlcl8yeG1CaGVqTmVEVHdhbk04Q2dJT25NZ1Z4ekMiLCJvIjoib3JnXzEyM2FiYyIsInQiOiJjaiJ9fQ.k6VgV0cZUbx75kdedaeAsVYSRT7PzxDOCseLowq6moX92B4QuqtNkPRLKtQX7pJCxjuqRwEjJxmfTeXtQ82Pyg" + + // Job token without orgId (orgId was null during creation) + const jobTokenNoOrg = + "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJyY2MiLCJzdWIiOiI1IiwiZXhwIjoxNzU2Mjc5NzU0LCJpYXQiOjE3NTYyNzU4NTQsIm5iZiI6MTc1NjI3NTgyNCwidiI6MSwiciI6eyJ1IjoidXNlcl8yeG1CaGVqTmVEVHdhbk04Q2dJT25NZ1Z4ekMiLCJ0IjoiY2oifX0.signature" + + // Auth token (t:'auth') - sub is User ID + const authToken = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJyY2MiLCJzdWIiOiJ1c2VyXzEyMyIsImV4cCI6MTc1NjI3OTc1NCwiaWF0IjoxNzU2Mjc1ODU0LCJuYmYiOjE3NTYyNzU4MjQsInYiOjEsInIiOnsidSI6InVzZXJfMTIzIiwidCI6ImF1dGgifX0.signature" + + // JWT without the 'r' field (legacy format) + const legacyJWT = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImlhdCI6MTUxNjIzOTAyMn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + + beforeEach(() => { + mockLog = vi.fn() + + // Create a minimal mock that satisfies the constructor requirements + const mockContextPartial = { + extension: { + packageJSON: { + publisher: "TestPublisher", + name: "test-extension", + }, + }, + globalState: { + get: vi.fn(), + update: vi.fn(), + }, + secrets: { + get: vi.fn(), + store: vi.fn(), + delete: vi.fn(), + onDidChange: vi.fn(), + }, + subscriptions: [], + } + + // Use type assertion for test mocking + mockContext = mockContextPartial as unknown as ExtensionContext + + authService = new StaticTokenAuthService(mockContext, testToken, mockLog) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe("constructor", () => { + it("should create instance and log static token mode", () => { + expect(authService).toBeInstanceOf(StaticTokenAuthService) + expect(mockLog).toHaveBeenCalledWith("[auth] Using StaticTokenAuthService") + }) + + it("should use console.log as default logger", () => { + const serviceWithoutLog = new StaticTokenAuthService(mockContext as unknown as ExtensionContext, testToken) + // Can't directly test console.log usage, but constructor should not throw + expect(serviceWithoutLog).toBeInstanceOf(StaticTokenAuthService) + }) + + it("should parse job token with orgId and extract userId from r.u", () => { + const serviceWithJWT = new StaticTokenAuthService(mockContext, jobTokenWithOrg, mockLog) + + const userInfo = serviceWithJWT.getUserInfo() + expect(userInfo?.id).toBe("user_2xmBhejNeDTwanM8CgIOnMgVxzC") + expect(userInfo?.organizationId).toBe("org_123abc") + expect(userInfo?.extensionBridgeEnabled).toBe(true) + }) + + it("should parse job token without orgId (null orgId case)", () => { + const serviceWithJWT = new StaticTokenAuthService(mockContext, jobTokenNoOrg, mockLog) + + const userInfo = serviceWithJWT.getUserInfo() + expect(userInfo?.id).toBe("user_2xmBhejNeDTwanM8CgIOnMgVxzC") + expect(userInfo?.organizationId).toBeUndefined() + expect(userInfo?.extensionBridgeEnabled).toBe(true) + }) + + it("should parse auth token and extract userId from r.u", () => { + const serviceWithAuthToken = new StaticTokenAuthService(mockContext, authToken, mockLog) + + const userInfo = serviceWithAuthToken.getUserInfo() + expect(userInfo?.id).toBe("user_123") + expect(userInfo?.organizationId).toBeUndefined() + expect(userInfo?.extensionBridgeEnabled).toBe(true) + }) + + it("should handle legacy JWT format with sub field", () => { + const serviceWithLegacyJWT = new StaticTokenAuthService(mockContext, legacyJWT, mockLog) + + const userInfo = serviceWithLegacyJWT.getUserInfo() + expect(userInfo?.id).toBe("user_123") + expect(userInfo?.organizationId).toBeUndefined() + expect(userInfo?.extensionBridgeEnabled).toBe(true) + }) + + it("should handle invalid JWT gracefully", () => { + const serviceWithInvalidJWT = new StaticTokenAuthService(mockContext, "invalid-jwt-token", mockLog) + + const userInfo = serviceWithInvalidJWT.getUserInfo() + expect(userInfo?.id).toBeUndefined() + expect(userInfo?.organizationId).toBeUndefined() + expect(userInfo?.extensionBridgeEnabled).toBe(true) + + expect(mockLog).toHaveBeenCalledWith("[auth] Failed to parse JWT:", expect.any(Error)) + }) + + it("should handle malformed JWT payload", () => { + // JWT with invalid base64 in payload + const malformedJWT = "header.!!!invalid-base64!!!.signature" + const serviceWithMalformedJWT = new StaticTokenAuthService(mockContext, malformedJWT, mockLog) + + const userInfo = serviceWithMalformedJWT.getUserInfo() + expect(userInfo?.id).toBeUndefined() + expect(userInfo?.organizationId).toBeUndefined() + + expect(mockLog).toHaveBeenCalledWith("[auth] Failed to parse JWT:", expect.any(Error)) + }) + }) + + describe("initialize", () => { + it("should start in active-session state", async () => { + await authService.initialize() + expect(authService.getState()).toBe("active-session") + }) + + it("should not emit events on initialize", async () => { + const authStateChangedSpy = vi.fn() + const userInfoSpy = vi.fn() + + authService.on("auth-state-changed", authStateChangedSpy) + authService.on("user-info", userInfoSpy) + + await authService.initialize() + + expect(authStateChangedSpy).not.toHaveBeenCalled() + expect(userInfoSpy).not.toHaveBeenCalled() + }) + }) + + describe("broadcast", () => { + it("should emit auth-state-changed event", () => { + const spy = vi.fn() + authService.on("auth-state-changed", spy) + + authService.broadcast() + + expect(spy).toHaveBeenCalledWith({ + state: "active-session", + previousState: "initializing", + }) + }) + + it("should emit user-info event", () => { + const spy = vi.fn() + authService.on("user-info", spy) + + authService.broadcast() + + expect(spy).toHaveBeenCalledWith({ + userInfo: expect.objectContaining({ + extensionBridgeEnabled: true, + }), + }) + }) + + it("should emit user-info with parsed JWT data", () => { + const serviceWithJWT = new StaticTokenAuthService(mockContext, jobTokenWithOrg, mockLog) + + const spy = vi.fn() + serviceWithJWT.on("user-info", spy) + + serviceWithJWT.broadcast() + + expect(spy).toHaveBeenCalledWith({ + userInfo: { + extensionBridgeEnabled: true, + id: "user_2xmBhejNeDTwanM8CgIOnMgVxzC", + organizationId: "org_123abc", + }, + }) + }) + }) + + describe("getSessionToken", () => { + it("should return the provided token", () => { + expect(authService.getSessionToken()).toBe(testToken) + }) + + it("should return different token when constructed with different token", () => { + const differentToken = "different-token" + const differentService = new StaticTokenAuthService(mockContext, differentToken, mockLog) + expect(differentService.getSessionToken()).toBe(differentToken) + }) + }) + + describe("getUserInfo", () => { + it("should return object with extensionBridgeEnabled flag", () => { + const userInfo = authService.getUserInfo() + expect(userInfo).toHaveProperty("extensionBridgeEnabled") + expect(userInfo?.extensionBridgeEnabled).toBe(true) + }) + }) + + describe("getStoredOrganizationId", () => { + it("should return null for non-JWT token", () => { + expect(authService.getStoredOrganizationId()).toBeNull() + }) + + it("should return organizationId from parsed JWT", () => { + const serviceWithJWT = new StaticTokenAuthService(mockContext, jobTokenWithOrg, mockLog) + + expect(serviceWithJWT.getStoredOrganizationId()).toBe("org_123abc") + }) + + it("should return null when JWT has no organizationId", () => { + const serviceWithNoOrg = new StaticTokenAuthService(mockContext, jobTokenNoOrg, mockLog) + + expect(serviceWithNoOrg.getStoredOrganizationId()).toBeNull() + }) + + it("should return null for legacy JWT format", () => { + const serviceWithLegacyJWT = new StaticTokenAuthService(mockContext, legacyJWT, mockLog) + + expect(serviceWithLegacyJWT.getStoredOrganizationId()).toBeNull() + }) + }) + + describe("authentication state methods", () => { + it("should always return true for isAuthenticated", () => { + expect(authService.isAuthenticated()).toBe(true) + }) + + it("should always return true for hasActiveSession", () => { + expect(authService.hasActiveSession()).toBe(true) + }) + + it("should always return true for hasOrIsAcquiringActiveSession", () => { + expect(authService.hasOrIsAcquiringActiveSession()).toBe(true) + }) + + it("should return active-session for getState", () => { + expect(authService.getState()).toBe("active-session") + }) + }) + + describe("disabled authentication methods", () => { + const expectedErrorMessage = "Authentication methods are disabled in StaticTokenAuthService" + + it("should throw error for login", async () => { + await expect(authService.login()).rejects.toThrow(expectedErrorMessage) + }) + + it("should throw error for logout", async () => { + await expect(authService.logout()).rejects.toThrow(expectedErrorMessage) + }) + + it("should throw error for handleCallback", async () => { + await expect(authService.handleCallback("code", "state")).rejects.toThrow(expectedErrorMessage) + }) + + it("should throw error for handleCallback with organization", async () => { + await expect(authService.handleCallback("code", "state", "org_123")).rejects.toThrow(expectedErrorMessage) + }) + }) + + describe("event emission", () => { + it("should be able to register and emit events via broadcast", () => { + const authStateChangedSpy = vi.fn() + const userInfoSpy = vi.fn() + + authService.on("auth-state-changed", authStateChangedSpy) + authService.on("user-info", userInfoSpy) + + authService.broadcast() + + expect(authStateChangedSpy).toHaveBeenCalledWith({ + state: "active-session", + previousState: "initializing", + }) + + expect(userInfoSpy).toHaveBeenCalledWith({ + userInfo: expect.objectContaining({ + extensionBridgeEnabled: true, + }), + }) + }) + }) +}) diff --git a/packages/cloud/src/__tests__/TelemetryClient.test.ts b/packages/cloud/src/__tests__/TelemetryClient.test.ts new file mode 100644 index 0000000000..6078e601dd --- /dev/null +++ b/packages/cloud/src/__tests__/TelemetryClient.test.ts @@ -0,0 +1,740 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +// npx vitest run src/__tests__/TelemetryClient.test.ts + +import { type TelemetryPropertiesProvider, TelemetryEventName } from "@roo-code/types" + +import { CloudTelemetryClient as TelemetryClient } from "../TelemetryClient.js" + +const mockFetch = vi.fn() +global.fetch = mockFetch as any + +describe("TelemetryClient", () => { + const getPrivateProperty = (instance: any, propertyName: string): T => { + return instance[propertyName] + } + + let mockAuthService: any + let mockSettingsService: any + + beforeEach(() => { + vi.clearAllMocks() + + // Create a mock AuthService instead of using the singleton + mockAuthService = { + getSessionToken: vi.fn().mockReturnValue("mock-token"), + getState: vi.fn().mockReturnValue("active-session"), + isAuthenticated: vi.fn().mockReturnValue(true), + hasActiveSession: vi.fn().mockReturnValue(true), + } + + // Create a mock SettingsService + mockSettingsService = { + getSettings: vi.fn().mockReturnValue({ + cloudSettings: { + recordTaskMessages: true, + }, + }), + } + + mockFetch.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({}), + }) + + vi.spyOn(console, "info").mockImplementation(() => {}) + vi.spyOn(console, "error").mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe("isEventCapturable", () => { + it("should return true for events not in exclude list", () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>( + client, + "isEventCapturable", + ).bind(client) + + expect(isEventCapturable(TelemetryEventName.TASK_CREATED)).toBe(true) + expect(isEventCapturable(TelemetryEventName.LLM_COMPLETION)).toBe(true) + expect(isEventCapturable(TelemetryEventName.MODE_SWITCH)).toBe(true) + expect(isEventCapturable(TelemetryEventName.TOOL_USED)).toBe(true) + }) + + it("should return false for events in exclude list", () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>( + client, + "isEventCapturable", + ).bind(client) + + expect(isEventCapturable(TelemetryEventName.TASK_CONVERSATION_MESSAGE)).toBe(false) + }) + + it("should return true for TASK_MESSAGE events when recordTaskMessages is true", () => { + mockSettingsService.getSettings.mockReturnValue({ + cloudSettings: { + recordTaskMessages: true, + }, + }) + + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>( + client, + "isEventCapturable", + ).bind(client) + + expect(isEventCapturable(TelemetryEventName.TASK_MESSAGE)).toBe(true) + }) + + it("should return false for TASK_MESSAGE events when recordTaskMessages is false", () => { + mockSettingsService.getSettings.mockReturnValue({ + cloudSettings: { + recordTaskMessages: false, + }, + }) + + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>( + client, + "isEventCapturable", + ).bind(client) + + expect(isEventCapturable(TelemetryEventName.TASK_MESSAGE)).toBe(false) + }) + + it("should return false for TASK_MESSAGE events when recordTaskMessages is undefined", () => { + mockSettingsService.getSettings.mockReturnValue({ + cloudSettings: {}, + }) + + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>( + client, + "isEventCapturable", + ).bind(client) + + expect(isEventCapturable(TelemetryEventName.TASK_MESSAGE)).toBe(false) + }) + + it("should return false for TASK_MESSAGE events when cloudSettings is undefined", () => { + mockSettingsService.getSettings.mockReturnValue({}) + + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>( + client, + "isEventCapturable", + ).bind(client) + + expect(isEventCapturable(TelemetryEventName.TASK_MESSAGE)).toBe(false) + }) + + it("should return false for TASK_MESSAGE events when getSettings returns undefined", () => { + mockSettingsService.getSettings.mockReturnValue(undefined) + + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + const isEventCapturable = getPrivateProperty<(eventName: TelemetryEventName) => boolean>( + client, + "isEventCapturable", + ).bind(client) + + expect(isEventCapturable(TelemetryEventName.TASK_MESSAGE)).toBe(false) + }) + }) + + describe("getEventProperties", () => { + it("should merge provider properties with event properties", async () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + const mockProvider: TelemetryPropertiesProvider = { + getTelemetryProperties: vi.fn().mockResolvedValue({ + appVersion: "1.0.0", + vscodeVersion: "1.60.0", + platform: "darwin", + editorName: "vscode", + language: "en", + mode: "code", + }), + } + + client.setProvider(mockProvider) + + const getEventProperties = getPrivateProperty< + (event: { event: TelemetryEventName; properties?: Record }) => Promise> + >(client, "getEventProperties").bind(client) + + const result = await getEventProperties({ + event: TelemetryEventName.TASK_CREATED, + properties: { + customProp: "value", + mode: "override", // This should override the provider's mode. + }, + }) + + expect(result).toEqual({ + appVersion: "1.0.0", + vscodeVersion: "1.60.0", + platform: "darwin", + editorName: "vscode", + language: "en", + mode: "override", // Event property takes precedence. + customProp: "value", + }) + + expect(mockProvider.getTelemetryProperties).toHaveBeenCalledTimes(1) + }) + + it("should handle errors from provider gracefully", async () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + const mockProvider: TelemetryPropertiesProvider = { + getTelemetryProperties: vi.fn().mockRejectedValue(new Error("Provider error")), + } + + const consoleErrorSpy = vi.spyOn(console, "error") + + client.setProvider(mockProvider) + + const getEventProperties = getPrivateProperty< + (event: { event: TelemetryEventName; properties?: Record }) => Promise> + >(client, "getEventProperties").bind(client) + + const result = await getEventProperties({ + event: TelemetryEventName.TASK_CREATED, + properties: { customProp: "value" }, + }) + + expect(result).toEqual({ customProp: "value" }) + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("Error getting telemetry properties: Provider error"), + ) + }) + + it("should return event properties when no provider is set", async () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + const getEventProperties = getPrivateProperty< + (event: { event: TelemetryEventName; properties?: Record }) => Promise> + >(client, "getEventProperties").bind(client) + + const result = await getEventProperties({ + event: TelemetryEventName.TASK_CREATED, + properties: { customProp: "value" }, + }) + + expect(result).toEqual({ customProp: "value" }) + }) + }) + + 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 recordTaskMessages is undefined", async () => { + mockSettingsService.getSettings.mockReturnValue({ + cloudSettings: {}, + }) + + 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 send request when schema validation fails", async () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + await client.capture({ + event: TelemetryEventName.TASK_CREATED, + properties: { test: "value" }, + }) + + 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 recordTaskMessages is true", async () => { + mockSettingsService.getSettings.mockReturnValue({ + cloudSettings: { + recordTaskMessages: 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, + } + + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + await client.capture({ + event: TelemetryEventName.TASK_MESSAGE, + properties: eventProperties, + }) + + expect(mockFetch).toHaveBeenCalledWith( + "https://app.roocode.com/api/events", + expect.objectContaining({ + method: "POST", + body: JSON.stringify(mockValidatedData), + }), + ) + }) + + it("should handle fetch errors gracefully", async () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService) + + mockFetch.mockRejectedValue(new Error("Network error")) + + await expect( + client.capture({ + event: TelemetryEventName.TASK_CREATED, + properties: { test: "value" }, + }), + ).resolves.not.toThrow() + }) + }) + + describe("telemetry state methods", () => { + it("should always return true for isTelemetryEnabled", () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService) + expect(client.isTelemetryEnabled()).toBe(true) + }) + + it("should have empty implementations for updateTelemetryState and shutdown", async () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService) + client.updateTelemetryState(true) + await client.shutdown() + }) + }) + + describe("backfillMessages", () => { + it("should not send request when not authenticated", async () => { + mockAuthService.isAuthenticated.mockReturnValue(false) + 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).not.toHaveBeenCalled() + }) + + it("should not send request when no session token available", async () => { + mockAuthService.getSessionToken.mockReturnValue(null) + 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).not.toHaveBeenCalled() + expect(console.error).toHaveBeenCalledWith( + "[TelemetryClient#backfillMessages] Unauthorized: No session token available.", + ) + }) + + it("should send FormData request with correct structure when authenticated", 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), + }), + ) + + // 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) + + 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 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)) + }) + + 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 log debug information when debug is enabled", async () => { + const client = new TelemetryClient(mockAuthService, mockSettingsService, true) + + const messages = [ + { + ts: 1, + type: "say" as const, + say: "text" as const, + text: "test message", + }, + ] + + await client.backfillMessages(messages, "test-task-id") + + expect(console.info).toHaveBeenCalledWith( + "[TelemetryClient#backfillMessages] Uploading 1 messages for task test-task-id", + ) + expect(console.info).toHaveBeenCalledWith( + "[TelemetryClient#backfillMessages] Successfully uploaded messages for task test-task-id", + ) + }) + + it("should handle empty messages array", 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("[]") + }) + }) +}) diff --git a/packages/cloud/src/__tests__/WebAuthService.spec.ts b/packages/cloud/src/__tests__/WebAuthService.spec.ts new file mode 100644 index 0000000000..dbcaf388d3 --- /dev/null +++ b/packages/cloud/src/__tests__/WebAuthService.spec.ts @@ -0,0 +1,1196 @@ +// npx vitest run src/__tests__/auth/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: { + showInformationMessage: vi.fn(), + showErrorMessage: vi.fn(), + }, + env: { + openExternal: vi.fn(), + uriScheme: "vscode", + }, + Uri: { + parse: vi.fn((uri: string) => ({ toString: () => uri })), + }, +})) + +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 + } + } + } + + beforeEach(() => { + // Reset all mocks + vi.clearAllMocks() + + // Setup mock context with proper subscriptions array + mockContext = { + subscriptions: { + push: vi.fn(), + }, + secrets: { + get: vi.fn().mockResolvedValue(undefined), + store: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }), + }, + globalState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + }, + extension: { + packageJSON: { + version: "1.0.0", + publisher: "RooVeterinaryInc", + name: "roo-cline", + }, + }, + } + + // 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) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe("constructor", () => { + it("should initialize with correct default values", () => { + expect(authService.getState()).toBe("initializing") + expect(authService.isAuthenticated()).toBe(false) + expect(authService.hasActiveSession()).toBe(false) + expect(authService.getSessionToken()).toBeUndefined() + 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 as unknown as ExtensionContext) + // Can't directly test console.log usage, but constructor should not throw + expect(serviceWithoutLog).toBeInstanceOf(WebAuthService) + }) + }) + + describe("initialize", () => { + it("should handle credentials change and setup event listener", async () => { + await authService.initialize() + + expect(mockContext.subscriptions.push).toHaveBeenCalled() + expect(mockContext.secrets.onDidChange).toHaveBeenCalled() + }) + + 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) + + 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) + + 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() + }) + }) + + describe("login", () => { + beforeEach(async () => { + await authService.initialize() + }) + + 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) + + 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", 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 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") + }) + }) + + describe("handleCallback", () => { + beforeEach(async () => { + await authService.initialize() + }) + + it("should handle invalid parameters", async () => { + const vscode = await import("vscode") + const mockShowInfo = vi.fn() + vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo) + + await authService.handleCallback(null, "state") + expect(mockShowInfo).toHaveBeenCalledWith("Invalid Roo Code Cloud sign in url") + + 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 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() + }) + }) + + describe("logout", () => { + beforeEach(async () => { + await authService.initialize() + }) + + 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) + + 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 authentication status", async () => { + 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 return correct values for new methods", async () => { + 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)) + await authService.initialize() + }) + + 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", + extensionBridgeEnabled: false, + }, + }) + }) + + 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", () => { + 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)) + 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", + extensionBridgeEnabled: false, + }) + }) + + 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", + extensionBridgeEnabled: false, + 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, + extensionBridgeEnabled: false, + }) + }) + }) + + describe("event emissions", () => { + it("should emit auth-state-changed event for logged-out", async () => { + const authStateChangedSpy = vi.fn() + authService.on("auth-state-changed", authStateChangedSpy) + + 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, + extensionBridgeEnabled: false, + }, + }) + }) + }) + + describe("error handling", () => { + it("should handle credentials change errors", async () => { + mockContext.secrets.get.mockRejectedValue(new Error("Storage error")) + + 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", + ) + }) + }) + + 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("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() + }) + }) +}) diff --git a/packages/cloud/src/bridge/ExtensionBridgeService.ts b/packages/cloud/src/bridge/ExtensionBridgeService.ts new file mode 100644 index 0000000000..0ab7e304f2 --- /dev/null +++ b/packages/cloud/src/bridge/ExtensionBridgeService.ts @@ -0,0 +1,290 @@ +import crypto from "crypto" + +import { + type TaskProviderLike, + type TaskLike, + type CloudUserInfo, + type ExtensionBridgeCommand, + type TaskBridgeCommand, + ConnectionState, + ExtensionSocketEvents, + TaskSocketEvents, +} from "@roo-code/types" + +import { SocketConnectionManager } from "./SocketConnectionManager.js" +import { ExtensionManager } from "./ExtensionManager.js" +import { TaskManager } from "./TaskManager.js" + +export interface ExtensionBridgeServiceOptions { + userId: string + socketBridgeUrl: string + token: string + provider: TaskProviderLike + sessionId?: string +} + +export class ExtensionBridgeService { + private static instance: ExtensionBridgeService | null = null + + // Core + private readonly userId: string + private readonly socketBridgeUrl: string + private readonly token: string + private readonly provider: TaskProviderLike + private readonly instanceId: string + + // Managers + private connectionManager: SocketConnectionManager + private extensionManager: ExtensionManager + private taskManager: TaskManager + + // Reconnection + private readonly MAX_RECONNECT_ATTEMPTS = Infinity + private readonly RECONNECT_DELAY = 1_000 + private readonly RECONNECT_DELAY_MAX = 30_000 + + public static getInstance(): ExtensionBridgeService | null { + return ExtensionBridgeService.instance + } + + public static async createInstance(options: ExtensionBridgeServiceOptions) { + console.log("[ExtensionBridgeService] createInstance") + ExtensionBridgeService.instance = new ExtensionBridgeService(options) + await ExtensionBridgeService.instance.initialize() + return ExtensionBridgeService.instance + } + + public static resetInstance() { + if (ExtensionBridgeService.instance) { + console.log("[ExtensionBridgeService] resetInstance") + ExtensionBridgeService.instance.disconnect().catch(() => {}) + ExtensionBridgeService.instance = null + } + } + + public static async handleRemoteControlState( + userInfo: CloudUserInfo | null, + remoteControlEnabled: boolean | undefined, + options: ExtensionBridgeServiceOptions, + logger?: (message: string) => void, + ) { + if (userInfo?.extensionBridgeEnabled && remoteControlEnabled) { + const existingService = ExtensionBridgeService.getInstance() + + if (!existingService) { + try { + const service = await ExtensionBridgeService.createInstance(options) + const state = service.getConnectionState() + + logger?.(`[ExtensionBridgeService#handleRemoteControlState] Instance created (state: ${state})`) + + if (state !== ConnectionState.CONNECTED) { + logger?.( + `[ExtensionBridgeService#handleRemoteControlState] Service is not connected yet, will retry in background`, + ) + } + } catch (error) { + const message = `[ExtensionBridgeService#handleRemoteControlState] Failed to create instance: ${ + error instanceof Error ? error.message : String(error) + }` + + logger?.(message) + console.error(message) + } + } else { + const state = existingService.getConnectionState() + + if (state === ConnectionState.FAILED || state === ConnectionState.DISCONNECTED) { + logger?.( + `[ExtensionBridgeService#handleRemoteControlState] Existing service is ${state}, attempting reconnection`, + ) + + existingService.reconnect().catch((error) => { + const message = `[ExtensionBridgeService#handleRemoteControlState] Reconnection failed: ${ + error instanceof Error ? error.message : String(error) + }` + + logger?.(message) + console.error(message) + }) + } + } + } else { + const existingService = ExtensionBridgeService.getInstance() + + if (existingService) { + try { + await existingService.disconnect() + ExtensionBridgeService.resetInstance() + + logger?.(`[ExtensionBridgeService#handleRemoteControlState] Service disconnected and reset`) + } catch (error) { + const message = `[ExtensionBridgeService#handleRemoteControlState] Failed to disconnect and reset instance: ${ + error instanceof Error ? error.message : String(error) + }` + + logger?.(message) + console.error(message) + } + } + } + } + + private constructor(options: ExtensionBridgeServiceOptions) { + this.userId = options.userId + this.socketBridgeUrl = options.socketBridgeUrl + this.token = options.token + this.provider = options.provider + this.instanceId = options.sessionId || crypto.randomUUID() + + this.connectionManager = new SocketConnectionManager({ + url: this.socketBridgeUrl, + socketOptions: { + query: { + token: this.token, + clientType: "extension", + instanceId: this.instanceId, + }, + transports: ["websocket", "polling"], + reconnection: true, + reconnectionAttempts: this.MAX_RECONNECT_ATTEMPTS, + reconnectionDelay: this.RECONNECT_DELAY, + reconnectionDelayMax: this.RECONNECT_DELAY_MAX, + }, + onConnect: () => this.handleConnect(), + onDisconnect: () => this.handleDisconnect(), + onReconnect: () => this.handleReconnect(), + }) + + this.extensionManager = new ExtensionManager(this.instanceId, this.userId, this.provider) + + this.taskManager = new TaskManager() + } + + private async initialize() { + // Populate the app and git properties before registering the instance. + await this.provider.getTelemetryProperties() + + await this.connectionManager.connect() + this.setupSocketListeners() + } + + private setupSocketListeners() { + const socket = this.connectionManager.getSocket() + + if (!socket) { + console.error("[ExtensionBridgeService] Socket not available") + return + } + + // Remove any existing listeners first to prevent duplicates. + socket.off(ExtensionSocketEvents.RELAYED_COMMAND) + socket.off(TaskSocketEvents.RELAYED_COMMAND) + socket.off("connected") + + socket.on(ExtensionSocketEvents.RELAYED_COMMAND, (message: ExtensionBridgeCommand) => { + console.log( + `[ExtensionBridgeService] on(${ExtensionSocketEvents.RELAYED_COMMAND}) -> ${message.type} for ${message.instanceId}`, + ) + + this.extensionManager?.handleExtensionCommand(message) + }) + + socket.on(TaskSocketEvents.RELAYED_COMMAND, (message: TaskBridgeCommand) => { + console.log( + `[ExtensionBridgeService] on(${TaskSocketEvents.RELAYED_COMMAND}) -> ${message.type} for ${message.taskId}`, + ) + + this.taskManager.handleTaskCommand(message) + }) + } + + private async handleConnect() { + const socket = this.connectionManager.getSocket() + + if (!socket) { + console.error("[ExtensionBridgeService] Socket not available after connect") + + return + } + + await this.extensionManager.onConnect(socket) + await this.taskManager.onConnect(socket) + } + + private handleDisconnect() { + this.extensionManager.onDisconnect() + this.taskManager.onDisconnect() + } + + private async handleReconnect() { + const socket = this.connectionManager.getSocket() + + if (!socket) { + console.error("[ExtensionBridgeService] Socket not available after reconnect") + + return + } + + // Re-setup socket listeners to ensure they're properly configured + // after automatic reconnection (Socket.IO's built-in reconnection) + // The socket.off() calls in setupSocketListeners prevent duplicates + this.setupSocketListeners() + + await this.extensionManager.onReconnect(socket) + await this.taskManager.onReconnect(socket) + } + + // Task API + + public async subscribeToTask(task: TaskLike): Promise { + const socket = this.connectionManager.getSocket() + + if (!socket || !this.connectionManager.isConnected()) { + console.warn("[ExtensionBridgeService] Cannot subscribe to task: not connected. Will retry when connected.") + + this.taskManager.addPendingTask(task) + + const state = this.connectionManager.getConnectionState() + + if (state === ConnectionState.DISCONNECTED || state === ConnectionState.FAILED) { + this.initialize() + } + + return + } + + await this.taskManager.subscribeToTask(task, socket) + } + + public async unsubscribeFromTask(taskId: string): Promise { + const socket = this.connectionManager.getSocket() + + if (!socket) { + return + } + + await this.taskManager.unsubscribeFromTask(taskId, socket) + } + + // Shared API + + public getConnectionState(): ConnectionState { + return this.connectionManager.getConnectionState() + } + + public async disconnect(): Promise { + await this.extensionManager.cleanup(this.connectionManager.getSocket()) + await this.taskManager.cleanup(this.connectionManager.getSocket()) + await this.connectionManager.disconnect() + ExtensionBridgeService.instance = null + } + + public async reconnect(): Promise { + await this.connectionManager.reconnect() + + // After a manual reconnect, we have a new socket instance + // so we need to set up listeners again. + this.setupSocketListeners() + } +} diff --git a/packages/cloud/src/bridge/ExtensionManager.ts b/packages/cloud/src/bridge/ExtensionManager.ts new file mode 100644 index 0000000000..335245e24c --- /dev/null +++ b/packages/cloud/src/bridge/ExtensionManager.ts @@ -0,0 +1,297 @@ +import type { Socket } from "socket.io-client" + +import { + type TaskProviderLike, + type ExtensionInstance, + type ExtensionBridgeCommand, + type ExtensionBridgeEvent, + RooCodeEventName, + TaskStatus, + ExtensionBridgeCommandName, + ExtensionBridgeEventName, + ExtensionSocketEvents, + HEARTBEAT_INTERVAL_MS, +} from "@roo-code/types" + +export class ExtensionManager { + private instanceId: string + private userId: string + private provider: TaskProviderLike + private extensionInstance: ExtensionInstance + private heartbeatInterval: NodeJS.Timeout | null = null + private socket: Socket | null = null + + constructor(instanceId: string, userId: string, provider: TaskProviderLike) { + this.instanceId = instanceId + this.userId = userId + this.provider = provider + + this.extensionInstance = { + instanceId: this.instanceId, + userId: this.userId, + workspacePath: this.provider.cwd, + appProperties: this.provider.appProperties, + gitProperties: this.provider.gitProperties, + lastHeartbeat: Date.now(), + task: { + taskId: "", + taskStatus: TaskStatus.None, + }, + taskHistory: [], + } + + this.setupListeners() + } + + public async onConnect(socket: Socket): Promise { + this.socket = socket + await this.registerInstance(socket) + this.startHeartbeat(socket) + } + + public onDisconnect(): void { + this.stopHeartbeat() + this.socket = null + } + + public async onReconnect(socket: Socket): Promise { + this.socket = socket + await this.registerInstance(socket) + this.startHeartbeat(socket) + } + + public async cleanup(socket: Socket | null): Promise { + this.stopHeartbeat() + + if (socket) { + await this.unregisterInstance(socket) + } + + this.socket = null + } + + public handleExtensionCommand(message: ExtensionBridgeCommand): void { + if (message.instanceId !== this.instanceId) { + console.log(`[ExtensionManager] command -> instance id mismatch | ${this.instanceId}`, { + messageInstanceId: message.instanceId, + }) + + return + } + + switch (message.type) { + case ExtensionBridgeCommandName.StartTask: { + console.log(`[ExtensionManager] command -> createTask() | ${message.instanceId}`, { + text: message.payload.text?.substring(0, 100) + "...", + hasImages: !!message.payload.images, + }) + + this.provider.createTask(message.payload.text, message.payload.images) + + break + } + case ExtensionBridgeCommandName.StopTask: { + const instance = this.updateInstance() + + if (instance.task.taskStatus === TaskStatus.Running) { + console.log(`[ExtensionManager] command -> cancelTask() | ${message.instanceId}`) + + this.provider.cancelTask() + this.provider.postStateToWebview() + } else if (instance.task.taskId) { + console.log(`[ExtensionManager] command -> clearTask() | ${message.instanceId}`) + + this.provider.clearTask() + this.provider.postStateToWebview() + } + + break + } + case ExtensionBridgeCommandName.ResumeTask: { + console.log(`[ExtensionManager] command -> resumeTask() | ${message.instanceId}`, { + taskId: message.payload.taskId, + }) + + // Resume the task from history by taskId + this.provider.resumeTask(message.payload.taskId) + + this.provider.postStateToWebview() + + break + } + } + } + + private async registerInstance(socket: Socket): Promise { + const instance = this.updateInstance() + + try { + socket.emit(ExtensionSocketEvents.REGISTER, instance) + + console.log( + `[ExtensionManager] emit() -> ${ExtensionSocketEvents.REGISTER}`, + // instance, + ) + } catch (error) { + console.error( + `[ExtensionManager] emit() failed -> ${ExtensionSocketEvents.REGISTER}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + + return + } + } + + private async unregisterInstance(socket: Socket): Promise { + const instance = this.updateInstance() + + try { + socket.emit(ExtensionSocketEvents.UNREGISTER, instance) + + console.log( + `[ExtensionManager] emit() -> ${ExtensionSocketEvents.UNREGISTER}`, + // instance, + ) + } catch (error) { + console.error( + `[ExtensionManager] emit() failed -> ${ExtensionSocketEvents.UNREGISTER}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + } + + private startHeartbeat(socket: Socket): void { + this.stopHeartbeat() + + this.heartbeatInterval = setInterval(async () => { + const instance = this.updateInstance() + + try { + socket.emit(ExtensionSocketEvents.HEARTBEAT, instance) + + // console.log( + // `[ExtensionManager] emit() -> ${ExtensionSocketEvents.HEARTBEAT}`, + // instance, + // ); + } catch (error) { + console.error( + `[ExtensionManager] emit() failed -> ${ExtensionSocketEvents.HEARTBEAT}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + }, HEARTBEAT_INTERVAL_MS) + } + + private stopHeartbeat(): void { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval) + this.heartbeatInterval = null + } + } + + private setupListeners(): void { + const eventMapping = [ + { + from: RooCodeEventName.TaskCreated, + to: ExtensionBridgeEventName.TaskCreated, + }, + { + from: RooCodeEventName.TaskStarted, + to: ExtensionBridgeEventName.TaskStarted, + }, + { + from: RooCodeEventName.TaskCompleted, + to: ExtensionBridgeEventName.TaskCompleted, + }, + { + from: RooCodeEventName.TaskAborted, + to: ExtensionBridgeEventName.TaskAborted, + }, + { + from: RooCodeEventName.TaskFocused, + to: ExtensionBridgeEventName.TaskFocused, + }, + { + from: RooCodeEventName.TaskUnfocused, + to: ExtensionBridgeEventName.TaskUnfocused, + }, + { + from: RooCodeEventName.TaskActive, + to: ExtensionBridgeEventName.TaskActive, + }, + { + from: RooCodeEventName.TaskInteractive, + to: ExtensionBridgeEventName.TaskInteractive, + }, + { + from: RooCodeEventName.TaskResumable, + to: ExtensionBridgeEventName.TaskResumable, + }, + { + from: RooCodeEventName.TaskIdle, + to: ExtensionBridgeEventName.TaskIdle, + }, + ] as const + + const addListener = + (type: ExtensionBridgeEventName) => + async (..._args: unknown[]) => { + this.publishEvent({ + type, + instance: this.updateInstance(), + timestamp: Date.now(), + }) + } + + eventMapping.forEach(({ from, to }) => this.provider.on(from, addListener(to))) + } + + private async publishEvent(message: ExtensionBridgeEvent): Promise { + if (!this.socket) { + console.error("[ExtensionManager] publishEvent -> socket not available") + return false + } + + try { + this.socket.emit(ExtensionSocketEvents.EVENT, message) + + console.log(`[ExtensionManager] emit() -> ${ExtensionSocketEvents.EVENT} ${message.type}`, message) + + return true + } catch (error) { + console.error( + `[ExtensionManager] emit() failed -> ${ExtensionSocketEvents.EVENT}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + + return false + } + } + + private updateInstance(): ExtensionInstance { + const task = this.provider?.getCurrentTask() + const taskHistory = this.provider?.getRecentTasks() ?? [] + + this.extensionInstance = { + ...this.extensionInstance, + appProperties: this.extensionInstance.appProperties ?? this.provider.appProperties, + gitProperties: this.extensionInstance.gitProperties ?? this.provider.gitProperties, + lastHeartbeat: Date.now(), + task: task + ? { + taskId: task.taskId, + taskStatus: task.taskStatus, + ...task.metadata, + } + : { taskId: "", taskStatus: TaskStatus.None }, + taskAsk: task?.taskAsk, + taskHistory, + } + + return this.extensionInstance + } +} diff --git a/packages/cloud/src/bridge/SocketConnectionManager.ts b/packages/cloud/src/bridge/SocketConnectionManager.ts new file mode 100644 index 0000000000..3ba9631fec --- /dev/null +++ b/packages/cloud/src/bridge/SocketConnectionManager.ts @@ -0,0 +1,289 @@ +import { io, type Socket } from "socket.io-client" + +import { ConnectionState, type RetryConfig } from "@roo-code/types" + +export interface SocketConnectionOptions { + url: string + socketOptions: Record + onConnect?: () => void | Promise + onDisconnect?: (reason: string) => void + onReconnect?: (attemptNumber: number) => void | Promise + onError?: (error: Error) => void + logger?: { + log: (message: string, ...args: unknown[]) => void + error: (message: string, ...args: unknown[]) => void + warn: (message: string, ...args: unknown[]) => void + } +} + +export class SocketConnectionManager { + private socket: Socket | null = null + private connectionState: ConnectionState = ConnectionState.DISCONNECTED + private retryAttempt: number = 0 + private retryTimeout: NodeJS.Timeout | null = null + private hasConnectedOnce: boolean = false + + private readonly retryConfig: RetryConfig = { + maxInitialAttempts: 10, + initialDelay: 1_000, + maxDelay: 15_000, + backoffMultiplier: 2, + } + + private readonly CONNECTION_TIMEOUT = 2_000 + private readonly options: SocketConnectionOptions + + constructor(options: SocketConnectionOptions, retryConfig?: Partial) { + this.options = options + + if (retryConfig) { + this.retryConfig = { ...this.retryConfig, ...retryConfig } + } + } + + public async connect(): Promise { + if (this.connectionState === ConnectionState.CONNECTED) { + console.log(`[SocketConnectionManager] Already connected`) + return + } + + if (this.connectionState === ConnectionState.CONNECTING || this.connectionState === ConnectionState.RETRYING) { + console.log(`[SocketConnectionManager] Connection attempt already in progress`) + + return + } + + // Start connection attempt without blocking. + this.startConnectionAttempt() + } + + private async startConnectionAttempt() { + this.retryAttempt = 0 + + try { + await this.connectWithRetry() + } catch (error) { + console.error(`[SocketConnectionManager] Initial connection attempts failed:`, error) + + // If we've never connected successfully, we've exhausted our retry attempts + // The user will need to manually retry or fix the issue + this.connectionState = ConnectionState.FAILED + } + } + + private async connectWithRetry(): Promise { + let delay = this.retryConfig.initialDelay + + while (this.retryAttempt < this.retryConfig.maxInitialAttempts) { + try { + this.connectionState = this.retryAttempt === 0 ? ConnectionState.CONNECTING : ConnectionState.RETRYING + + console.log( + `[SocketConnectionManager] Connection attempt ${this.retryAttempt + 1} / ${this.retryConfig.maxInitialAttempts}`, + ) + + await this.connectSocket() + + console.log(`[SocketConnectionManager] Connected to ${this.options.url}`) + + this.connectionState = ConnectionState.CONNECTED + this.retryAttempt = 0 + + this.clearRetryTimeouts() + + if (this.options.onConnect) { + await this.options.onConnect() + } + + return + } catch (error) { + this.retryAttempt++ + + console.error(`[SocketConnectionManager] Connection attempt ${this.retryAttempt} failed:`, error) + + if (this.socket) { + this.socket.disconnect() + this.socket = null + } + + if (this.retryAttempt >= this.retryConfig.maxInitialAttempts) { + this.connectionState = ConnectionState.FAILED + + throw new Error(`Failed to connect after ${this.retryConfig.maxInitialAttempts} attempts`) + } + + console.log(`[SocketConnectionManager] Waiting ${delay}ms before retry...`) + + await this.delay(delay) + + delay = Math.min(delay * this.retryConfig.backoffMultiplier, this.retryConfig.maxDelay) + } + } + } + + private async connectSocket(): Promise { + return new Promise((resolve, reject) => { + this.socket = io(this.options.url, this.options.socketOptions) + + const connectionTimeout = setTimeout(() => { + console.error(`[SocketConnectionManager] Connection timeout`) + + if (this.connectionState !== ConnectionState.CONNECTED) { + this.socket?.disconnect() + reject(new Error("Connection timeout")) + } + }, this.CONNECTION_TIMEOUT) + + this.socket.on("connect", async () => { + clearTimeout(connectionTimeout) + + const isReconnection = this.hasConnectedOnce + + // If this is a reconnection (not the first connect), treat it as a + // reconnect. + // This handles server restarts where 'reconnect' event might not fire. + if (isReconnection) { + console.log( + `[SocketConnectionManager] Treating connect as reconnection (server may have restarted)`, + ) + + this.connectionState = ConnectionState.CONNECTED + + if (this.options.onReconnect) { + // Call onReconnect to re-register instance. + await this.options.onReconnect(0) + } + } + + this.hasConnectedOnce = true + resolve() + }) + + this.socket.on("disconnect", (reason: string) => { + console.log(`[SocketConnectionManager] Disconnected (reason: ${reason})`) + + this.connectionState = ConnectionState.DISCONNECTED + + if (this.options.onDisconnect) { + this.options.onDisconnect(reason) + } + + // Don't attempt to reconnect if we're manually disconnecting. + const isManualDisconnect = reason === "io client disconnect" + + if (!isManualDisconnect && this.hasConnectedOnce) { + // After successful initial connection, rely entirely on Socket.IO's + // reconnection. + console.log(`[SocketConnectionManager] Socket.IO will handle reconnection (reason: ${reason})`) + } + }) + + // Listen for reconnection attempts. + this.socket.on("reconnect_attempt", (attemptNumber: number) => { + console.log(`[SocketConnectionManager] Socket.IO reconnect attempt:`, { + attemptNumber, + }) + }) + + this.socket.on("reconnect", (attemptNumber: number) => { + console.log(`[SocketConnectionManager] Socket reconnected (attempt: ${attemptNumber})`) + + this.connectionState = ConnectionState.CONNECTED + + if (this.options.onReconnect) { + this.options.onReconnect(attemptNumber) + } + }) + + this.socket.on("reconnect_error", (error: Error) => { + console.error(`[SocketConnectionManager] Socket.IO reconnect error:`, error) + }) + + this.socket.on("reconnect_failed", () => { + console.error(`[SocketConnectionManager] Socket.IO reconnection failed after all attempts`) + + this.connectionState = ConnectionState.FAILED + + // Socket.IO has exhausted its reconnection attempts + // The connection is now permanently failed until manual intervention + }) + + this.socket.on("error", (error) => { + console.error(`[SocketConnectionManager] Socket error:`, error) + + if (this.connectionState !== ConnectionState.CONNECTED) { + clearTimeout(connectionTimeout) + reject(error) + } + + if (this.options.onError) { + this.options.onError(error) + } + }) + + this.socket.on("auth_error", (error) => { + console.error(`[SocketConnectionManager] Authentication error:`, error) + clearTimeout(connectionTimeout) + reject(new Error(error.message || "Authentication failed")) + }) + }) + } + + private delay(ms: number): Promise { + return new Promise((resolve) => { + this.retryTimeout = setTimeout(resolve, ms) + }) + } + + // 1. Custom retry for initial connection attempts. + // 2. Socket.IO's built-in reconnection after successful initial connection. + + private clearRetryTimeouts() { + if (this.retryTimeout) { + clearTimeout(this.retryTimeout) + this.retryTimeout = null + } + } + + public async disconnect(): Promise { + console.log(`[SocketConnectionManager] Disconnecting...`) + + this.clearRetryTimeouts() + + if (this.socket) { + this.socket.removeAllListeners() + this.socket.disconnect() + this.socket = null + } + + this.connectionState = ConnectionState.DISCONNECTED + + console.log(`[SocketConnectionManager] Disconnected`) + } + + public getSocket(): Socket | null { + return this.socket + } + + public getConnectionState(): ConnectionState { + return this.connectionState + } + + public isConnected(): boolean { + return this.connectionState === ConnectionState.CONNECTED && this.socket?.connected === true + } + + public async reconnect(): Promise { + if (this.connectionState === ConnectionState.CONNECTED) { + console.log(`[SocketConnectionManager] Already connected`) + return + } + + console.log(`[SocketConnectionManager] Manual reconnection requested`) + + this.hasConnectedOnce = false + + await this.disconnect() + await this.connect() + } +} diff --git a/packages/cloud/src/bridge/TaskManager.ts b/packages/cloud/src/bridge/TaskManager.ts new file mode 100644 index 0000000000..3940d59f25 --- /dev/null +++ b/packages/cloud/src/bridge/TaskManager.ts @@ -0,0 +1,279 @@ +import type { Socket } from "socket.io-client" + +import { + type ClineMessage, + type TaskEvents, + type TaskLike, + type TaskBridgeCommand, + type TaskBridgeEvent, + RooCodeEventName, + TaskBridgeEventName, + TaskBridgeCommandName, + TaskSocketEvents, +} from "@roo-code/types" + +type TaskEventListener = { + [K in keyof TaskEvents]: (...args: TaskEvents[K]) => void | Promise +}[keyof TaskEvents] + +const TASK_EVENT_MAPPING: Record = { + [TaskBridgeEventName.Message]: RooCodeEventName.Message, + [TaskBridgeEventName.TaskModeSwitched]: RooCodeEventName.TaskModeSwitched, + [TaskBridgeEventName.TaskInteractive]: RooCodeEventName.TaskInteractive, +} + +export class TaskManager { + private subscribedTasks: Map = new Map() + private pendingTasks: Map = new Map() + private socket: Socket | null = null + + private taskListeners: Map> = new Map() + + constructor() {} + + public async onConnect(socket: Socket): Promise { + this.socket = socket + + // Rejoin all subscribed tasks. + for (const taskId of this.subscribedTasks.keys()) { + try { + socket.emit(TaskSocketEvents.JOIN, { taskId }) + + console.log(`[TaskManager] emit() -> ${TaskSocketEvents.JOIN} ${taskId}`) + } catch (error) { + console.error( + `[TaskManager] emit() failed -> ${TaskSocketEvents.JOIN}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + } + + // Subscribe to any pending tasks. + for (const task of this.pendingTasks.values()) { + await this.subscribeToTask(task, socket) + } + + this.pendingTasks.clear() + } + + public onDisconnect(): void { + this.socket = null + } + + public async onReconnect(socket: Socket): Promise { + this.socket = socket + + // Rejoin all subscribed tasks. + for (const taskId of this.subscribedTasks.keys()) { + try { + socket.emit(TaskSocketEvents.JOIN, { taskId }) + + console.log(`[TaskManager] emit() -> ${TaskSocketEvents.JOIN} ${taskId}`) + } catch (error) { + console.error( + `[TaskManager] emit() failed -> ${TaskSocketEvents.JOIN}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + } + } + + public async cleanup(socket: Socket | null): Promise { + if (!socket) { + return + } + + const unsubscribePromises = [] + + for (const taskId of this.subscribedTasks.keys()) { + unsubscribePromises.push(this.unsubscribeFromTask(taskId, socket)) + } + + await Promise.allSettled(unsubscribePromises) + this.subscribedTasks.clear() + this.taskListeners.clear() + this.pendingTasks.clear() + this.socket = null + } + + public addPendingTask(task: TaskLike): void { + this.pendingTasks.set(task.taskId, task) + } + + public async subscribeToTask(task: TaskLike, socket: Socket): Promise { + const taskId = task.taskId + this.subscribedTasks.set(taskId, task) + this.setupListeners(task) + + try { + socket.emit(TaskSocketEvents.JOIN, { taskId }) + console.log(`[TaskManager] emit() -> ${TaskSocketEvents.JOIN} ${taskId}`) + } catch (error) { + console.error( + `[TaskManager] emit() failed -> ${TaskSocketEvents.JOIN}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + } + + public async unsubscribeFromTask(taskId: string, socket: Socket): Promise { + const task = this.subscribedTasks.get(taskId) + + if (task) { + this.removeListeners(task) + this.subscribedTasks.delete(taskId) + } + + try { + socket.emit(TaskSocketEvents.LEAVE, { taskId }) + + console.log(`[TaskManager] emit() -> ${TaskSocketEvents.LEAVE} ${taskId}`) + } catch (error) { + console.error( + `[TaskManager] emit() failed -> ${TaskSocketEvents.LEAVE}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + } + + public handleTaskCommand(message: TaskBridgeCommand): void { + const task = this.subscribedTasks.get(message.taskId) + + if (!task) { + console.error(`[TaskManager#handleTaskCommand] Unable to find task ${message.taskId}`) + + return + } + + switch (message.type) { + case TaskBridgeCommandName.Message: + console.log( + `[TaskManager#handleTaskCommand] ${TaskBridgeCommandName.Message} ${message.taskId} -> submitUserMessage()`, + message, + ) + + task.submitUserMessage(message.payload.text, message.payload.images) + break + case TaskBridgeCommandName.ApproveAsk: + console.log( + `[TaskManager#handleTaskCommand] ${TaskBridgeCommandName.ApproveAsk} ${message.taskId} -> approveAsk()`, + message, + ) + + task.approveAsk(message.payload) + break + case TaskBridgeCommandName.DenyAsk: + console.log( + `[TaskManager#handleTaskCommand] ${TaskBridgeCommandName.DenyAsk} ${message.taskId} -> denyAsk()`, + message, + ) + + task.denyAsk(message.payload) + break + } + } + + private setupListeners(task: TaskLike): void { + if (this.taskListeners.has(task.taskId)) { + console.warn("[TaskManager] Listeners already exist for task, removing old listeners:", task.taskId) + + this.removeListeners(task) + } + + const listeners = new Map() + + const onMessage = ({ action, message }: { action: string; message: ClineMessage }) => { + this.publishEvent({ + type: TaskBridgeEventName.Message, + taskId: task.taskId, + action, + message, + }) + } + + task.on(RooCodeEventName.Message, onMessage) + listeners.set(TaskBridgeEventName.Message, onMessage) + + const onTaskModeSwitched = (mode: string) => { + this.publishEvent({ + type: TaskBridgeEventName.TaskModeSwitched, + taskId: task.taskId, + mode, + }) + } + + task.on(RooCodeEventName.TaskModeSwitched, onTaskModeSwitched) + listeners.set(TaskBridgeEventName.TaskModeSwitched, onTaskModeSwitched) + + const onTaskInteractive = (_taskId: string) => { + this.publishEvent({ + type: TaskBridgeEventName.TaskInteractive, + taskId: task.taskId, + }) + } + + task.on(RooCodeEventName.TaskInteractive, onTaskInteractive) + + listeners.set(TaskBridgeEventName.TaskInteractive, onTaskInteractive) + + this.taskListeners.set(task.taskId, listeners) + + console.log("[TaskManager] Task listeners setup complete for:", task.taskId) + } + + private removeListeners(task: TaskLike): void { + const listeners = this.taskListeners.get(task.taskId) + + if (!listeners) { + return + } + + console.log("[TaskManager] Removing task listeners for:", task.taskId) + + listeners.forEach((listener, eventName) => { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + task.off(TASK_EVENT_MAPPING[eventName], listener as any) + } catch (error) { + console.error( + `[TaskManager] Error removing listener for ${String(eventName)} on task ${task.taskId}:`, + error, + ) + } + }) + + this.taskListeners.delete(task.taskId) + } + + private async publishEvent(message: TaskBridgeEvent): Promise { + if (!this.socket) { + console.error("[TaskManager] publishEvent -> socket not available") + return false + } + + try { + this.socket.emit(TaskSocketEvents.EVENT, message) + + if (message.type !== TaskBridgeEventName.Message) { + console.log( + `[TaskManager] emit() -> ${TaskSocketEvents.EVENT} ${message.taskId} ${message.type}`, + message, + ) + } + + return true + } catch (error) { + console.error( + `[TaskManager] emit() failed -> ${TaskSocketEvents.EVENT}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + + return false + } + } +} diff --git a/packages/cloud/src/config.ts b/packages/cloud/src/config.ts new file mode 100644 index 0000000000..cfff9d0f58 --- /dev/null +++ b/packages/cloud/src/config.ts @@ -0,0 +1,6 @@ +export const PRODUCTION_CLERK_BASE_URL = "https://clerk.roocode.com" +export const PRODUCTION_ROO_CODE_API_URL = "https://app.roocode.com" + +export const getClerkBaseUrl = () => process.env.CLERK_BASE_URL || PRODUCTION_CLERK_BASE_URL + +export const getRooCodeApiUrl = () => process.env.ROO_CODE_API_URL || PRODUCTION_ROO_CODE_API_URL diff --git a/packages/cloud/src/errors.ts b/packages/cloud/src/errors.ts new file mode 100644 index 0000000000..7400f26b39 --- /dev/null +++ b/packages/cloud/src/errors.ts @@ -0,0 +1,42 @@ +export class CloudAPIError extends Error { + constructor( + message: string, + public statusCode?: number, + public responseBody?: unknown, + ) { + super(message) + this.name = "CloudAPIError" + Object.setPrototypeOf(this, CloudAPIError.prototype) + } +} + +export class TaskNotFoundError extends CloudAPIError { + constructor(taskId?: string) { + super(taskId ? `Task '${taskId}' not found` : "Task not found", 404) + this.name = "TaskNotFoundError" + Object.setPrototypeOf(this, TaskNotFoundError.prototype) + } +} + +export class AuthenticationError extends CloudAPIError { + constructor(message = "Authentication required") { + super(message, 401) + this.name = "AuthenticationError" + Object.setPrototypeOf(this, AuthenticationError.prototype) + } +} + +export class NetworkError extends CloudAPIError { + constructor(message = "Network error occurred") { + super(message) + this.name = "NetworkError" + Object.setPrototypeOf(this, NetworkError.prototype) + } +} + +export class InvalidClientTokenError extends Error { + constructor() { + super("Invalid/Expired client token") + Object.setPrototypeOf(this, InvalidClientTokenError.prototype) + } +} diff --git a/packages/cloud/src/importVscode.ts b/packages/cloud/src/importVscode.ts new file mode 100644 index 0000000000..b3c3c94150 --- /dev/null +++ b/packages/cloud/src/importVscode.ts @@ -0,0 +1,49 @@ +/** + * Utility for lazy-loading the VS Code module in environments where it's available. + * This allows the SDK to be used in both VS Code extension and Node.js environments. + * Compatible with both VSCode and Cursor extension hosts. + */ + +let vscodeModule: typeof import("vscode") | undefined + +/** + * Attempts to dynamically import the VS Code module. + * Returns undefined if not running in a VS Code/Cursor extension context. + */ +export async function importVscode(): Promise { + // Check if already loaded + if (vscodeModule) { + return vscodeModule + } + + try { + // Method 1: Check if vscode is available in global scope (common in extension hosts). + if (typeof globalThis !== "undefined" && "acquireVsCodeApi" in globalThis) { + // We're in a webview context, vscode module won't be available. + return undefined + } + + // Method 2: Try to require the module (works in most extension contexts). + if (typeof require !== "undefined") { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + vscodeModule = require("vscode") + + if (vscodeModule) { + return vscodeModule + } + } catch (error) { + console.error("Error loading VS Code module:", error) + // Fall through to dynamic import. + } + } + + // Method 3: Dynamic import (original approach, works in VSCode). + vscodeModule = await import("vscode") + return vscodeModule + } catch (error) { + // Log the original error for debugging. + console.warn("VS Code module not available in this environment:", error) + return undefined + } +} diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts new file mode 100644 index 0000000000..6ba2d3e61e --- /dev/null +++ b/packages/cloud/src/index.ts @@ -0,0 +1,5 @@ +export * from "./config.js" + +export * from "./CloudAPI.js" +export * from "./CloudService.js" +export * from "./bridge/ExtensionBridgeService.js" diff --git a/packages/cloud/src/utils.ts b/packages/cloud/src/utils.ts new file mode 100644 index 0000000000..bd53fe1ce3 --- /dev/null +++ b/packages/cloud/src/utils.ts @@ -0,0 +1,5 @@ +import type { ExtensionContext } from "vscode" + +export function getUserAgent(context?: ExtensionContext): string { + return `Roo-Code ${context?.extension?.packageJSON?.version || "unknown"}` +} diff --git a/packages/cloud/tsconfig.json b/packages/cloud/tsconfig.json new file mode 100644 index 0000000000..01f83fb326 --- /dev/null +++ b/packages/cloud/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@roo-code/config-typescript/base.json", + "compilerOptions": { + "types": ["vitest/globals", "node"], + "outDir": "./dist" + }, + "include": ["src", "scripts", "*.config.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/cloud/vitest.config.ts b/packages/cloud/vitest.config.ts new file mode 100644 index 0000000000..569f167543 --- /dev/null +++ b/packages/cloud/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + globals: true, + environment: "node", + watch: false, + }, + resolve: { + alias: { + vscode: new URL("./src/__mocks__/vscode.ts", import.meta.url).pathname, + }, + }, +}) diff --git a/packages/config-typescript/base.json b/packages/config-typescript/base.json index 0756a8cdef..244a6c40b9 100644 --- a/packages/config-typescript/base.json +++ b/packages/config-typescript/base.json @@ -14,6 +14,7 @@ "resolveJsonModule": true, "skipLibCheck": true, "strict": true, - "target": "ES2022" + "target": "ES2022", + "types": ["node"] } } diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 42d0df387c..1d434ad290 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -8,7 +8,7 @@ "lint": "eslint src --ext=ts --max-warnings=0", "check-types": "tsc --noEmit", "test": "vitest run", - "clean": "rimraf dist .turbo" + "clean": "rimraf .turbo" }, "dependencies": { "@roo-code/types": "workspace:^", diff --git a/packages/types/npm/package.metadata.json b/packages/types/npm/package.metadata.json index 46978350d6..1b1d0d9892 100644 --- a/packages/types/npm/package.metadata.json +++ b/packages/types/npm/package.metadata.json @@ -1,6 +1,6 @@ { "name": "@roo-code/types", - "version": "1.63.0", + "version": "1.64.0", "description": "TypeScript type definitions for Roo Code.", "publishConfig": { "access": "public", diff --git a/packages/types/src/__tests__/ipc.test.ts b/packages/types/src/__tests__/ipc.test.ts index a2b4429356..dd0f7c5cdc 100644 --- a/packages/types/src/__tests__/ipc.test.ts +++ b/packages/types/src/__tests__/ipc.test.ts @@ -1,4 +1,3 @@ -import { describe, it, expect } from "vitest" import { TaskCommandName, taskCommandSchema } from "../ipc.js" describe("IPC Types", () => { diff --git a/packages/types/src/__tests__/provider-settings.test.ts b/packages/types/src/__tests__/provider-settings.test.ts index 93a74f3c42..cedf9a3e2f 100644 --- a/packages/types/src/__tests__/provider-settings.test.ts +++ b/packages/types/src/__tests__/provider-settings.test.ts @@ -1,4 +1,3 @@ -import { describe, it, expect } from "vitest" import { getApiProtocol } from "../provider-settings.js" describe("getApiProtocol", () => { diff --git a/packages/types/src/cloud.ts b/packages/types/src/cloud.ts new file mode 100644 index 0000000000..b80c562fa3 --- /dev/null +++ b/packages/types/src/cloud.ts @@ -0,0 +1,618 @@ +import EventEmitter from "events" + +import { z } from "zod" + +import { RooCodeEventName } from "./events.js" +import { TaskStatus, taskMetadataSchema } from "./task.js" +import { globalSettingsSchema } from "./global-settings.js" +import { providerSettingsWithIdSchema } from "./provider-settings.js" +import { mcpMarketplaceItemSchema } from "./marketplace.js" +import { clineMessageSchema } from "./message.js" +import { staticAppPropertiesSchema, gitPropertiesSchema } from "./telemetry.js" + +/** + * JWTPayload + */ + +export interface JWTPayload { + iss?: string // Issuer (should be 'rcc') + sub?: string // Subject - CloudJob ID for job tokens (t:'cj'), User ID for auth tokens (t:'auth') + exp?: number // Expiration time + iat?: number // Issued at time + nbf?: number // Not before time + v?: number // Version (should be 1) + r?: { + u?: string // User ID (always present in valid tokens) + o?: string // Organization ID (optional - undefined when orgId is null) + t?: string // Token type: 'cj' for job tokens, 'auth' for auth tokens + } +} + +/** + * CloudUserInfo + */ + +export interface CloudUserInfo { + id?: string + name?: string + email?: string + picture?: string + organizationId?: string + organizationName?: string + organizationRole?: string + organizationImageUrl?: string + extensionBridgeEnabled?: boolean +} + +/** + * CloudOrganization + */ + +export interface CloudOrganization { + id: string + name: string + slug?: string + image_url?: string + has_image?: boolean + created_at?: number + updated_at?: number +} + +/** + * CloudOrganizationMembership + */ + +export interface CloudOrganizationMembership { + id: string + organization: CloudOrganization + role: string + permissions?: string[] + created_at?: number + updated_at?: number +} + +/** + * OrganizationAllowList + */ + +export const organizationAllowListSchema = z.object({ + allowAll: z.boolean(), + providers: z.record( + z.object({ + allowAll: z.boolean(), + models: z.array(z.string()).optional(), + }), + ), +}) + +export type OrganizationAllowList = z.infer + +/** + * OrganizationDefaultSettings + */ + +export const organizationDefaultSettingsSchema = globalSettingsSchema + .pick({ + enableCheckpoints: true, + fuzzyMatchThreshold: true, + maxOpenTabsContext: true, + maxReadFileLine: true, + maxWorkspaceFiles: true, + showRooIgnoredFiles: true, + terminalCommandDelay: true, + terminalCompressProgressBar: true, + terminalOutputLineLimit: true, + terminalShellIntegrationDisabled: true, + terminalShellIntegrationTimeout: true, + terminalZshClearEolMark: true, + }) + // Add stronger validations for some fields. + .merge( + z.object({ + maxOpenTabsContext: z.number().int().nonnegative().optional(), + maxReadFileLine: z.number().int().gte(-1).optional(), + maxWorkspaceFiles: z.number().int().nonnegative().optional(), + terminalCommandDelay: z.number().int().nonnegative().optional(), + terminalOutputLineLimit: z.number().int().nonnegative().optional(), + terminalShellIntegrationTimeout: z.number().int().nonnegative().optional(), + }), + ) + +export type OrganizationDefaultSettings = z.infer + +/** + * OrganizationCloudSettings + */ + +export const organizationCloudSettingsSchema = z.object({ + recordTaskMessages: z.boolean().optional(), + enableTaskSharing: z.boolean().optional(), + taskShareExpirationDays: z.number().int().positive().optional(), + allowMembersViewAllTasks: z.boolean().optional(), +}) + +export type OrganizationCloudSettings = z.infer + +/** + * OrganizationSettings + */ + +export const organizationSettingsSchema = z.object({ + version: z.number(), + cloudSettings: organizationCloudSettingsSchema.optional(), + defaultSettings: organizationDefaultSettingsSchema, + allowList: organizationAllowListSchema, + hiddenMcps: z.array(z.string()).optional(), + hideMarketplaceMcps: z.boolean().optional(), + mcps: z.array(mcpMarketplaceItemSchema).optional(), + providerProfiles: z.record(z.string(), providerSettingsWithIdSchema).optional(), +}) + +export type OrganizationSettings = z.infer + +/** + * User Settings Schemas + */ + +export const userFeaturesSchema = z.object({ + roomoteControlEnabled: z.boolean().optional(), +}) + +export type UserFeatures = z.infer + +export const userSettingsConfigSchema = z.object({ + extensionBridgeEnabled: z.boolean().optional(), +}) + +export type UserSettingsConfig = z.infer + +export const userSettingsDataSchema = z.object({ + features: userFeaturesSchema, + settings: userSettingsConfigSchema, + version: z.number(), +}) + +export type UserSettingsData = z.infer + +/** + * Constants + */ + +export const ORGANIZATION_ALLOW_ALL: OrganizationAllowList = { + allowAll: true, + providers: {}, +} as const + +export const ORGANIZATION_DEFAULT: OrganizationSettings = { + version: 0, + cloudSettings: { + recordTaskMessages: true, + enableTaskSharing: true, + taskShareExpirationDays: 30, + allowMembersViewAllTasks: true, + }, + defaultSettings: {}, + allowList: ORGANIZATION_ALLOW_ALL, +} as const + +/** + * ShareVisibility + */ + +export type ShareVisibility = "organization" | "public" + +/** + * ShareResponse + */ + +export const shareResponseSchema = z.object({ + success: z.boolean(), + shareUrl: z.string().optional(), + error: z.string().optional(), + isNewShare: z.boolean().optional(), + manageUrl: z.string().optional(), +}) + +export type ShareResponse = z.infer + +/** + * AuthService + */ + +export type AuthState = "initializing" | "logged-out" | "active-session" | "attempting-session" | "inactive-session" + +export interface AuthService extends EventEmitter { + // Lifecycle + initialize(): Promise + broadcast(): void + + // Authentication methods + login(): Promise + logout(): Promise + handleCallback(code: string | null, state: string | null, organizationId?: string | null): Promise + + // State methods + getState(): AuthState + isAuthenticated(): boolean + hasActiveSession(): boolean + hasOrIsAcquiringActiveSession(): boolean + + // Token and user info + getSessionToken(): string | undefined + getUserInfo(): CloudUserInfo | null + getStoredOrganizationId(): string | null +} + +/** + * AuthServiceEvents + */ + +export interface AuthServiceEvents { + "auth-state-changed": [ + data: { + state: AuthState + previousState: AuthState + }, + ] + "user-info": [data: { userInfo: CloudUserInfo }] +} + +/** + * SettingsService + */ + +/** + * Interface for settings services that provide organization settings + */ +export interface SettingsService { + /** + * Get the organization allow list + * @returns The organization allow list or default if none available + */ + getAllowList(): OrganizationAllowList + + /** + * Get the current organization settings + * @returns The organization settings or undefined if none available + */ + getSettings(): OrganizationSettings | undefined + + /** + * Get the current user settings + * @returns The user settings data or undefined if none available + */ + getUserSettings(): UserSettingsData | undefined + + /** + * Get the current user features + * @returns The user features or empty object if none available + */ + getUserFeatures(): UserFeatures + + /** + * Get the current user settings configuration + * @returns The user settings configuration or empty object if none available + */ + getUserSettingsConfig(): UserSettingsConfig + + /** + * Update user settings with partial configuration + * @param settings Partial user settings configuration to update + * @returns Promise that resolves to true if successful, false otherwise + */ + updateUserSettings(settings: Partial): Promise + + /** + * Dispose of the settings service and clean up resources + */ + dispose(): void +} + +/** + * SettingsServiceEvents + */ + +export interface SettingsServiceEvents { + "settings-updated": [data: Record] +} + +/** + * CloudServiceEvents + */ + +export type CloudServiceEvents = AuthServiceEvents & SettingsServiceEvents + +/** + * ConnectionState + */ + +export enum ConnectionState { + DISCONNECTED = "disconnected", + CONNECTING = "connecting", + CONNECTED = "connected", + RETRYING = "retrying", + FAILED = "failed", +} + +/** + * RetryConfig + */ + +export interface RetryConfig { + maxInitialAttempts: number + initialDelay: number + maxDelay: number + backoffMultiplier: number +} + +/** + * Constants + */ + +export const HEARTBEAT_INTERVAL_MS = 20_000 +export const INSTANCE_TTL_SECONDS = 60 + +/** + * ExtensionTask + */ + +const extensionTaskSchema = z.object({ + taskId: z.string(), + taskStatus: z.nativeEnum(TaskStatus), + ...taskMetadataSchema.shape, +}) + +export type ExtensionTask = z.infer + +/** + * ExtensionInstance + */ + +export const extensionInstanceSchema = z.object({ + instanceId: z.string(), + userId: z.string(), + workspacePath: z.string(), + appProperties: staticAppPropertiesSchema, + gitProperties: gitPropertiesSchema.optional(), + lastHeartbeat: z.coerce.number(), + task: extensionTaskSchema, + taskAsk: clineMessageSchema.optional(), + taskHistory: z.array(z.string()), +}) + +export type ExtensionInstance = z.infer + +/** + * ExtensionBridgeEvent + */ + +export enum ExtensionBridgeEventName { + TaskCreated = RooCodeEventName.TaskCreated, + TaskStarted = RooCodeEventName.TaskStarted, + TaskCompleted = RooCodeEventName.TaskCompleted, + TaskAborted = RooCodeEventName.TaskAborted, + TaskFocused = RooCodeEventName.TaskFocused, + TaskUnfocused = RooCodeEventName.TaskUnfocused, + TaskActive = RooCodeEventName.TaskActive, + TaskInteractive = RooCodeEventName.TaskInteractive, + TaskResumable = RooCodeEventName.TaskResumable, + TaskIdle = RooCodeEventName.TaskIdle, + + InstanceRegistered = "instance_registered", + InstanceUnregistered = "instance_unregistered", + HeartbeatUpdated = "heartbeat_updated", +} + +export const extensionBridgeEventSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal(ExtensionBridgeEventName.TaskCreated), + instance: extensionInstanceSchema, + timestamp: z.number(), + }), + z.object({ + type: z.literal(ExtensionBridgeEventName.TaskStarted), + instance: extensionInstanceSchema, + timestamp: z.number(), + }), + z.object({ + type: z.literal(ExtensionBridgeEventName.TaskCompleted), + instance: extensionInstanceSchema, + timestamp: z.number(), + }), + z.object({ + type: z.literal(ExtensionBridgeEventName.TaskAborted), + instance: extensionInstanceSchema, + timestamp: z.number(), + }), + z.object({ + type: z.literal(ExtensionBridgeEventName.TaskFocused), + instance: extensionInstanceSchema, + timestamp: z.number(), + }), + z.object({ + type: z.literal(ExtensionBridgeEventName.TaskUnfocused), + instance: extensionInstanceSchema, + timestamp: z.number(), + }), + z.object({ + type: z.literal(ExtensionBridgeEventName.TaskActive), + instance: extensionInstanceSchema, + timestamp: z.number(), + }), + z.object({ + type: z.literal(ExtensionBridgeEventName.TaskInteractive), + instance: extensionInstanceSchema, + timestamp: z.number(), + }), + z.object({ + type: z.literal(ExtensionBridgeEventName.TaskResumable), + instance: extensionInstanceSchema, + timestamp: z.number(), + }), + z.object({ + type: z.literal(ExtensionBridgeEventName.TaskIdle), + instance: extensionInstanceSchema, + timestamp: z.number(), + }), + z.object({ + type: z.literal(ExtensionBridgeEventName.InstanceRegistered), + instance: extensionInstanceSchema, + timestamp: z.number(), + }), + z.object({ + type: z.literal(ExtensionBridgeEventName.InstanceUnregistered), + instance: extensionInstanceSchema, + timestamp: z.number(), + }), + z.object({ + type: z.literal(ExtensionBridgeEventName.HeartbeatUpdated), + instance: extensionInstanceSchema, + timestamp: z.number(), + }), +]) + +export type ExtensionBridgeEvent = z.infer + +/** + * ExtensionBridgeCommand + */ + +export enum ExtensionBridgeCommandName { + StartTask = "start_task", + StopTask = "stop_task", + ResumeTask = "resume_task", +} + +export const extensionBridgeCommandSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal(ExtensionBridgeCommandName.StartTask), + instanceId: z.string(), + payload: z.object({ + text: z.string(), + images: z.array(z.string()).optional(), + }), + timestamp: z.number(), + }), + z.object({ + type: z.literal(ExtensionBridgeCommandName.StopTask), + instanceId: z.string(), + payload: z.object({ taskId: z.string() }), + timestamp: z.number(), + }), + z.object({ + type: z.literal(ExtensionBridgeCommandName.ResumeTask), + instanceId: z.string(), + payload: z.object({ + taskId: z.string(), + }), + timestamp: z.number(), + }), +]) + +export type ExtensionBridgeCommand = z.infer + +/** + * TaskBridgeEvent + */ + +export enum TaskBridgeEventName { + Message = RooCodeEventName.Message, + TaskModeSwitched = RooCodeEventName.TaskModeSwitched, + TaskInteractive = RooCodeEventName.TaskInteractive, +} + +export const taskBridgeEventSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal(TaskBridgeEventName.Message), + taskId: z.string(), + action: z.string(), + message: clineMessageSchema, + }), + z.object({ + type: z.literal(TaskBridgeEventName.TaskModeSwitched), + taskId: z.string(), + mode: z.string(), + }), + z.object({ + type: z.literal(TaskBridgeEventName.TaskInteractive), + taskId: z.string(), + }), +]) + +export type TaskBridgeEvent = z.infer + +/** + * TaskBridgeCommand + */ + +export enum TaskBridgeCommandName { + Message = "message", + ApproveAsk = "approve_ask", + DenyAsk = "deny_ask", +} + +export const taskBridgeCommandSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal(TaskBridgeCommandName.Message), + taskId: z.string(), + payload: z.object({ + text: z.string(), + images: z.array(z.string()).optional(), + }), + timestamp: z.number(), + }), + z.object({ + type: z.literal(TaskBridgeCommandName.ApproveAsk), + taskId: z.string(), + payload: z.object({ + text: z.string().optional(), + images: z.array(z.string()).optional(), + }), + timestamp: z.number(), + }), + z.object({ + type: z.literal(TaskBridgeCommandName.DenyAsk), + taskId: z.string(), + payload: z.object({ + text: z.string().optional(), + images: z.array(z.string()).optional(), + }), + timestamp: z.number(), + }), +]) + +export type TaskBridgeCommand = z.infer + +/** + * ExtensionSocketEvents + */ + +export const ExtensionSocketEvents = { + CONNECTED: "extension:connected", + + REGISTER: "extension:register", + UNREGISTER: "extension:unregister", + + HEARTBEAT: "extension:heartbeat", + + EVENT: "extension:event", // event from extension instance + RELAYED_EVENT: "extension:relayed_event", // relay from server + + COMMAND: "extension:command", // command from user + RELAYED_COMMAND: "extension:relayed_command", // relay from server +} as const + +/** + * TaskSocketEvents + */ + +export const TaskSocketEvents = { + JOIN: "task:join", + LEAVE: "task:leave", + + EVENT: "task:event", // event from extension task + RELAYED_EVENT: "task:relayed_event", // relay from server + + COMMAND: "task:command", // command from user + RELAYED_COMMAND: "task:relayed_command", // relay from server +} as const diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index b151067d1d..38b8c750f7 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,4 +1,5 @@ export * from "./api.js" +export * from "./cloud.js" export * from "./codebase-index.js" export * from "./events.js" export * from "./experiment.js" diff --git a/packages/types/src/providers/__tests__/claude-code.spec.ts b/packages/types/src/providers/__tests__/claude-code.spec.ts index 8f997068f2..a73912d44a 100644 --- a/packages/types/src/providers/__tests__/claude-code.spec.ts +++ b/packages/types/src/providers/__tests__/claude-code.spec.ts @@ -1,4 +1,3 @@ -import { describe, test, expect } from "vitest" import { convertModelNameForVertex, getClaudeCodeModelId } from "../claude-code.js" describe("convertModelNameForVertex", () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e01247290..f9ccd8512a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,8 +71,8 @@ importers: specifier: ^4.19.3 version: 4.19.4 turbo: - specifier: ^2.5.3 - version: 2.5.4 + specifier: ^2.5.6 + version: 2.5.6 typescript: specifier: ^5.4.5 version: 5.8.3 @@ -285,8 +285,8 @@ importers: specifier: ^8.6.0 version: 8.6.0(react@18.3.1) framer-motion: - specifier: ^12.15.0 - version: 12.16.0(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 12.15.0 + version: 12.15.0(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) lucide-react: specifier: ^0.518.0 version: 0.518.0(react@18.3.1) @@ -371,6 +371,46 @@ importers: specifier: ^3.2.3 version: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.57)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + packages/cloud: + dependencies: + '@roo-code/types': + specifier: workspace:^ + version: link:../types + ioredis: + specifier: ^5.6.1 + version: 5.6.1 + jwt-decode: + specifier: ^4.0.0 + version: 4.0.0 + p-wait-for: + specifier: ^5.0.2 + version: 5.0.2 + socket.io-client: + specifier: ^4.8.1 + version: 4.8.1 + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + '@roo-code/config-eslint': + specifier: workspace:^ + version: link:../config-eslint + '@roo-code/config-typescript': + specifier: workspace:^ + version: link:../config-typescript + '@types/node': + specifier: ^24.1.0 + version: 24.2.1 + '@types/vscode': + specifier: ^1.102.0 + version: 1.103.0 + globals: + specifier: ^16.3.0 + version: 16.3.0 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + packages/config-eslint: devDependencies: '@eslint/js': @@ -396,7 +436,7 @@ importers: version: 5.2.0(eslint@9.27.0(jiti@2.4.2)) eslint-plugin-turbo: specifier: ^2.4.4 - version: 2.5.3(eslint@9.27.0(jiti@2.4.2))(turbo@2.5.4) + version: 2.5.3(eslint@9.27.0(jiti@2.4.2))(turbo@2.5.6) globals: specifier: ^16.0.0 version: 16.1.0 @@ -578,14 +618,14 @@ importers: specifier: ^1.9.18 version: 1.9.18(zod@3.25.61) '@modelcontextprotocol/sdk': - specifier: ^1.9.0 + specifier: 1.12.0 version: 1.12.0 '@qdrant/js-client-rest': specifier: ^1.14.0 version: 1.14.0(typescript@5.8.3) '@roo-code/cloud': - specifier: ^0.29.0 - version: 0.29.0 + specifier: workspace:^ + version: link:../packages/cloud '@roo-code/ipc': specifier: workspace:^ version: link:../packages/ipc @@ -595,9 +635,6 @@ importers: '@roo-code/types': specifier: workspace:^ version: link:../packages/types - '@types/lodash.debounce': - specifier: ^4.0.9 - version: 4.0.9 '@vscode/codicons': specifier: ^0.0.36 version: 0.0.36 @@ -803,6 +840,9 @@ importers: '@types/glob': specifier: ^8.1.0 version: 8.1.0 + '@types/lodash.debounce': + specifier: ^4.0.9 + version: 4.0.9 '@types/mocha': specifier: ^10.0.10 version: 10.0.10 @@ -3346,12 +3386,6 @@ packages: cpu: [x64] os: [win32] - '@roo-code/cloud@0.29.0': - resolution: {integrity: sha512-fXN0mdkd5GezpVrCspe6atUkwvSk5D4wF80g+lc8E3aPVqEAozoI97kHNulRChGlBw7UIdd5xxbr1Z8Jtn+S/Q==} - - '@roo-code/types@1.63.0': - resolution: {integrity: sha512-pX8ftkDq1CySBbkUTIW9/QEG52ttFT/kl0ID286l0L3W22wpGRUct6PCedNI9kLDM4s5sxaUeZx7b3rUChikkw==} - '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -4225,6 +4259,9 @@ packages: '@types/vscode@1.100.0': resolution: {integrity: sha512-4uNyvzHoraXEeCamR3+fzcBlh7Afs4Ifjs4epINyUX/jvdk0uzLnwiDY35UKDKnkCHP5Nu3dljl2H8lR6s+rQw==} + '@types/vscode@1.103.0': + resolution: {integrity: sha512-o4hanZAQdNfsKecexq9L3eHICd0AAvdbLk6hA60UzGXbGH/q8b/9xv2RgR7vV3ZcHuyKVq7b37IGd/+gM4Tu+Q==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -6129,8 +6166,8 @@ packages: fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} - framer-motion@12.16.0: - resolution: {integrity: sha512-xryrmD4jSBQrS2IkMdcTmiS4aSKckbS7kLDCuhUn9110SQKG1w3zlq1RTqCblewg+ZYe+m3sdtzQA6cRwo5g8Q==} + framer-motion@12.15.0: + resolution: {integrity: sha512-XKg/LnKExdLGugZrDILV7jZjI599785lDIJZLxMiiIFidCsy0a4R2ZEf+Izm67zyOuJgQYTHOmodi7igQsw3vg==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -9470,38 +9507,38 @@ packages: resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} - turbo-darwin-64@2.5.4: - resolution: {integrity: sha512-ah6YnH2dErojhFooxEzmvsoZQTMImaruZhFPfMKPBq8sb+hALRdvBNLqfc8NWlZq576FkfRZ/MSi4SHvVFT9PQ==} + turbo-darwin-64@2.5.6: + resolution: {integrity: sha512-3C1xEdo4aFwMJAPvtlPqz1Sw/+cddWIOmsalHFMrsqqydcptwBfu26WW2cDm3u93bUzMbBJ8k3zNKFqxJ9ei2A==} cpu: [x64] os: [darwin] - turbo-darwin-arm64@2.5.4: - resolution: {integrity: sha512-2+Nx6LAyuXw2MdXb7pxqle3MYignLvS7OwtsP9SgtSBaMlnNlxl9BovzqdYAgkUW3AsYiQMJ/wBRb7d+xemM5A==} + turbo-darwin-arm64@2.5.6: + resolution: {integrity: sha512-LyiG+rD7JhMfYwLqB6k3LZQtYn8CQQUePbpA8mF/hMLPAekXdJo1g0bUPw8RZLwQXUIU/3BU7tXENvhSGz5DPA==} cpu: [arm64] os: [darwin] - turbo-linux-64@2.5.4: - resolution: {integrity: sha512-5May2kjWbc8w4XxswGAl74GZ5eM4Gr6IiroqdLhXeXyfvWEdm2mFYCSWOzz0/z5cAgqyGidF1jt1qzUR8hTmOA==} + turbo-linux-64@2.5.6: + resolution: {integrity: sha512-GOcUTT0xiT/pSnHL4YD6Yr3HreUhU8pUcGqcI2ksIF9b2/r/kRHwGFcsHgpG3+vtZF/kwsP0MV8FTlTObxsYIA==} cpu: [x64] os: [linux] - turbo-linux-arm64@2.5.4: - resolution: {integrity: sha512-/2yqFaS3TbfxV3P5yG2JUI79P7OUQKOUvAnx4MV9Bdz6jqHsHwc9WZPpO4QseQm+NvmgY6ICORnoVPODxGUiJg==} + turbo-linux-arm64@2.5.6: + resolution: {integrity: sha512-10Tm15bruJEA3m0V7iZcnQBpObGBcOgUcO+sY7/2vk1bweW34LMhkWi8svjV9iDF68+KJDThnYDlYE/bc7/zzQ==} cpu: [arm64] os: [linux] - turbo-windows-64@2.5.4: - resolution: {integrity: sha512-EQUO4SmaCDhO6zYohxIjJpOKRN3wlfU7jMAj3CgcyTPvQR/UFLEKAYHqJOnJtymbQmiiM/ihX6c6W6Uq0yC7mA==} + turbo-windows-64@2.5.6: + resolution: {integrity: sha512-FyRsVpgaj76It0ludwZsNN40ytHN+17E4PFJyeliBEbxrGTc5BexlXVpufB7XlAaoaZVxbS6KT8RofLfDRyEPg==} cpu: [x64] os: [win32] - turbo-windows-arm64@2.5.4: - resolution: {integrity: sha512-oQ8RrK1VS8lrxkLriotFq+PiF7iiGgkZtfLKF4DDKsmdbPo0O9R2mQxm7jHLuXraRCuIQDWMIw6dpcr7Iykf4A==} + turbo-windows-arm64@2.5.6: + resolution: {integrity: sha512-j/tWu8cMeQ7HPpKri6jvKtyXg9K1gRyhdK4tKrrchH8GNHscPX/F71zax58yYtLRWTiK04zNzPcUJuoS0+v/+Q==} cpu: [arm64] os: [win32] - turbo@2.5.4: - resolution: {integrity: sha512-kc8ZibdRcuWUG1pbYSBFWqmIjynlD8Lp7IB6U3vIzvOv9VG+6Sp8bzyeBWE3Oi8XV5KsQrznyRTBPvrf99E4mA==} + turbo@2.5.6: + resolution: {integrity: sha512-gxToHmi9oTBNB05UjUsrWf0OyN5ZXtD0apOarC1KIx232Vp3WimRNy3810QzeNSgyD5rsaIDXlxlbnOzlouo+w==} hasBin: true turndown@7.2.0: @@ -11424,8 +11461,8 @@ snapshots: '@modelcontextprotocol/sdk': 1.12.0 google-auth-library: 9.15.1 ws: 8.18.2 - zod: 3.25.61 - zod-to-json-schema: 3.24.5(zod@3.25.61) + zod: 3.25.76 + zod-to-json-schema: 3.24.5(zod@3.25.76) transitivePeerDependencies: - bufferutil - encoding @@ -11684,8 +11721,8 @@ snapshots: '@lmstudio/lms-isomorphic': 0.4.5 chalk: 4.1.2 jsonschema: 1.5.0 - zod: 3.25.61 - zod-to-json-schema: 3.24.5(zod@3.25.61) + zod: 3.25.76 + zod-to-json-schema: 3.24.5(zod@3.25.76) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -11751,8 +11788,8 @@ snapshots: express-rate-limit: 7.5.0(express@5.1.0) pkce-challenge: 5.0.0 raw-body: 3.0.0 - zod: 3.25.61 - zod-to-json-schema: 3.24.5(zod@3.25.61) + zod: 3.25.76 + zod-to-json-schema: 3.24.5(zod@3.25.76) transitivePeerDependencies: - supports-color @@ -12732,23 +12769,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.40.2': optional: true - '@roo-code/cloud@0.29.0': - dependencies: - '@roo-code/types': 1.63.0 - ioredis: 5.6.1 - jwt-decode: 4.0.0 - p-wait-for: 5.0.2 - socket.io-client: 4.8.1 - zod: 3.25.76 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - '@roo-code/types@1.63.0': - dependencies: - zod: 3.25.76 - '@sec-ant/readable-stream@0.4.1': {} '@sevinf/maybe@0.5.0': {} @@ -13799,6 +13819,8 @@ snapshots: '@types/vscode@1.100.0': {} + '@types/vscode@1.103.0': {} + '@types/ws@8.18.1': dependencies: '@types/node': 24.2.1 @@ -15551,11 +15573,11 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-turbo@2.5.3(eslint@9.27.0(jiti@2.4.2))(turbo@2.5.4): + eslint-plugin-turbo@2.5.3(eslint@9.27.0(jiti@2.4.2))(turbo@2.5.6): dependencies: dotenv: 16.0.3 eslint: 9.27.0(jiti@2.4.2) - turbo: 2.5.4 + turbo: 2.5.6 eslint-scope@8.3.0: dependencies: @@ -16026,7 +16048,7 @@ snapshots: fraction.js@4.3.7: {} - framer-motion@12.16.0(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + framer-motion@12.15.0(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: motion-dom: 12.16.0 motion-utils: 12.12.1 @@ -19977,32 +19999,32 @@ snapshots: tunnel@0.0.6: {} - turbo-darwin-64@2.5.4: + turbo-darwin-64@2.5.6: optional: true - turbo-darwin-arm64@2.5.4: + turbo-darwin-arm64@2.5.6: optional: true - turbo-linux-64@2.5.4: + turbo-linux-64@2.5.6: optional: true - turbo-linux-arm64@2.5.4: + turbo-linux-arm64@2.5.6: optional: true - turbo-windows-64@2.5.4: + turbo-windows-64@2.5.6: optional: true - turbo-windows-arm64@2.5.4: + turbo-windows-arm64@2.5.6: optional: true - turbo@2.5.4: + turbo@2.5.6: optionalDependencies: - turbo-darwin-64: 2.5.4 - turbo-darwin-arm64: 2.5.4 - turbo-linux-64: 2.5.4 - turbo-linux-arm64: 2.5.4 - turbo-windows-64: 2.5.4 - turbo-windows-arm64: 2.5.4 + turbo-darwin-64: 2.5.6 + turbo-darwin-arm64: 2.5.6 + turbo-linux-64: 2.5.6 + turbo-linux-arm64: 2.5.6 + turbo-windows-64: 2.5.6 + turbo-windows-arm64: 2.5.6 turndown@7.2.0: dependencies: @@ -20836,6 +20858,10 @@ snapshots: dependencies: zod: 3.25.61 + zod-to-json-schema@3.24.5(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod-to-ts@1.2.0(typescript@5.8.3)(zod@3.25.61): dependencies: typescript: 5.8.3 diff --git a/scripts/link-packages.ts b/scripts/link-packages.ts deleted file mode 100644 index 97aeebd6b5..0000000000 --- a/scripts/link-packages.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { spawn, execSync, type ChildProcess } from "child_process" -import * as path from "path" -import * as fs from "fs" -import { fileURLToPath } from "url" -import { glob } from "glob" - -// @ts-expect-error - TS1470: We only run this script with tsx so it will never -// compile to CJS and it's safe to ignore this tsc error. -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -interface PackageConfig { - readonly name: string - readonly sourcePath: string - readonly targetPaths: readonly string[] - readonly replacePath?: string - readonly npmPath: string - readonly watchCommand?: string - readonly watchOutput?: { - readonly start: string[] - readonly stop: string[] - } -} - -interface Config { - readonly packages: readonly PackageConfig[] -} - -interface WatcherResult { - child: ChildProcess -} - -interface NpmPackage { - name?: string - version?: string - type: "module" - dependencies: Record - main: string - module: string - types: string - exports: { - ".": { - types: string - import: string - require: { - types: string - default: string - } - } - } - files: string[] -} - -const config: Config = { - packages: [ - { - name: "@roo-code/cloud", - sourcePath: "../Roo-Code-Cloud/packages/sdk", - targetPaths: ["src/node_modules/@roo-code/cloud"] as const, - replacePath: "node_modules/.pnpm/@roo-code+cloud*", - npmPath: "npm", - watchCommand: "pnpm build:development:watch", - watchOutput: { - start: ["CLI Building", "CLI Change detected"], - stop: ["DTS ⚡️ Build success"], - }, - }, - ], -} as const - -const args = process.argv.slice(2) -const packageName = args.find((arg) => !arg.startsWith("--")) -const watchMode = !args.includes("--no-watch") -const unlink = args.includes("--unlink") - -const packages: readonly PackageConfig[] = packageName - ? config.packages.filter((p) => p.name === packageName) - : config.packages - -if (!packages.length) { - console.error(`Package '${packageName}' not found`) - process.exit(1) -} - -function pathExists(filePath: string): boolean { - try { - fs.accessSync(filePath) - return true - } catch { - return false - } -} - -function copyRecursiveSync(src: string, dest: string): void { - const exists = pathExists(src) - - if (!exists) { - return - } - - const stats = fs.statSync(src) - const isDirectory = stats.isDirectory() - - if (isDirectory) { - if (!pathExists(dest)) { - fs.mkdirSync(dest, { recursive: true }) - } - - const children = fs.readdirSync(src) - - children.forEach((childItemName) => { - copyRecursiveSync(path.join(src, childItemName), path.join(dest, childItemName)) - }) - } else { - fs.copyFileSync(src, dest) - } -} - -function generateNpmPackageJson(sourcePath: string, npmPath: string): string { - const npmDir = path.join(sourcePath, npmPath) - const npmPackagePath = path.join(npmDir, "package.json") - const npmMetadataPath = path.join(npmDir, "package.metadata.json") - const monorepoPackagePath = path.join(sourcePath, "package.json") - - if (pathExists(npmPackagePath)) { - return npmPackagePath - } - - if (!pathExists(npmMetadataPath)) { - throw new Error(`No package.metadata.json found in ${npmDir}`) - } - - const monorepoPackageContent = fs.readFileSync(monorepoPackagePath, "utf8") - - const monorepoPackage = JSON.parse(monorepoPackageContent) as { - dependencies?: Record - } - - const npmMetadataContent = fs.readFileSync(npmMetadataPath, "utf8") - const npmMetadata = JSON.parse(npmMetadataContent) as Partial - - const npmPackage: NpmPackage = { - ...npmMetadata, - type: "module", - dependencies: monorepoPackage.dependencies || {}, - main: "./dist/index.cjs", - module: "./dist/index.js", - types: "./dist/index.d.ts", - exports: { - ".": { - types: "./dist/index.d.ts", - import: "./dist/index.js", - require: { - types: "./dist/index.d.cts", - default: "./dist/index.cjs", - }, - }, - }, - files: ["dist"], - } - - fs.writeFileSync(npmPackagePath, JSON.stringify(npmPackage, null, 2) + "\n") - - return npmPackagePath -} - -function linkPackage(pkg: PackageConfig): void { - const sourcePath = path.resolve(__dirname, "..", pkg.sourcePath) - - if (!pathExists(sourcePath)) { - console.error(`❌ Source not found: ${sourcePath}`) - process.exit(1) - } - - generateNpmPackageJson(sourcePath, pkg.npmPath) - - for (const currentTargetPath of pkg.targetPaths) { - const targetPath = path.resolve(__dirname, "..", currentTargetPath) - - if (pathExists(targetPath)) { - fs.rmSync(targetPath, { recursive: true, force: true }) - } - - const parentDir = path.dirname(targetPath) - fs.mkdirSync(parentDir, { recursive: true }) - - const linkSource = pkg.npmPath ? path.join(sourcePath, pkg.npmPath) : sourcePath - copyRecursiveSync(linkSource, targetPath) - } -} - -function unlinkPackage(pkg: PackageConfig): void { - for (const currentTargetPath of pkg.targetPaths) { - const targetPath = path.resolve(__dirname, "..", currentTargetPath) - - if (pathExists(targetPath)) { - fs.rmSync(targetPath, { recursive: true, force: true }) - console.log(`🗑️ Removed ${pkg.name} from ${currentTargetPath}`) - } - } -} - -function startWatch(pkg: PackageConfig): WatcherResult { - if (!pkg.watchCommand) { - throw new Error(`Package ${pkg.name} has no watch command configured`) - } - - const commandParts = pkg.watchCommand.split(" ") - const [cmd, ...args] = commandParts - - if (!cmd) { - throw new Error(`Invalid watch command for ${pkg.name}`) - } - - console.log(`👀 Watching for changes to ${pkg.sourcePath} with ${cmd} ${args.join(" ")}`) - - const child = spawn(cmd, args, { - cwd: path.resolve(__dirname, "..", pkg.sourcePath), - stdio: "pipe", - shell: true, - }) - - let debounceTimer: NodeJS.Timeout | null = null - - const DEBOUNCE_DELAY = 500 - - if (child.stdout) { - child.stdout.on("data", (data: Buffer) => { - const output = data.toString() - - const isStarting = pkg.watchOutput?.start.some((start) => output.includes(start)) - - const isDone = pkg.watchOutput?.stop.some((stop) => output.includes(stop)) - - if (isStarting) { - console.log(`🔨 Building ${pkg.name}...`) - - if (debounceTimer) { - clearTimeout(debounceTimer) - debounceTimer = null - } - } - - if (isDone) { - console.log(`✅ Built ${pkg.name}`) - - if (debounceTimer) { - clearTimeout(debounceTimer) - } - - debounceTimer = setTimeout(() => { - linkPackage(pkg) - - console.log(`♻️ Copied ${pkg.name} to ${pkg.targetPaths.length} paths\n`) - - debounceTimer = null - }, DEBOUNCE_DELAY) - } - }) - } - - if (child.stderr) { - child.stderr.on("data", (data: Buffer) => { - console.log(`❌ "${data.toString()}"`) - }) - } - - return { child } -} - -function main(): void { - if (unlink) { - packages.forEach(unlinkPackage) - - console.log("\n📦 Restoring npm packages...") - - try { - execSync("pnpm install", { cwd: __dirname, stdio: "ignore" }) - console.log("✅ npm packages restored") - } catch (error) { - console.error(`❌ Failed to restore packages: ${error instanceof Error ? error.message : String(error)}`) - - console.log(" Run 'pnpm install' manually if needed") - } - } else { - packages.forEach((pkg) => { - linkPackage(pkg) - - if (pkg.replacePath) { - const replacePattern = path.resolve(__dirname, "..", pkg.replacePath) - - try { - const matchedPaths = glob.sync(replacePattern) - - if (matchedPaths.length > 0) { - matchedPaths.forEach((matchedPath: string) => { - if (pathExists(matchedPath)) { - fs.rmSync(matchedPath, { recursive: true, force: true }) - console.log(`🗑️ Removed ${pkg.name} from ${matchedPath}`) - } - }) - } else { - if (pathExists(replacePattern)) { - fs.rmSync(replacePattern, { recursive: true, force: true }) - console.log(`🗑️ Removed ${pkg.name} from ${replacePattern}`) - } - } - } catch (error) { - console.error( - `❌ Error processing replace path: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } - }) - - if (watchMode) { - const packagesWithWatch = packages.filter( - (pkg): pkg is PackageConfig & { watchCommand: string } => pkg.watchCommand !== undefined, - ) - - const watchers = packagesWithWatch.map(startWatch) - - if (watchers.length > 0) { - process.on("SIGINT", () => { - console.log("\n👋 Stopping watchers...") - - watchers.forEach((w) => { - if (w.child) { - w.child.kill() - } - }) - - process.exit(0) - }) - } - } - } -} - -main() diff --git a/src/__tests__/command-integration.spec.ts b/src/__tests__/command-integration.spec.ts index 66621dbb3a..59427415f7 100644 --- a/src/__tests__/command-integration.spec.ts +++ b/src/__tests__/command-integration.spec.ts @@ -1,7 +1,7 @@ -import { describe, it, expect } from "vitest" -import { getCommands, getCommand, getCommandNames } from "../services/command/commands" import * as path from "path" +import { getCommands, getCommand, getCommandNames } from "../services/command/commands" + describe("Command Integration Tests", () => { const testWorkspaceDir = path.join(__dirname, "../../") diff --git a/src/__tests__/command-mentions.spec.ts b/src/__tests__/command-mentions.spec.ts index cb55eb84e7..7ddaf3d092 100644 --- a/src/__tests__/command-mentions.spec.ts +++ b/src/__tests__/command-mentions.spec.ts @@ -1,4 +1,3 @@ -import { describe, it, expect, beforeEach, vi } from "vitest" import { parseMentions } from "../core/mentions" import { UrlContentFetcher } from "../services/browser/UrlContentFetcher" import { getCommand } from "../services/command/commands" diff --git a/src/__tests__/commands.spec.ts b/src/__tests__/commands.spec.ts index 9401050062..e3d9e81c23 100644 --- a/src/__tests__/commands.spec.ts +++ b/src/__tests__/commands.spec.ts @@ -1,4 +1,3 @@ -import { describe, it, expect } from "vitest" import { getCommands, getCommand, diff --git a/src/api/providers/__tests__/bedrock-error-handling.spec.ts b/src/api/providers/__tests__/bedrock-error-handling.spec.ts index 53e582c25b..4f9328c2f6 100644 --- a/src/api/providers/__tests__/bedrock-error-handling.spec.ts +++ b/src/api/providers/__tests__/bedrock-error-handling.spec.ts @@ -1,5 +1,3 @@ -import { vi } from "vitest" - // Mock BedrockRuntimeClient and commands const mockSend = vi.fn() diff --git a/src/api/providers/__tests__/cerebras.spec.ts b/src/api/providers/__tests__/cerebras.spec.ts index 2b7668435f..ff4d50e641 100644 --- a/src/api/providers/__tests__/cerebras.spec.ts +++ b/src/api/providers/__tests__/cerebras.spec.ts @@ -1,5 +1,3 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" - // Mock i18n vi.mock("../../i18n", () => ({ t: vi.fn((key: string, params?: Record) => { diff --git a/src/api/providers/__tests__/claude-code-caching.spec.ts b/src/api/providers/__tests__/claude-code-caching.spec.ts index b7f7ff852a..96b19964fc 100644 --- a/src/api/providers/__tests__/claude-code-caching.spec.ts +++ b/src/api/providers/__tests__/claude-code-caching.spec.ts @@ -1,10 +1,10 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" +import type { Anthropic } from "@anthropic-ai/sdk" + import { ClaudeCodeHandler } from "../claude-code" import { runClaudeCode } from "../../../integrations/claude-code/run" import type { ApiHandlerOptions } from "../../../shared/api" import type { ClaudeCodeMessage } from "../../../integrations/claude-code/types" import type { ApiStreamUsageChunk } from "../../transform/stream" -import type { Anthropic } from "@anthropic-ai/sdk" // Mock the runClaudeCode function vi.mock("../../../integrations/claude-code/run", () => ({ diff --git a/src/api/providers/__tests__/claude-code.spec.ts b/src/api/providers/__tests__/claude-code.spec.ts index af1bf809c4..8d154d794f 100644 --- a/src/api/providers/__tests__/claude-code.spec.ts +++ b/src/api/providers/__tests__/claude-code.spec.ts @@ -1,4 +1,3 @@ -import { describe, test, expect, vi, beforeEach } from "vitest" import { ClaudeCodeHandler } from "../claude-code" import { ApiHandlerOptions } from "../../../shared/api" import { ClaudeCodeMessage } from "../../../integrations/claude-code/types" diff --git a/src/api/providers/__tests__/constants.spec.ts b/src/api/providers/__tests__/constants.spec.ts index 8a04416d72..ba28b44e68 100644 --- a/src/api/providers/__tests__/constants.spec.ts +++ b/src/api/providers/__tests__/constants.spec.ts @@ -1,6 +1,5 @@ // npx vitest run src/api/providers/__tests__/constants.spec.ts -import { describe, it, expect } from "vitest" import { DEFAULT_HEADERS } from "../constants" import { Package } from "../../../shared/package" diff --git a/src/api/providers/__tests__/gemini-handler.spec.ts b/src/api/providers/__tests__/gemini-handler.spec.ts index 7c61639cfd..97c40fe67c 100644 --- a/src/api/providers/__tests__/gemini-handler.spec.ts +++ b/src/api/providers/__tests__/gemini-handler.spec.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, vi } from "vitest" import { t } from "i18next" + import { GeminiHandler } from "../gemini" import type { ApiHandlerOptions } from "../../../shared/api" diff --git a/src/api/providers/__tests__/io-intelligence.spec.ts b/src/api/providers/__tests__/io-intelligence.spec.ts index 56baf711cd..3b46b79ee2 100644 --- a/src/api/providers/__tests__/io-intelligence.spec.ts +++ b/src/api/providers/__tests__/io-intelligence.spec.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { Anthropic } from "@anthropic-ai/sdk" + import { IOIntelligenceHandler } from "../io-intelligence" import type { ApiHandlerOptions } from "../../../shared/api" -import { Anthropic } from "@anthropic-ai/sdk" const mockCreate = vi.fn() diff --git a/src/api/providers/__tests__/lite-llm.spec.ts b/src/api/providers/__tests__/lite-llm.spec.ts index 26ebbc3525..88fa2d313c 100644 --- a/src/api/providers/__tests__/lite-llm.spec.ts +++ b/src/api/providers/__tests__/lite-llm.spec.ts @@ -1,4 +1,3 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" import OpenAI from "openai" import { Anthropic } from "@anthropic-ai/sdk" diff --git a/src/api/providers/__tests__/roo.spec.ts b/src/api/providers/__tests__/roo.spec.ts index 3b99b65761..5509c51e02 100644 --- a/src/api/providers/__tests__/roo.spec.ts +++ b/src/api/providers/__tests__/roo.spec.ts @@ -100,7 +100,6 @@ vitest.mock("../../../i18n", () => ({ // Import after mocks are set up import { RooHandler } from "../roo" import { CloudService } from "@roo-code/cloud" -import { t } from "../../../i18n" describe("RooHandler", () => { let handler: RooHandler diff --git a/src/api/providers/fetchers/__tests__/lmstudio.test.ts b/src/api/providers/fetchers/__tests__/lmstudio.test.ts index 8e7e36c73f..286ec41d67 100644 --- a/src/api/providers/fetchers/__tests__/lmstudio.test.ts +++ b/src/api/providers/fetchers/__tests__/lmstudio.test.ts @@ -1,8 +1,9 @@ import axios from "axios" -import { vi, describe, it, expect, beforeEach } from "vitest" -import { LMStudioClient, LLM, LLMInstanceInfo, LLMInfo } from "@lmstudio/sdk" +import { LMStudioClient, LLMInstanceInfo, LLMInfo } from "@lmstudio/sdk" + +import { ModelInfo, lMStudioDefaultModelInfo } from "@roo-code/types" + import { getLMStudioModels, parseLMStudioModel } from "../lmstudio" -import { ModelInfo, lMStudioDefaultModelInfo } from "@roo-code/types" // ModelInfo is a type // Mock axios vi.mock("axios") diff --git a/src/api/providers/fetchers/__tests__/ollama.test.ts b/src/api/providers/fetchers/__tests__/ollama.test.ts index bf1bf3c6b2..5eb7c76866 100644 --- a/src/api/providers/fetchers/__tests__/ollama.test.ts +++ b/src/api/providers/fetchers/__tests__/ollama.test.ts @@ -1,6 +1,5 @@ import axios from "axios" -import path from "path" -import { vi, describe, it, expect, beforeEach } from "vitest" + import { getOllamaModels, parseOllamaModel } from "../ollama" import ollamaModelsData from "./fixtures/ollama-model-details.json" diff --git a/src/api/providers/roo.ts b/src/api/providers/roo.ts index ed5cb85f87..44b0160862 100644 --- a/src/api/providers/roo.ts +++ b/src/api/providers/roo.ts @@ -1,4 +1,5 @@ import { Anthropic } from "@anthropic-ai/sdk" + import { rooDefaultModelId, rooModels, type RooModelId } from "@roo-code/types" import { CloudService } from "@roo-code/cloud" diff --git a/src/core/assistant-message/__tests__/AssistantMessageParser.spec.ts b/src/core/assistant-message/__tests__/AssistantMessageParser.spec.ts index 828bf9ed22..6b7c3915ee 100644 --- a/src/core/assistant-message/__tests__/AssistantMessageParser.spec.ts +++ b/src/core/assistant-message/__tests__/AssistantMessageParser.spec.ts @@ -1,10 +1,8 @@ // npx vitest src/core/assistant-message/__tests__/AssistantMessageParser.spec.ts -import { describe, it, expect, beforeEach } from "vitest" import { AssistantMessageParser } from "../AssistantMessageParser" import { AssistantMessageContent } from "../parseAssistantMessage" import { TextContent, ToolUse } from "../../../shared/tools" -import { toolNames } from "@roo-code/types" /** * Helper to filter out empty text content blocks. diff --git a/src/core/context/context-management/__tests__/context-error-handling.test.ts b/src/core/context/context-management/__tests__/context-error-handling.test.ts index 5d2321f0aa..d26ac837f0 100644 --- a/src/core/context/context-management/__tests__/context-error-handling.test.ts +++ b/src/core/context/context-management/__tests__/context-error-handling.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, vi } from "vitest" import { APIError } from "openai" + import { checkContextWindowExceededError } from "../context-error-handling" describe("checkContextWindowExceededError", () => { diff --git a/src/core/mentions/__tests__/index.spec.ts b/src/core/mentions/__tests__/index.spec.ts index e65dbb44e0..2cb24b4502 100644 --- a/src/core/mentions/__tests__/index.spec.ts +++ b/src/core/mentions/__tests__/index.spec.ts @@ -1,10 +1,9 @@ // npx vitest core/mentions/__tests__/index.spec.ts -import { describe, it, expect, vi, beforeEach } from "vitest" import * as vscode from "vscode" + import { parseMentions } from "../index" import { UrlContentFetcher } from "../../../services/browser/UrlContentFetcher" -import { t } from "../../../i18n" // Mock vscode vi.mock("vscode", () => ({ diff --git a/src/core/mentions/__tests__/processUserContentMentions.spec.ts b/src/core/mentions/__tests__/processUserContentMentions.spec.ts index 36bea7e0bc..13c225042d 100644 --- a/src/core/mentions/__tests__/processUserContentMentions.spec.ts +++ b/src/core/mentions/__tests__/processUserContentMentions.spec.ts @@ -1,6 +1,5 @@ // npx vitest core/mentions/__tests__/processUserContentMentions.spec.ts -import { describe, it, expect, vi, beforeEach } from "vitest" import { processUserContentMentions } from "../processUserContentMentions" import { parseMentions } from "../index" import { UrlContentFetcher } from "../../../services/browser/UrlContentFetcher" diff --git a/src/core/prompts/__tests__/get-prompt-component.spec.ts b/src/core/prompts/__tests__/get-prompt-component.spec.ts index 7c4229b9c6..814270f9db 100644 --- a/src/core/prompts/__tests__/get-prompt-component.spec.ts +++ b/src/core/prompts/__tests__/get-prompt-component.spec.ts @@ -1,7 +1,7 @@ -import { describe, it, expect } from "vitest" -import { getPromptComponent } from "../system" import type { CustomModePrompts } from "@roo-code/types" +import { getPromptComponent } from "../system" + describe("getPromptComponent", () => { it("should return undefined for empty objects", () => { const customModePrompts: CustomModePrompts = { diff --git a/src/core/prompts/sections/__tests__/custom-instructions-global.spec.ts b/src/core/prompts/sections/__tests__/custom-instructions-global.spec.ts index 269dd554ea..7e13096a9d 100644 --- a/src/core/prompts/sections/__tests__/custom-instructions-global.spec.ts +++ b/src/core/prompts/sections/__tests__/custom-instructions-global.spec.ts @@ -1,5 +1,4 @@ import * as path from "path" -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" // Use vi.hoisted to ensure mocks are available during hoisting const { mockHomedir, mockStat, mockReadFile, mockReaddir, mockGetRooDirectoriesForCwd, mockGetGlobalRooDirectory } = diff --git a/src/core/prompts/sections/__tests__/custom-instructions-path-detection.spec.ts b/src/core/prompts/sections/__tests__/custom-instructions-path-detection.spec.ts index 53272a112b..bced3a767e 100644 --- a/src/core/prompts/sections/__tests__/custom-instructions-path-detection.spec.ts +++ b/src/core/prompts/sections/__tests__/custom-instructions-path-detection.spec.ts @@ -1,5 +1,3 @@ -import { describe, it, expect, vi } from "vitest" -import * as os from "os" import * as path from "path" describe("custom-instructions path detection", () => { diff --git a/src/core/prompts/tools/__tests__/fetch-instructions.spec.ts b/src/core/prompts/tools/__tests__/fetch-instructions.spec.ts index 29e7f0fca2..ef01f132f5 100644 --- a/src/core/prompts/tools/__tests__/fetch-instructions.spec.ts +++ b/src/core/prompts/tools/__tests__/fetch-instructions.spec.ts @@ -1,4 +1,3 @@ -import { describe, it, expect } from "vitest" import { getFetchInstructionsDescription } from "../fetch-instructions" describe("getFetchInstructionsDescription", () => { diff --git a/src/core/prompts/tools/__tests__/new-task.spec.ts b/src/core/prompts/tools/__tests__/new-task.spec.ts index 94dfaf1d79..c110cffcd1 100644 --- a/src/core/prompts/tools/__tests__/new-task.spec.ts +++ b/src/core/prompts/tools/__tests__/new-task.spec.ts @@ -1,4 +1,3 @@ -import { describe, it, expect } from "vitest" import { getNewTaskDescription } from "../new-task" import { ToolArgs } from "../types" diff --git a/src/core/task/__tests__/AutoApprovalHandler.spec.ts b/src/core/task/__tests__/AutoApprovalHandler.spec.ts index e200948a33..4d91b1f77d 100644 --- a/src/core/task/__tests__/AutoApprovalHandler.spec.ts +++ b/src/core/task/__tests__/AutoApprovalHandler.spec.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" -import { AutoApprovalHandler } from "../AutoApprovalHandler" import { GlobalState, ClineMessage } from "@roo-code/types" +import { AutoApprovalHandler } from "../AutoApprovalHandler" + // Mock getApiMetrics vi.mock("../../../shared/getApiMetrics", () => ({ getApiMetrics: vi.fn(), diff --git a/src/core/task/__tests__/Task.dispose.test.ts b/src/core/task/__tests__/Task.dispose.test.ts index 1d93d148a4..9e793fd8d8 100644 --- a/src/core/task/__tests__/Task.dispose.test.ts +++ b/src/core/task/__tests__/Task.dispose.test.ts @@ -1,7 +1,7 @@ +import { ProviderSettings } from "@roo-code/types" + import { Task } from "../Task" import { ClineProvider } from "../../webview/ClineProvider" -import { ProviderSettings } from "@roo-code/types" -import { vi, describe, test, expect, beforeEach, afterEach } from "vitest" // Mock dependencies vi.mock("../../webview/ClineProvider") diff --git a/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts b/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts index fbb9ef9eb3..6bddddcdf1 100644 --- a/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts +++ b/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts @@ -1,4 +1,3 @@ -import { describe, it, expect, vi } from "vitest" import { askFollowupQuestionTool } from "../askFollowupQuestionTool" import { ToolUse } from "../../../shared/tools" diff --git a/src/core/tools/__tests__/attemptCompletionTool.spec.ts b/src/core/tools/__tests__/attemptCompletionTool.spec.ts index b39c1acac6..fcad4d5f49 100644 --- a/src/core/tools/__tests__/attemptCompletionTool.spec.ts +++ b/src/core/tools/__tests__/attemptCompletionTool.spec.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" import { TodoItem } from "@roo-code/types" + import { AttemptCompletionToolUse } from "../../../shared/tools" // Mock the formatResponse module before importing the tool diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 9e4434745f..0c26858b61 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -29,16 +29,17 @@ import { type TerminalActionId, type TerminalActionPromptType, type HistoryItem, - type ClineAsk, + type CloudUserInfo, RooCodeEventName, requestyDefaultModelId, openRouterDefaultModelId, glamaDefaultModelId, DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT, DEFAULT_WRITE_DELAY_MS, + ORGANIZATION_ALLOW_ALL, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" -import { type CloudUserInfo, CloudService, ORGANIZATION_ALLOW_ALL, getRooCodeApiUrl } from "@roo-code/cloud" +import { CloudService, getRooCodeApiUrl } from "@roo-code/cloud" import { Package } from "../../shared/package" import { findLast } from "../../shared/array" diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index d61dd6b705..e5e7e85da5 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -4,9 +4,8 @@ import Anthropic from "@anthropic-ai/sdk" import * as vscode from "vscode" import axios from "axios" -import { type ProviderSettingsEntry, type ClineMessage } from "@roo-code/types" +import { type ProviderSettingsEntry, type ClineMessage, ORGANIZATION_ALLOW_ALL } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" -import { ORGANIZATION_ALLOW_ALL } from "@roo-code/cloud" import { ExtensionMessage, ExtensionState } from "../../../shared/ExtensionMessage" import { defaultModeSlug } from "../../../shared/modes" diff --git a/src/core/webview/__tests__/messageEnhancer.test.ts b/src/core/webview/__tests__/messageEnhancer.test.ts index f6f6b44e1d..bc624821da 100644 --- a/src/core/webview/__tests__/messageEnhancer.test.ts +++ b/src/core/webview/__tests__/messageEnhancer.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" -import { MessageEnhancer } from "../messageEnhancer" import { ProviderSettings, ClineMessage } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" + +import { MessageEnhancer } from "../messageEnhancer" import * as singleCompletionHandlerModule from "../../../utils/single-completion-handler" import { ProviderSettingsManager } from "../../config/ProviderSettingsManager" diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index a6a7e7022a..6bf1320ccf 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -14,6 +14,7 @@ import { } from "@roo-code/types" import { CloudService } from "@roo-code/cloud" import { TelemetryService } from "@roo-code/telemetry" + import { type ApiMessage } from "../task-persistence/apiMessages" import { ClineProvider } from "./ClineProvider" diff --git a/src/extension.ts b/src/extension.ts index f1f413ec36..6060bb341f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -12,7 +12,8 @@ try { console.warn("Failed to load environment variables:", e) } -import { CloudService, ExtensionBridgeService, type CloudUserInfo } from "@roo-code/cloud" +import type { CloudUserInfo } from "@roo-code/types" +import { CloudService, ExtensionBridgeService } from "@roo-code/cloud" import { TelemetryService, PostHogTelemetryClient } from "@roo-code/telemetry" import "./utils/path" // Necessary to have access to String.prototype.toPosix. diff --git a/src/integrations/claude-code/__tests__/message-filter.spec.ts b/src/integrations/claude-code/__tests__/message-filter.spec.ts index 25f4948cb3..a2fc701f41 100644 --- a/src/integrations/claude-code/__tests__/message-filter.spec.ts +++ b/src/integrations/claude-code/__tests__/message-filter.spec.ts @@ -1,7 +1,7 @@ -import { describe, test, expect } from "vitest" -import { filterMessagesForClaudeCode } from "../message-filter" import type { Anthropic } from "@anthropic-ai/sdk" +import { filterMessagesForClaudeCode } from "../message-filter" + describe("filterMessagesForClaudeCode", () => { test("should pass through string messages unchanged", () => { const messages: Anthropic.Messages.MessageParam[] = [ diff --git a/src/integrations/claude-code/__tests__/run.spec.ts b/src/integrations/claude-code/__tests__/run.spec.ts index fa5aedcd36..a07120c28a 100644 --- a/src/integrations/claude-code/__tests__/run.spec.ts +++ b/src/integrations/claude-code/__tests__/run.spec.ts @@ -1,5 +1,3 @@ -import { describe, test, expect, vi, beforeEach, afterEach } from "vitest" - // Mock i18n system vi.mock("../../i18n", () => ({ t: vi.fn((key: string, options?: Record) => { diff --git a/src/integrations/misc/__tests__/extract-text-large-files.spec.ts b/src/integrations/misc/__tests__/extract-text-large-files.spec.ts index fc2f7f54b6..c9e2f181f5 100644 --- a/src/integrations/misc/__tests__/extract-text-large-files.spec.ts +++ b/src/integrations/misc/__tests__/extract-text-large-files.spec.ts @@ -1,7 +1,7 @@ // npx vitest run integrations/misc/__tests__/extract-text-large-files.spec.ts -import { describe, it, expect, vi, beforeEach, Mock } from "vitest" import * as fs from "fs/promises" + import { extractTextFromFile } from "../extract-text" import { countFileLines } from "../line-counter" import { readLines } from "../read-lines" diff --git a/src/integrations/misc/__tests__/open-file.spec.ts b/src/integrations/misc/__tests__/open-file.spec.ts index e8f9be259d..f6f47e639d 100644 --- a/src/integrations/misc/__tests__/open-file.spec.ts +++ b/src/integrations/misc/__tests__/open-file.spec.ts @@ -1,7 +1,5 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" import * as vscode from "vscode" -import * as path from "path" -import * as os from "os" + import { openFile } from "../open-file" // Mock vscode module diff --git a/src/package.json b/src/package.json index 8d806d2d30..e3f919c61b 100644 --- a/src/package.json +++ b/src/package.json @@ -427,13 +427,12 @@ "@google/genai": "^1.0.0", "@lmstudio/sdk": "^1.1.1", "@mistralai/mistralai": "^1.9.18", - "@modelcontextprotocol/sdk": "^1.9.0", + "@modelcontextprotocol/sdk": "1.12.0", "@qdrant/js-client-rest": "^1.14.0", - "@roo-code/cloud": "^0.29.0", + "@roo-code/cloud": "workspace:^", "@roo-code/ipc": "workspace:^", "@roo-code/telemetry": "workspace:^", "@roo-code/types": "workspace:^", - "@types/lodash.debounce": "^4.0.9", "@vscode/codicons": "^0.0.36", "async-mutex": "^0.5.0", "axios": "^1.7.4", @@ -504,6 +503,7 @@ "@types/diff": "^5.2.1", "@types/diff-match-patch": "^1.0.36", "@types/glob": "^8.1.0", + "@types/lodash.debounce": "^4.0.9", "@types/mocha": "^10.0.10", "@types/node": "20.x", "@types/node-cache": "^4.1.3", diff --git a/src/services/browser/__tests__/BrowserSession.spec.ts b/src/services/browser/__tests__/BrowserSession.spec.ts index 0ba43a382c..b69fb2d140 100644 --- a/src/services/browser/__tests__/BrowserSession.spec.ts +++ b/src/services/browser/__tests__/BrowserSession.spec.ts @@ -1,9 +1,7 @@ // npx vitest services/browser/__tests__/BrowserSession.spec.ts -import { describe, it, expect, vi, beforeEach } from "vitest" import { BrowserSession } from "../BrowserSession" import { discoverChromeHostUrl, tryChromeHostUrl } from "../browserDiscovery" -import { fileExistsAtPath } from "../../../utils/fs" // Mock dependencies vi.mock("vscode", () => ({ diff --git a/src/services/browser/__tests__/UrlContentFetcher.spec.ts b/src/services/browser/__tests__/UrlContentFetcher.spec.ts index 98c3fcd7e4..b21456e379 100644 --- a/src/services/browser/__tests__/UrlContentFetcher.spec.ts +++ b/src/services/browser/__tests__/UrlContentFetcher.spec.ts @@ -1,10 +1,9 @@ // npx vitest services/browser/__tests__/UrlContentFetcher.spec.ts -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" -import { UrlContentFetcher } from "../UrlContentFetcher" -import { fileExistsAtPath } from "../../../utils/fs" import * as path from "path" +import { UrlContentFetcher } from "../UrlContentFetcher" + // Mock dependencies vi.mock("vscode", () => ({ ExtensionContext: vi.fn(), @@ -128,8 +127,8 @@ describe("UrlContentFetcher", () => { it("should launch browser with correct arguments on non-Linux platforms", async () => { // Ensure we're not on Linux for this test const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { - value: 'darwin' // macOS + Object.defineProperty(process, "platform", { + value: "darwin", // macOS }) try { @@ -153,8 +152,8 @@ describe("UrlContentFetcher", () => { }) } finally { // Restore original platform - Object.defineProperty(process, 'platform', { - value: originalPlatform + Object.defineProperty(process, "platform", { + value: originalPlatform, }) } }) @@ -162,8 +161,8 @@ describe("UrlContentFetcher", () => { it("should launch browser with Linux-specific arguments", async () => { // Mock process.platform to be linux const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { - value: 'linux' + Object.defineProperty(process, "platform", { + value: "linux", }) try { @@ -190,8 +189,8 @@ describe("UrlContentFetcher", () => { }) } finally { // Restore original platform - Object.defineProperty(process, 'platform', { - value: originalPlatform + Object.defineProperty(process, "platform", { + value: originalPlatform, }) } }) diff --git a/src/services/code-index/__tests__/config-manager.spec.ts b/src/services/code-index/__tests__/config-manager.spec.ts index 2d6e704d76..9fc096ba74 100644 --- a/src/services/code-index/__tests__/config-manager.spec.ts +++ b/src/services/code-index/__tests__/config-manager.spec.ts @@ -1,8 +1,6 @@ // npx vitest services/code-index/__tests__/config-manager.spec.ts -import { describe, it, expect, beforeEach, vi } from "vitest" import { CodeIndexConfigManager } from "../config-manager" -import { ContextProxy } from "../../../core/config/ContextProxy" import { PreviousConfigSnapshot } from "../interfaces/config" // Mock ContextProxy diff --git a/src/services/code-index/embedders/__tests__/mistral.spec.ts b/src/services/code-index/embedders/__tests__/mistral.spec.ts index 5085882503..82c7410274 100644 --- a/src/services/code-index/embedders/__tests__/mistral.spec.ts +++ b/src/services/code-index/embedders/__tests__/mistral.spec.ts @@ -1,5 +1,5 @@ -import { vitest, describe, it, expect, beforeEach } from "vitest" import type { MockedClass } from "vitest" + import { MistralEmbedder } from "../mistral" import { OpenAICompatibleEmbedder } from "../openai-compatible" diff --git a/src/services/code-index/embedders/__tests__/ollama.spec.ts b/src/services/code-index/embedders/__tests__/ollama.spec.ts index 253158dd50..650140beac 100644 --- a/src/services/code-index/embedders/__tests__/ollama.spec.ts +++ b/src/services/code-index/embedders/__tests__/ollama.spec.ts @@ -1,5 +1,5 @@ -import { vitest, describe, it, expect, beforeEach, afterEach } from "vitest" import type { MockedFunction } from "vitest" + import { CodeIndexOllamaEmbedder } from "../ollama" // Mock fetch diff --git a/src/services/code-index/embedders/__tests__/openai-compatible-rate-limit.spec.ts b/src/services/code-index/embedders/__tests__/openai-compatible-rate-limit.spec.ts index 3e2acc398e..e148b6c932 100644 --- a/src/services/code-index/embedders/__tests__/openai-compatible-rate-limit.spec.ts +++ b/src/services/code-index/embedders/__tests__/openai-compatible-rate-limit.spec.ts @@ -1,5 +1,6 @@ -import { describe, it, expect, vi, beforeEach, afterEach, MockedClass, MockedFunction } from "vitest" +import type { MockedClass, MockedFunction } from "vitest" import { OpenAI } from "openai" + import { OpenAICompatibleEmbedder } from "../openai-compatible" // Mock the OpenAI SDK diff --git a/src/services/code-index/embedders/__tests__/openai.spec.ts b/src/services/code-index/embedders/__tests__/openai.spec.ts index c8e4706f39..089abe151a 100644 --- a/src/services/code-index/embedders/__tests__/openai.spec.ts +++ b/src/services/code-index/embedders/__tests__/openai.spec.ts @@ -1,8 +1,8 @@ -import { vitest, describe, it, expect, beforeEach, afterEach } from "vitest" import type { MockedClass, MockedFunction } from "vitest" import { OpenAI } from "openai" + import { OpenAiEmbedder } from "../openai" -import { MAX_BATCH_TOKENS, MAX_ITEM_TOKENS, MAX_BATCH_RETRIES, INITIAL_RETRY_DELAY_MS } from "../../constants" +import { MAX_ITEM_TOKENS, INITIAL_RETRY_DELAY_MS } from "../../constants" // Mock the OpenAI SDK vitest.mock("openai") diff --git a/src/services/code-index/embedders/__tests__/vercel-ai-gateway.spec.ts b/src/services/code-index/embedders/__tests__/vercel-ai-gateway.spec.ts index 0b819f9bbe..891339025c 100644 --- a/src/services/code-index/embedders/__tests__/vercel-ai-gateway.spec.ts +++ b/src/services/code-index/embedders/__tests__/vercel-ai-gateway.spec.ts @@ -1,6 +1,5 @@ // npx vitest run src/services/code-index/embedders/__tests__/vercel-ai-gateway.spec.ts -import { describe, it, expect, vi, beforeEach } from "vitest" import { VercelAiGatewayEmbedder } from "../vercel-ai-gateway" import { OpenAICompatibleEmbedder } from "../openai-compatible" diff --git a/src/services/code-index/processors/__tests__/parser.vb.spec.ts b/src/services/code-index/processors/__tests__/parser.vb.spec.ts index 3b17e0d67f..95fd73cc9a 100644 --- a/src/services/code-index/processors/__tests__/parser.vb.spec.ts +++ b/src/services/code-index/processors/__tests__/parser.vb.spec.ts @@ -1,6 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from "vitest" import { CodeParser } from "../parser" -import * as path from "path" // Mock TelemetryService vi.mock("../../../../../packages/telemetry/src/TelemetryService", () => ({ diff --git a/src/services/code-index/shared/__tests__/get-relative-path.spec.ts b/src/services/code-index/shared/__tests__/get-relative-path.spec.ts index 5226fbf6e6..d5398f1332 100644 --- a/src/services/code-index/shared/__tests__/get-relative-path.spec.ts +++ b/src/services/code-index/shared/__tests__/get-relative-path.spec.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from "vitest" import path from "path" + import { generateNormalizedAbsolutePath, generateRelativeFilePath } from "../get-relative-path" describe("get-relative-path", () => { diff --git a/src/services/code-index/shared/__tests__/validation-helpers.spec.ts b/src/services/code-index/shared/__tests__/validation-helpers.spec.ts index 00270017db..bf6c732a92 100644 --- a/src/services/code-index/shared/__tests__/validation-helpers.spec.ts +++ b/src/services/code-index/shared/__tests__/validation-helpers.spec.ts @@ -1,4 +1,3 @@ -import { describe, it, expect } from "vitest" import { sanitizeErrorMessage } from "../validation-helpers" describe("sanitizeErrorMessage", () => { diff --git a/src/services/command/__tests__/built-in-commands.spec.ts b/src/services/command/__tests__/built-in-commands.spec.ts index 5a191b3860..ecb2bdb0fb 100644 --- a/src/services/command/__tests__/built-in-commands.spec.ts +++ b/src/services/command/__tests__/built-in-commands.spec.ts @@ -1,4 +1,3 @@ -import { describe, it, expect } from "vitest" import { getBuiltInCommands, getBuiltInCommand, getBuiltInCommandNames } from "../built-in-commands" describe("Built-in Commands", () => { diff --git a/src/services/command/__tests__/frontmatter-commands.spec.ts b/src/services/command/__tests__/frontmatter-commands.spec.ts index 3b7c403146..40acc8ae84 100644 --- a/src/services/command/__tests__/frontmatter-commands.spec.ts +++ b/src/services/command/__tests__/frontmatter-commands.spec.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, beforeEach, vi } from "vitest" import fs from "fs/promises" import * as path from "path" + import { getCommand, getCommands } from "../commands" // Mock fs and path modules diff --git a/src/services/glob/__tests__/gitignore-integration.spec.ts b/src/services/glob/__tests__/gitignore-integration.spec.ts index 1361816b17..fbd2474e15 100644 --- a/src/services/glob/__tests__/gitignore-integration.spec.ts +++ b/src/services/glob/__tests__/gitignore-integration.spec.ts @@ -1,4 +1,3 @@ -import { vi, describe, it, expect, beforeEach, afterEach } from "vitest" import * as path from "path" import * as fs from "fs" import * as os from "os" diff --git a/src/services/glob/__tests__/gitignore-test.spec.ts b/src/services/glob/__tests__/gitignore-test.spec.ts index cef884b584..6928819f9c 100644 --- a/src/services/glob/__tests__/gitignore-test.spec.ts +++ b/src/services/glob/__tests__/gitignore-test.spec.ts @@ -1,4 +1,3 @@ -import { vi, describe, it, expect, beforeEach, afterEach } from "vitest" import * as path from "path" import * as fs from "fs" import * as os from "os" diff --git a/src/services/glob/__tests__/list-files.spec.ts b/src/services/glob/__tests__/list-files.spec.ts index d855388002..b987319419 100644 --- a/src/services/glob/__tests__/list-files.spec.ts +++ b/src/services/glob/__tests__/list-files.spec.ts @@ -1,7 +1,6 @@ -import { vi, describe, it, expect, beforeEach } from "vitest" import * as path from "path" -import { listFiles } from "../list-files" import * as childProcess from "child_process" +import { listFiles } from "../list-files" vi.mock("child_process") vi.mock("fs") diff --git a/src/services/marketplace/MarketplaceManager.ts b/src/services/marketplace/MarketplaceManager.ts index 0227cdb79d..dfde5600b9 100644 --- a/src/services/marketplace/MarketplaceManager.ts +++ b/src/services/marketplace/MarketplaceManager.ts @@ -4,9 +4,9 @@ import * as path from "path" import * as vscode from "vscode" import * as yaml from "yaml" -import type { MarketplaceItem, MarketplaceItemType, McpMarketplaceItem } from "@roo-code/types" +import type { OrganizationSettings, MarketplaceItem, MarketplaceItemType, McpMarketplaceItem } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" -import { type OrganizationSettings, CloudService } from "@roo-code/cloud" +import { CloudService } from "@roo-code/cloud" import { GlobalFileNames } from "../../shared/globalFileNames" import { ensureSettingsDirectoryExists } from "../../utils/globalContext" diff --git a/src/services/marketplace/RemoteConfigLoader.ts b/src/services/marketplace/RemoteConfigLoader.ts index fe66b32be0..b5851ae854 100644 --- a/src/services/marketplace/RemoteConfigLoader.ts +++ b/src/services/marketplace/RemoteConfigLoader.ts @@ -1,11 +1,15 @@ import axios from "axios" import * as yaml from "yaml" import { z } from "zod" + +import { + type MarketplaceItem, + type MarketplaceItemType, + modeMarketplaceItemSchema, + mcpMarketplaceItemSchema, +} from "@roo-code/types" import { getRooCodeApiUrl } from "@roo-code/cloud" -import type { MarketplaceItem, MarketplaceItemType } from "@roo-code/types" -import { modeMarketplaceItemSchema, mcpMarketplaceItemSchema } from "@roo-code/types" -// Response schemas for YAML API responses const modeMarketplaceResponse = z.object({ items: z.array(modeMarketplaceItemSchema), }) @@ -38,11 +42,13 @@ export class RemoteConfigLoader { private async fetchModes(): Promise { const cacheKey = "modes" const cached = this.getFromCache(cacheKey) - if (cached) return cached + + if (cached) { + return cached + } const data = await this.fetchWithRetry(`${this.apiBaseUrl}/api/marketplace/modes`) - // Parse and validate YAML response const yamlData = yaml.parse(data) const validated = modeMarketplaceResponse.parse(yamlData) @@ -58,11 +64,13 @@ export class RemoteConfigLoader { private async fetchMcps(): Promise { const cacheKey = "mcps" const cached = this.getFromCache(cacheKey) - if (cached) return cached + + if (cached) { + return cached + } const data = await this.fetchWithRetry(`${this.apiBaseUrl}/api/marketplace/mcps`) - // Parse and validate YAML response const yamlData = yaml.parse(data) const validated = mcpMarketplaceResponse.parse(yamlData) diff --git a/src/services/mcp/__tests__/McpHub.spec.ts b/src/services/mcp/__tests__/McpHub.spec.ts index ebce2d5b2a..1db924ed6c 100644 --- a/src/services/mcp/__tests__/McpHub.spec.ts +++ b/src/services/mcp/__tests__/McpHub.spec.ts @@ -1,9 +1,12 @@ -import type { McpHub as McpHubType, McpConnection, ConnectedMcpConnection, DisconnectedMcpConnection } from "../McpHub" -import type { ClineProvider } from "../../../core/webview/ClineProvider" -import type { ExtensionContext, Uri } from "vscode" -import { ServerConfigSchema, McpHub, DisableReason } from "../McpHub" import fs from "fs/promises" -import { vi, Mock } from "vitest" + +import type { Mock } from "vitest" +import type { ExtensionContext, Uri } from "vscode" + +import type { ClineProvider } from "../../../core/webview/ClineProvider" + +import type { McpHub as McpHubType, McpConnection, ConnectedMcpConnection, DisconnectedMcpConnection } from "../McpHub" +import { ServerConfigSchema, McpHub } from "../McpHub" // Mock fs/promises before importing anything that uses it vi.mock("fs/promises", () => ({ diff --git a/src/services/mdm/MdmService.ts b/src/services/mdm/MdmService.ts index 67d684b176..91c407514f 100644 --- a/src/services/mdm/MdmService.ts +++ b/src/services/mdm/MdmService.ts @@ -1,11 +1,10 @@ import * as fs from "fs" import * as path from "path" import * as os from "os" -import * as vscode from "vscode" import { z } from "zod" import { CloudService, getClerkBaseUrl, PRODUCTION_CLERK_BASE_URL } from "@roo-code/cloud" -import { Package } from "../../shared/package" + import { t } from "../../i18n" // MDM Configuration Schema diff --git a/src/services/mdm/__tests__/MdmService.spec.ts b/src/services/mdm/__tests__/MdmService.spec.ts index 81ff61652b..31a7ba401e 100644 --- a/src/services/mdm/__tests__/MdmService.spec.ts +++ b/src/services/mdm/__tests__/MdmService.spec.ts @@ -1,5 +1,4 @@ import * as path from "path" -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" // Mock dependencies vi.mock("fs", () => ({ diff --git a/src/services/roo-config/__tests__/index.spec.ts b/src/services/roo-config/__tests__/index.spec.ts index 8e9bf929cc..946bb27c7f 100644 --- a/src/services/roo-config/__tests__/index.spec.ts +++ b/src/services/roo-config/__tests__/index.spec.ts @@ -1,5 +1,4 @@ import * as path from "path" -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" // Use vi.hoisted to ensure mocks are available during hoisting const { mockStat, mockReadFile, mockHomedir } = vi.hoisted(() => ({ diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 8812187635..65fe181859 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -9,8 +9,10 @@ import type { ClineMessage, MarketplaceItem, TodoItem, + CloudUserInfo, + OrganizationAllowList, + ShareVisibility, } from "@roo-code/types" -import type { CloudUserInfo, OrganizationAllowList, ShareVisibility } from "@roo-code/cloud" import { GitCommit } from "../utils/git" diff --git a/src/shared/ProfileValidator.ts b/src/shared/ProfileValidator.ts index 51eed227d3..57c10301a2 100644 --- a/src/shared/ProfileValidator.ts +++ b/src/shared/ProfileValidator.ts @@ -1,5 +1,4 @@ -import type { ProviderSettings } from "@roo-code/types" -import type { OrganizationAllowList } from "@roo-code/cloud" +import type { ProviderSettings, OrganizationAllowList } from "@roo-code/types" export class ProfileValidator { public static isProfileAllowed(profile: ProviderSettings, allowList: OrganizationAllowList): boolean { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 57bad0e402..e2df805340 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -6,9 +6,9 @@ import { type ModeConfig, type InstallMarketplaceItemOptions, type MarketplaceItem, + type ShareVisibility, marketplaceItemSchema, } from "@roo-code/types" -import type { ShareVisibility } from "@roo-code/cloud" import { Mode } from "./modes" diff --git a/src/shared/__tests__/ProfileValidator.spec.ts b/src/shared/__tests__/ProfileValidator.spec.ts index 4396e8268a..5cfe7a720b 100644 --- a/src/shared/__tests__/ProfileValidator.spec.ts +++ b/src/shared/__tests__/ProfileValidator.spec.ts @@ -1,7 +1,6 @@ // npx vitest run src/shared/__tests__/ProfileValidator.spec.ts -import { type ProviderSettings } from "@roo-code/types" -import { type OrganizationAllowList } from "@roo-code/cloud" +import type { ProviderSettings, OrganizationAllowList } from "@roo-code/types" import { ProfileValidator } from "../ProfileValidator" diff --git a/src/shared/__tests__/api.spec.ts b/src/shared/__tests__/api.spec.ts index 7c25fe4197..aaeb1bf444 100644 --- a/src/shared/__tests__/api.spec.ts +++ b/src/shared/__tests__/api.spec.ts @@ -1,7 +1,11 @@ -import { describe, test, expect } from "vitest" +import { + type ModelInfo, + type ProviderSettings, + CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS, + ANTHROPIC_DEFAULT_MAX_TOKENS, +} from "@roo-code/types" + import { getModelMaxOutputTokens, shouldUseReasoningBudget, shouldUseReasoningEffort } from "../api" -import type { ModelInfo, ProviderSettings } from "@roo-code/types" -import { CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS, ANTHROPIC_DEFAULT_MAX_TOKENS } from "@roo-code/types" describe("getModelMaxOutputTokens", () => { const mockModel: ModelInfo = { diff --git a/src/shared/__tests__/experiments-preventFocusDisruption.spec.ts b/src/shared/__tests__/experiments-preventFocusDisruption.spec.ts index 7e5389c286..e9f96c7ce7 100644 --- a/src/shared/__tests__/experiments-preventFocusDisruption.spec.ts +++ b/src/shared/__tests__/experiments-preventFocusDisruption.spec.ts @@ -1,4 +1,3 @@ -import { describe, it, expect } from "vitest" import { EXPERIMENT_IDS, experimentConfigsMap, experimentDefault, experiments } from "../experiments" describe("PREVENT_FOCUS_DISRUPTION experiment", () => { diff --git a/src/shared/__tests__/modes-empty-prompt-component.spec.ts b/src/shared/__tests__/modes-empty-prompt-component.spec.ts index 5af93cb5d9..f3be0e9927 100644 --- a/src/shared/__tests__/modes-empty-prompt-component.spec.ts +++ b/src/shared/__tests__/modes-empty-prompt-component.spec.ts @@ -1,7 +1,7 @@ -import { describe, it, expect } from "vitest" -import { getModeSelection, modes } from "../modes" import type { PromptComponent } from "@roo-code/types" +import { getModeSelection, modes } from "../modes" + describe("getModeSelection with empty promptComponent", () => { it("should use built-in mode instructions when promptComponent is undefined", () => { const architectMode = modes.find((m) => m.slug === "architect")! diff --git a/src/shared/cloud.ts b/src/shared/cloud.ts deleted file mode 100644 index 9adf9294a0..0000000000 --- a/src/shared/cloud.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { CloudUserInfo, OrganizationAllowList, ShareVisibility } from "@roo-code/cloud" - -export type { CloudUserInfo, OrganizationAllowList, ShareVisibility } - -export const ORGANIZATION_ALLOW_ALL: OrganizationAllowList = { - allowAll: true, - providers: {}, -} as const diff --git a/src/shared/utils/__tests__/requesty.spec.ts b/src/shared/utils/__tests__/requesty.spec.ts index eddb639843..6ed798b1a2 100644 --- a/src/shared/utils/__tests__/requesty.spec.ts +++ b/src/shared/utils/__tests__/requesty.spec.ts @@ -1,4 +1,3 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" import { toRequestyServiceUrl } from "../requesty" describe("toRequestyServiceUrl", () => { diff --git a/src/tsconfig.json b/src/tsconfig.json index 90bdb860cd..11d189aa2e 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -16,7 +16,7 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "target": "es2022", + "target": "ES2022", "useDefineForClassFields": true, "useUnknownInCatchVariables": false }, diff --git a/src/utils/__tests__/autoImportSettings.spec.ts b/src/utils/__tests__/autoImportSettings.spec.ts index 2b9b42293f..be0d769670 100644 --- a/src/utils/__tests__/autoImportSettings.spec.ts +++ b/src/utils/__tests__/autoImportSettings.spec.ts @@ -1,5 +1,3 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" - // Mock dependencies vi.mock("vscode", () => ({ workspace: { diff --git a/src/utils/__tests__/migrateSettings.spec.ts b/src/utils/__tests__/migrateSettings.spec.ts index 5b8481949b..a64cc540cb 100644 --- a/src/utils/__tests__/migrateSettings.spec.ts +++ b/src/utils/__tests__/migrateSettings.spec.ts @@ -1,7 +1,5 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" import * as vscode from "vscode" -import * as fs from "fs/promises" -import * as path from "path" + import { migrateSettings } from "../migrateSettings" // Mock vscode module diff --git a/src/utils/__tests__/object.spec.ts b/src/utils/__tests__/object.spec.ts index ce9d9f5cc7..772de85c77 100644 --- a/src/utils/__tests__/object.spec.ts +++ b/src/utils/__tests__/object.spec.ts @@ -1,4 +1,3 @@ -import { describe, it, expect } from "vitest" import { isEmpty } from "../object" describe("isEmpty", () => { diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index f3b687595a..e18b86a02d 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -1,11 +1,11 @@ -import { vi, describe, test, expect, beforeEach, afterEach, beforeAll, afterAll } from "vitest" import * as actualFsPromises from "fs/promises" import * as fsSyncActual from "fs" import { Writable } from "stream" -import { safeWriteJson } from "../safeWriteJson" import * as path from "path" import * as os from "os" +import { safeWriteJson } from "../safeWriteJson" + const originalFsPromisesRename = actualFsPromises.rename const originalFsPromisesUnlink = actualFsPromises.unlink const originalFsPromisesWriteFile = actualFsPromises.writeFile diff --git a/src/utils/remoteControl.ts b/src/utils/remoteControl.ts index e645ce7219..f003b522d1 100644 --- a/src/utils/remoteControl.ts +++ b/src/utils/remoteControl.ts @@ -1,4 +1,4 @@ -import type { CloudUserInfo } from "@roo-code/cloud" +import type { CloudUserInfo } from "@roo-code/types" /** * Determines if remote control features should be enabled diff --git a/webview-ui/src/__tests__/command-autocomplete.spec.ts b/webview-ui/src/__tests__/command-autocomplete.spec.ts index be87f3586b..d239128cce 100644 --- a/webview-ui/src/__tests__/command-autocomplete.spec.ts +++ b/webview-ui/src/__tests__/command-autocomplete.spec.ts @@ -1,7 +1,7 @@ -import { describe, it, expect } from "vitest" -import { getContextMenuOptions, ContextMenuOptionType } from "../utils/context-mentions" import type { Command } from "@roo/ExtensionMessage" +import { getContextMenuOptions, ContextMenuOptionType } from "../utils/context-mentions" + describe("Command Autocomplete", () => { const mockCommands: Command[] = [ { name: "setup", source: "project" }, diff --git a/webview-ui/src/components/__tests__/ErrorBoundary.spec.tsx b/webview-ui/src/components/__tests__/ErrorBoundary.spec.tsx index 9c1f2e8c98..1fbb6774f2 100644 --- a/webview-ui/src/components/__tests__/ErrorBoundary.spec.tsx +++ b/webview-ui/src/components/__tests__/ErrorBoundary.spec.tsx @@ -1,6 +1,6 @@ import React from "react" import { render, screen } from "@testing-library/react" -import { vi } from "vitest" + import ErrorBoundary from "../ErrorBoundary" // Mock telemetryClient diff --git a/webview-ui/src/components/account/AccountView.tsx b/webview-ui/src/components/account/AccountView.tsx index 2e75b2d1c6..25923deda3 100644 --- a/webview-ui/src/components/account/AccountView.tsx +++ b/webview-ui/src/components/account/AccountView.tsx @@ -1,9 +1,7 @@ import { useEffect, useRef } from "react" import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" -import { TelemetryEventName } from "@roo-code/types" - -import type { CloudUserInfo } from "@roo/cloud" +import { type CloudUserInfo, TelemetryEventName } from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" import { useExtensionState } from "@src/context/ExtensionStateContext" diff --git a/webview-ui/src/components/account/__tests__/AccountView.spec.tsx b/webview-ui/src/components/account/__tests__/AccountView.spec.tsx index c960ca4662..63058bd5b2 100644 --- a/webview-ui/src/components/account/__tests__/AccountView.spec.tsx +++ b/webview-ui/src/components/account/__tests__/AccountView.spec.tsx @@ -1,5 +1,5 @@ import { render, screen } from "@/utils/test-utils" -import { describe, it, expect, vi } from "vitest" + import { AccountView } from "../AccountView" // Mock the translation context diff --git a/webview-ui/src/components/chat/ShareButton.tsx b/webview-ui/src/components/chat/ShareButton.tsx index 4a0559fec7..b8584fca11 100644 --- a/webview-ui/src/components/chat/ShareButton.tsx +++ b/webview-ui/src/components/chat/ShareButton.tsx @@ -2,9 +2,7 @@ import { useState, useEffect, useRef } from "react" import { useTranslation } from "react-i18next" import { SquareArrowOutUpRightIcon } from "lucide-react" -import { type HistoryItem, TelemetryEventName } from "@roo-code/types" - -import type { ShareVisibility } from "@roo/cloud" +import { type HistoryItem, type ShareVisibility, TelemetryEventName } from "@roo-code/types" import { vscode } from "@/utils/vscode" import { telemetryClient } from "@/utils/TelemetryClient" diff --git a/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx index d5e9cff848..bac66255e4 100644 --- a/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx @@ -1,9 +1,8 @@ -import React from "react" import { render, screen, fireEvent, waitFor } from "@/utils/test-utils" -import { describe, test, expect, vi, beforeEach } from "vitest" -import { ApiConfigSelector } from "../ApiConfigSelector" import { vscode } from "@/utils/vscode" +import { ApiConfigSelector } from "../ApiConfigSelector" + // Mock the dependencies vi.mock("@/utils/vscode", () => ({ vscode: { diff --git a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx index adea2390b9..38624f674a 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx @@ -67,10 +67,7 @@ vi.mock("../../common/VersionIndicator", () => ({ })) // Get the mock function after the module is mocked -const mockVersionIndicator = vi.mocked( - // @ts-expect-error - accessing mocked module - (await import("../../common/VersionIndicator")).default, -) +const mockVersionIndicator = vi.mocked((await import("../../common/VersionIndicator")).default) vi.mock("../Announcement", () => ({ default: function MockAnnouncement({ hideAnnouncement }: { hideAnnouncement: () => void }) { diff --git a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx index e25e9029f8..5afbdd93d4 100644 --- a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx @@ -1,6 +1,6 @@ import React from "react" import { render, screen, fireEvent } from "@testing-library/react" -import { describe, it, expect, vi, beforeEach } from "vitest" + import { CommandExecution } from "../CommandExecution" import { ExtensionStateContext } from "../../../context/ExtensionStateContext" diff --git a/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx index c39d8afad3..373148016f 100644 --- a/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx @@ -1,6 +1,6 @@ import React from "react" import { render, screen, fireEvent } from "@testing-library/react" -import { describe, it, expect, vi } from "vitest" + import { CommandPatternSelector } from "../CommandPatternSelector" import { TooltipProvider } from "../../../components/ui/tooltip" diff --git a/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx b/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx index aa3f84fd8c..7a9d140dd5 100644 --- a/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx @@ -1,9 +1,8 @@ import React, { createContext, useContext } from "react" import { render, screen, act } from "@testing-library/react" +import { TooltipProvider } from "@radix-ui/react-tooltip" -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" import { FollowUpSuggest } from "../FollowUpSuggest" -import { TooltipProvider } from "@radix-ui/react-tooltip" // Mock the translation hook vi.mock("@src/i18n/TranslationContext", () => ({ diff --git a/webview-ui/src/components/chat/__tests__/ShareButton.spec.tsx b/webview-ui/src/components/chat/__tests__/ShareButton.spec.tsx index cbe5620264..f245719bbd 100644 --- a/webview-ui/src/components/chat/__tests__/ShareButton.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ShareButton.spec.tsx @@ -1,9 +1,10 @@ -import { describe, test, expect, vi, beforeEach } from "vitest" -import { render, screen, fireEvent, waitFor } from "@/utils/test-utils" -import { ShareButton } from "../ShareButton" import { useTranslation } from "react-i18next" + +import { render, screen, fireEvent, waitFor } from "@/utils/test-utils" import { vscode } from "@/utils/vscode" +import { ShareButton } from "../ShareButton" + // Mock the vscode utility vi.mock("@/utils/vscode", () => ({ vscode: { diff --git a/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx index 4c1138944d..791e5196b3 100644 --- a/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx @@ -1,10 +1,11 @@ -import { render, screen, fireEvent } from "@/utils/test-utils" -import { vi, describe, it, expect, beforeEach } from "vitest" -import { TaskActions } from "../TaskActions" import type { HistoryItem } from "@roo-code/types" + +import { render, screen, fireEvent } from "@/utils/test-utils" import { vscode } from "@/utils/vscode" import { useExtensionState } from "@/context/ExtensionStateContext" +import { TaskActions } from "../TaskActions" + // Mock scrollIntoView for JSDOM Object.defineProperty(Element.prototype, "scrollIntoView", { value: vi.fn(), diff --git a/webview-ui/src/components/common/__tests__/FormattedTextField.spec.tsx b/webview-ui/src/components/common/__tests__/FormattedTextField.spec.tsx index 637a0b5061..a58f23764c 100644 --- a/webview-ui/src/components/common/__tests__/FormattedTextField.spec.tsx +++ b/webview-ui/src/components/common/__tests__/FormattedTextField.spec.tsx @@ -1,6 +1,5 @@ -import React from "react" -import { describe, it, expect, vi } from "vitest" import { render, screen, fireEvent } from "@testing-library/react" + import { FormattedTextField, unlimitedIntegerFormatter, unlimitedDecimalFormatter } from "../FormattedTextField" // Mock VSCodeTextField to render as regular HTML input for testing diff --git a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx index 38a0680b22..a0b6857a37 100644 --- a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx +++ b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx @@ -1,7 +1,6 @@ -import React from "react" import { render, screen } from "@/utils/test-utils" + import MarkdownBlock from "../MarkdownBlock" -import { vi } from "vitest" vi.mock("@src/utils/vscode", () => ({ vscode: { diff --git a/webview-ui/src/components/marketplace/__tests__/MarketplaceView.spec.tsx b/webview-ui/src/components/marketplace/__tests__/MarketplaceView.spec.tsx index 57f7e9eb9d..1f342b9b38 100644 --- a/webview-ui/src/components/marketplace/__tests__/MarketplaceView.spec.tsx +++ b/webview-ui/src/components/marketplace/__tests__/MarketplaceView.spec.tsx @@ -1,10 +1,11 @@ import { render, waitFor } from "@testing-library/react" -import { vi, describe, it, expect, beforeEach } from "vitest" -import { MarketplaceView } from "../MarketplaceView" -import { MarketplaceViewStateManager } from "../MarketplaceViewStateManager" + import { ExtensionStateContext } from "@/context/ExtensionStateContext" import { vscode } from "@/utils/vscode" +import { MarketplaceView } from "../MarketplaceView" +import { MarketplaceViewStateManager } from "../MarketplaceViewStateManager" + vi.mock("@/utils/vscode", () => ({ vscode: { postMessage: vi.fn(), diff --git a/webview-ui/src/components/settings/ApiConfigManager.tsx b/webview-ui/src/components/settings/ApiConfigManager.tsx index 0d18f2a55b..366f7a81e7 100644 --- a/webview-ui/src/components/settings/ApiConfigManager.tsx +++ b/webview-ui/src/components/settings/ApiConfigManager.tsx @@ -2,9 +2,7 @@ import { memo, useEffect, useRef, useState } from "react" import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" import { AlertTriangle } from "lucide-react" -import type { ProviderSettingsEntry } from "@roo-code/types" - -import type { OrganizationAllowList } from "@roo/cloud" +import type { ProviderSettingsEntry, OrganizationAllowList } from "@roo-code/types" import { useAppTranslation } from "@/i18n/TranslationContext" import { diff --git a/webview-ui/src/components/settings/ModelPicker.tsx b/webview-ui/src/components/settings/ModelPicker.tsx index e398a9f01f..949e0a081f 100644 --- a/webview-ui/src/components/settings/ModelPicker.tsx +++ b/webview-ui/src/components/settings/ModelPicker.tsx @@ -3,9 +3,7 @@ import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" import { Trans } from "react-i18next" import { ChevronsUpDown, Check, X } from "lucide-react" -import type { ProviderSettings, ModelInfo } from "@roo-code/types" - -import type { OrganizationAllowList } from "@roo/cloud" +import type { ProviderSettings, ModelInfo, OrganizationAllowList } from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel" diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx index 52048de95d..17f898a5b7 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx @@ -1,13 +1,14 @@ -import { describe, it, expect, vi } from "vitest" import { render, screen } from "@testing-library/react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" -import ApiOptions from "../ApiOptions" -import { MODELS_BY_PROVIDER, PROVIDERS } from "../constants" -import type { ProviderSettings } from "@roo-code/types" -import type { OrganizationAllowList } from "@roo/cloud" + +import type { ProviderSettings, OrganizationAllowList } from "@roo-code/types" + import { useExtensionState } from "@src/context/ExtensionStateContext" import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel" +import ApiOptions from "../ApiOptions" +import { MODELS_BY_PROVIDER, PROVIDERS } from "../constants" + // Mock the extension state context vi.mock("@src/context/ExtensionStateContext", () => ({ useExtensionState: vi.fn(() => ({ diff --git a/webview-ui/src/components/settings/__tests__/ImageGenerationSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/ImageGenerationSettings.spec.tsx index ef3808c20b..2d69879772 100644 --- a/webview-ui/src/components/settings/__tests__/ImageGenerationSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ImageGenerationSettings.spec.tsx @@ -1,8 +1,9 @@ import { render, fireEvent } from "@testing-library/react" -import { vi } from "vitest" -import { ImageGenerationSettings } from "../ImageGenerationSettings" + import type { ProviderSettings } from "@roo-code/types" +import { ImageGenerationSettings } from "../ImageGenerationSettings" + // Mock the translation context vi.mock("@/i18n/TranslationContext", () => ({ useAppTranslation: () => ({ diff --git a/webview-ui/src/components/settings/__tests__/MaxCostInput.spec.tsx b/webview-ui/src/components/settings/__tests__/MaxCostInput.spec.tsx index b57d1cba6c..5fc7d72672 100644 --- a/webview-ui/src/components/settings/__tests__/MaxCostInput.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/MaxCostInput.spec.tsx @@ -1,5 +1,5 @@ import { render, screen, fireEvent } from "@testing-library/react" -import { vi } from "vitest" + import { MaxCostInput } from "../MaxCostInput" vi.mock("@/utils/vscode", () => ({ diff --git a/webview-ui/src/components/settings/__tests__/MaxRequestsInput.spec.tsx b/webview-ui/src/components/settings/__tests__/MaxRequestsInput.spec.tsx index 94940e4569..59b495bcd9 100644 --- a/webview-ui/src/components/settings/__tests__/MaxRequestsInput.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/MaxRequestsInput.spec.tsx @@ -1,5 +1,5 @@ import { render, screen, fireEvent } from "@testing-library/react" -import { vi } from "vitest" + import { MaxRequestsInput } from "../MaxRequestsInput" vi.mock("@/utils/vscode", () => ({ diff --git a/webview-ui/src/components/settings/__tests__/ModelPicker.spec.tsx b/webview-ui/src/components/settings/__tests__/ModelPicker.spec.tsx index 82c6da2a3f..eba7d0fa05 100644 --- a/webview-ui/src/components/settings/__tests__/ModelPicker.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ModelPicker.spec.tsx @@ -3,7 +3,6 @@ import { screen, fireEvent, render } from "@/utils/test-utils" import { act } from "react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" -import { vi } from "vitest" import { ModelInfo } from "@roo-code/types" diff --git a/webview-ui/src/components/settings/__tests__/TodoListSettingsControl.spec.tsx b/webview-ui/src/components/settings/__tests__/TodoListSettingsControl.spec.tsx index 432b2c9c61..76f65d312f 100644 --- a/webview-ui/src/components/settings/__tests__/TodoListSettingsControl.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/TodoListSettingsControl.spec.tsx @@ -1,6 +1,5 @@ -import React from "react" import { render, screen, fireEvent } from "@testing-library/react" -import { describe, it, expect, vi } from "vitest" + import { TodoListSettingsControl } from "../TodoListSettingsControl" // Mock the translation hook diff --git a/webview-ui/src/components/settings/providers/Glama.tsx b/webview-ui/src/components/settings/providers/Glama.tsx index bc3fb71cae..ca1c6590ef 100644 --- a/webview-ui/src/components/settings/providers/Glama.tsx +++ b/webview-ui/src/components/settings/providers/Glama.tsx @@ -1,9 +1,8 @@ import { useCallback } from "react" import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import { type ProviderSettings, glamaDefaultModelId } from "@roo-code/types" +import { type ProviderSettings, type OrganizationAllowList, glamaDefaultModelId } from "@roo-code/types" -import type { OrganizationAllowList } from "@roo/cloud" import type { RouterModels } from "@roo/api" import { useAppTranslation } from "@src/i18n/TranslationContext" diff --git a/webview-ui/src/components/settings/providers/IOIntelligence.tsx b/webview-ui/src/components/settings/providers/IOIntelligence.tsx index 81e0d546f4..4a7f74797a 100644 --- a/webview-ui/src/components/settings/providers/IOIntelligence.tsx +++ b/webview-ui/src/components/settings/providers/IOIntelligence.tsx @@ -1,9 +1,12 @@ import { useCallback } from "react" import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import { type ProviderSettings, ioIntelligenceDefaultModelId, ioIntelligenceModels } from "@roo-code/types" - -import type { OrganizationAllowList } from "@roo/cloud" +import { + type ProviderSettings, + type OrganizationAllowList, + ioIntelligenceDefaultModelId, + ioIntelligenceModels, +} from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" diff --git a/webview-ui/src/components/settings/providers/LiteLLM.tsx b/webview-ui/src/components/settings/providers/LiteLLM.tsx index 0cb3245ea4..caf7a173fe 100644 --- a/webview-ui/src/components/settings/providers/LiteLLM.tsx +++ b/webview-ui/src/components/settings/providers/LiteLLM.tsx @@ -1,9 +1,8 @@ import { useCallback, useState, useEffect, useRef } from "react" import { VSCodeTextField, VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react" -import { type ProviderSettings, litellmDefaultModelId } from "@roo-code/types" +import { type ProviderSettings, type OrganizationAllowList, litellmDefaultModelId } from "@roo-code/types" -import type { OrganizationAllowList } from "@roo/cloud" import { RouterName } from "@roo/api" import { ExtensionMessage } from "@roo/ExtensionMessage" diff --git a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx index ee462296b5..736b0253c4 100644 --- a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx +++ b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx @@ -7,11 +7,11 @@ import { type ProviderSettings, type ModelInfo, type ReasoningEffort, + type OrganizationAllowList, azureOpenAiDefaultApiVersion, openAiModelInfoSaneDefaults, } from "@roo-code/types" -import type { OrganizationAllowList } from "@roo/cloud" import { ExtensionMessage } from "@roo/ExtensionMessage" import { useAppTranslation } from "@src/i18n/TranslationContext" diff --git a/webview-ui/src/components/settings/providers/OpenRouter.tsx b/webview-ui/src/components/settings/providers/OpenRouter.tsx index cf8b54d0cd..f6cad36bf8 100644 --- a/webview-ui/src/components/settings/providers/OpenRouter.tsx +++ b/webview-ui/src/components/settings/providers/OpenRouter.tsx @@ -3,9 +3,8 @@ import { Trans } from "react-i18next" import { Checkbox } from "vscrui" import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import { type ProviderSettings, openRouterDefaultModelId } from "@roo-code/types" +import { type ProviderSettings, type OrganizationAllowList, openRouterDefaultModelId } from "@roo-code/types" -import type { OrganizationAllowList } from "@roo/cloud" import type { RouterModels } from "@roo/api" import { useAppTranslation } from "@src/i18n/TranslationContext" diff --git a/webview-ui/src/components/settings/providers/Requesty.tsx b/webview-ui/src/components/settings/providers/Requesty.tsx index f634eac890..2f531a0ecc 100644 --- a/webview-ui/src/components/settings/providers/Requesty.tsx +++ b/webview-ui/src/components/settings/providers/Requesty.tsx @@ -1,9 +1,8 @@ import { useCallback, useEffect, useState } from "react" import { VSCodeCheckbox, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import { type ProviderSettings, requestyDefaultModelId } from "@roo-code/types" +import { type ProviderSettings, type OrganizationAllowList, requestyDefaultModelId } from "@roo-code/types" -import type { OrganizationAllowList } from "@roo/cloud" import type { RouterModels } from "@roo/api" import { vscode } from "@src/utils/vscode" diff --git a/webview-ui/src/components/settings/providers/Unbound.tsx b/webview-ui/src/components/settings/providers/Unbound.tsx index 2dd105a663..0fac8e3fa1 100644 --- a/webview-ui/src/components/settings/providers/Unbound.tsx +++ b/webview-ui/src/components/settings/providers/Unbound.tsx @@ -2,9 +2,8 @@ import { useCallback, useState, useRef } from "react" import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" import { useQueryClient } from "@tanstack/react-query" -import { type ProviderSettings, unboundDefaultModelId } from "@roo-code/types" +import { type ProviderSettings, type OrganizationAllowList, unboundDefaultModelId } from "@roo-code/types" -import type { OrganizationAllowList } from "@roo/cloud" import type { RouterModels } from "@roo/api" import { useAppTranslation } from "@src/i18n/TranslationContext" diff --git a/webview-ui/src/components/settings/providers/VercelAiGateway.tsx b/webview-ui/src/components/settings/providers/VercelAiGateway.tsx index 8050469efc..e816e79e34 100644 --- a/webview-ui/src/components/settings/providers/VercelAiGateway.tsx +++ b/webview-ui/src/components/settings/providers/VercelAiGateway.tsx @@ -1,9 +1,8 @@ import { useCallback } from "react" import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import { type ProviderSettings, vercelAiGatewayDefaultModelId } from "@roo-code/types" +import { type ProviderSettings, type OrganizationAllowList, vercelAiGatewayDefaultModelId } from "@roo-code/types" -import type { OrganizationAllowList } from "@roo/cloud" import type { RouterModels } from "@roo/api" import { useAppTranslation } from "@src/i18n/TranslationContext" diff --git a/webview-ui/src/components/settings/utils/__tests__/organizationFilters.test.ts b/webview-ui/src/components/settings/utils/__tests__/organizationFilters.test.ts index 530e5d4942..84183cd3c9 100644 --- a/webview-ui/src/components/settings/utils/__tests__/organizationFilters.test.ts +++ b/webview-ui/src/components/settings/utils/__tests__/organizationFilters.test.ts @@ -1,6 +1,4 @@ -import type { ModelInfo } from "@roo-code/types" - -import type { OrganizationAllowList } from "@roo/cloud" +import type { ModelInfo, OrganizationAllowList } from "@roo-code/types" import { filterProviders, filterModels } from "../organizationFilters" diff --git a/webview-ui/src/components/settings/utils/organizationFilters.ts b/webview-ui/src/components/settings/utils/organizationFilters.ts index f6f129257a..56bd5a3c33 100644 --- a/webview-ui/src/components/settings/utils/organizationFilters.ts +++ b/webview-ui/src/components/settings/utils/organizationFilters.ts @@ -1,6 +1,4 @@ -import type { ProviderName, ModelInfo } from "@roo-code/types" - -import type { OrganizationAllowList } from "@roo/cloud" +import type { ProviderName, ModelInfo, OrganizationAllowList } from "@roo-code/types" export const filterProviders = ( providers: Array<{ value: string; label: string }>, diff --git a/webview-ui/src/components/ui/__tests__/tooltip.spec.tsx b/webview-ui/src/components/ui/__tests__/tooltip.spec.tsx index e1cb619545..97d6e37977 100644 --- a/webview-ui/src/components/ui/__tests__/tooltip.spec.tsx +++ b/webview-ui/src/components/ui/__tests__/tooltip.spec.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from "@testing-library/react" import userEvent from "@testing-library/user-event" -import { describe, it, expect } from "vitest" + import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../tooltip" import { StandardTooltip } from "../standard-tooltip" diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index e308ed5e64..bd335d7b2d 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -8,9 +8,10 @@ import { type ExperimentId, type TodoItem, type TelemetrySetting, + type OrganizationAllowList, + ORGANIZATION_ALLOW_ALL, } from "@roo-code/types" -import { type OrganizationAllowList, ORGANIZATION_ALLOW_ALL } from "@roo/cloud" import { ExtensionMessage, ExtensionState, MarketplaceInstalledMetadata, Command } from "@roo/ExtensionMessage" import { findLastIndex } from "@roo/array" import { McpServer } from "@roo/mcp" diff --git a/webview-ui/src/hooks/useEscapeKey.spec.ts b/webview-ui/src/hooks/useEscapeKey.spec.ts index 057235b7cf..2e1b5ae6d9 100644 --- a/webview-ui/src/hooks/useEscapeKey.spec.ts +++ b/webview-ui/src/hooks/useEscapeKey.spec.ts @@ -1,5 +1,5 @@ import { renderHook } from "@testing-library/react" -import { vi } from "vitest" + import { useEscapeKey } from "./useEscapeKey" describe("useEscapeKey", () => { diff --git a/webview-ui/src/utils/__tests__/command-parser.spec.ts b/webview-ui/src/utils/__tests__/command-parser.spec.ts index 05303f87fc..ede8ebe874 100644 --- a/webview-ui/src/utils/__tests__/command-parser.spec.ts +++ b/webview-ui/src/utils/__tests__/command-parser.spec.ts @@ -1,4 +1,3 @@ -import { describe, it, expect } from "vitest" import { extractPatternsFromCommand } from "../command-parser" describe("extractPatternsFromCommand", () => { diff --git a/webview-ui/src/utils/__tests__/format.spec.ts b/webview-ui/src/utils/__tests__/format.spec.ts index 4d642f3f44..2f633d364d 100644 --- a/webview-ui/src/utils/__tests__/format.spec.ts +++ b/webview-ui/src/utils/__tests__/format.spec.ts @@ -1,4 +1,3 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" import { formatLargeNumber, formatDate, formatTimeAgo } from "../format" // Mock i18next diff --git a/webview-ui/src/utils/__tests__/sourceMapUtils.spec.ts b/webview-ui/src/utils/__tests__/sourceMapUtils.spec.ts index b5bda3bcdb..d694655e92 100644 --- a/webview-ui/src/utils/__tests__/sourceMapUtils.spec.ts +++ b/webview-ui/src/utils/__tests__/sourceMapUtils.spec.ts @@ -1,4 +1,3 @@ -import { vi, describe, test, expect, beforeEach } from "vitest" import { parseStackTrace, applySourceMapsToStack, enhanceErrorWithSourceMaps } from "../sourceMapUtils" // Mock console.debug to avoid cluttering test output diff --git a/webview-ui/src/utils/__tests__/validate.test.ts b/webview-ui/src/utils/__tests__/validate.test.ts index 30ccfd4463..2f62dd181d 100644 --- a/webview-ui/src/utils/__tests__/validate.test.ts +++ b/webview-ui/src/utils/__tests__/validate.test.ts @@ -1,6 +1,5 @@ -import type { ProviderSettings } from "@roo-code/types" +import type { ProviderSettings, OrganizationAllowList } from "@roo-code/types" -import type { OrganizationAllowList } from "@roo/cloud" import { RouterModels } from "@roo/api" import { getModelValidationError, validateApiConfigurationExcludingModelErrors } from "../validate" diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index 5613eb9eb8..1cbeba76d0 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -1,8 +1,7 @@ import i18next from "i18next" -import type { ProviderSettings } from "@roo-code/types" +import type { ProviderSettings, OrganizationAllowList } from "@roo-code/types" -import type { OrganizationAllowList } from "@roo/cloud" import { isRouterName, RouterModels } from "@roo/api" export function validateApiConfiguration( diff --git a/webview-ui/tsconfig.json b/webview-ui/tsconfig.json index 122cfca0ec..c075b99612 100644 --- a/webview-ui/tsconfig.json +++ b/webview-ui/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "types": ["vitest/globals"], - "target": "es5", + "target": "ES2022", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, diff --git a/webview-ui/vite.config.ts b/webview-ui/vite.config.ts index 0579344a22..b38452a990 100644 --- a/webview-ui/vite.config.ts +++ b/webview-ui/vite.config.ts @@ -5,6 +5,7 @@ import { execSync } from "child_process" import { defineConfig, type PluginOption, type Plugin } from "vite" import react from "@vitejs/plugin-react" import tailwindcss from "@tailwindcss/vite" + import { sourcemapPlugin } from "./src/vite-plugins/sourcemapPlugin" function getGitSha() {