| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740 |
- /* 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 = <T>(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<string, any> }) => Promise<Record<string, any>>
- >(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<string, any> }) => Promise<Record<string, any>>
- >(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<string, any> }) => Promise<Record<string, any>>
- >(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("[]")
- })
- })
- })
|