From 2637e204040f18333bede3777d3aabce80297cd0 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Fri, 31 Jul 2026 03:09:33 +0000 Subject: [PATCH 1/4] fix(telemetry): default telemetry to opt-out with explicit consent UI --- PRIVACY.md | 12 +- .../telemetry.isTelemetryOptedIn.test.ts | 21 ++ packages/types/src/telemetry.ts | 12 + packages/types/src/vscode-extension-host.ts | 5 + src/__tests__/extension.spec.ts | 169 +++++++++++++- src/core/webview/ClineProvider.ts | 2 + .../telemetrySettingsTracking.spec.ts | 45 ++-- .../__tests__/webviewMessageHandler.spec.ts | 218 ++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 90 ++++++-- src/eslint-suppressions.json | 5 - src/extension.ts | 34 ++- webview-ui/src/App.tsx | 14 +- .../src/__tests__/TelemetryClient.spec.ts | 61 ++++- .../src/components/common/TelemetryBanner.tsx | 27 ++- .../common/__tests__/TelemetryBanner.spec.tsx | 65 ++++++ webview-ui/src/components/settings/About.tsx | 4 +- .../settings/__tests__/About.spec.tsx | 30 +++ webview-ui/src/i18n/locales/ca/settings.json | 4 +- webview-ui/src/i18n/locales/ca/welcome.json | 4 +- webview-ui/src/i18n/locales/de/settings.json | 4 +- webview-ui/src/i18n/locales/de/welcome.json | 4 +- webview-ui/src/i18n/locales/en/settings.json | 4 +- webview-ui/src/i18n/locales/en/welcome.json | 4 +- webview-ui/src/i18n/locales/es/settings.json | 4 +- webview-ui/src/i18n/locales/es/welcome.json | 4 +- webview-ui/src/i18n/locales/fr/settings.json | 4 +- webview-ui/src/i18n/locales/fr/welcome.json | 4 +- webview-ui/src/i18n/locales/hi/settings.json | 4 +- webview-ui/src/i18n/locales/hi/welcome.json | 4 +- webview-ui/src/i18n/locales/id/settings.json | 4 +- webview-ui/src/i18n/locales/id/welcome.json | 4 +- webview-ui/src/i18n/locales/it/settings.json | 4 +- webview-ui/src/i18n/locales/it/welcome.json | 4 +- webview-ui/src/i18n/locales/ja/settings.json | 4 +- webview-ui/src/i18n/locales/ja/welcome.json | 4 +- webview-ui/src/i18n/locales/ko/settings.json | 4 +- webview-ui/src/i18n/locales/ko/welcome.json | 4 +- webview-ui/src/i18n/locales/nl/settings.json | 4 +- webview-ui/src/i18n/locales/nl/welcome.json | 4 +- webview-ui/src/i18n/locales/pl/settings.json | 4 +- webview-ui/src/i18n/locales/pl/welcome.json | 4 +- .../src/i18n/locales/pt-BR/settings.json | 4 +- .../src/i18n/locales/pt-BR/welcome.json | 4 +- webview-ui/src/i18n/locales/ru/settings.json | 4 +- webview-ui/src/i18n/locales/ru/welcome.json | 4 +- webview-ui/src/i18n/locales/tr/settings.json | 4 +- webview-ui/src/i18n/locales/tr/welcome.json | 4 +- webview-ui/src/i18n/locales/vi/settings.json | 4 +- webview-ui/src/i18n/locales/vi/welcome.json | 4 +- .../src/i18n/locales/zh-CN/settings.json | 4 +- .../src/i18n/locales/zh-CN/welcome.json | 4 +- .../src/i18n/locales/zh-TW/settings.json | 4 +- .../src/i18n/locales/zh-TW/welcome.json | 4 +- webview-ui/src/utils/TelemetryClient.ts | 11 +- 54 files changed, 841 insertions(+), 128 deletions(-) create mode 100644 packages/types/src/__tests__/telemetry.isTelemetryOptedIn.test.ts create mode 100644 webview-ui/src/components/common/__tests__/TelemetryBanner.spec.tsx diff --git a/PRIVACY.md b/PRIVACY.md index 2f589cf3ea..741a7bdc06 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -32,14 +32,14 @@ go—and, importantly, where they don't. - **API Keys & Credentials**: If you enter an API key (e.g., to connect an AI model), it is stored locally on your device and never sent to us or any third party, except the provider you have chosen. -- **Telemetry (Usage Data)**: We collect feature usage and error data to help - us improve Zoo Code. This telemetry is powered by PostHog and includes your - VS Code machine ID, feature usage patterns, and exception reports. The VS Code +- **Telemetry (Usage Data)**: We collect feature usage and error data to help us + improve Zoo Code. This telemetry is powered by PostHog and includes your VS + Code machine ID, feature usage patterns, and exception reports. The VS Code machine ID is a persistent identifier and may be considered personal data in some jurisdictions; we use it only for product analytics and error grouping. - We retain telemetry only as long as needed for product analytics and debugging. - Telemetry does **not** collect your code or AI prompts, and you can opt out at - any time through the settings. + We retain telemetry only as long as needed for product analytics and + debugging. This PostHog-based telemetry does **not** collect your code or AI + prompts, and you can opt out at any time through the settings. - **Marketplace Requests**: When you browse or search the Marketplace for Model Configuration Profiles (MCPs) or Custom Modes, Zoo Code makes a secure API call to Zoo Code's backend servers to retrieve listing information. These diff --git a/packages/types/src/__tests__/telemetry.isTelemetryOptedIn.test.ts b/packages/types/src/__tests__/telemetry.isTelemetryOptedIn.test.ts new file mode 100644 index 0000000000..671f88cc67 --- /dev/null +++ b/packages/types/src/__tests__/telemetry.isTelemetryOptedIn.test.ts @@ -0,0 +1,21 @@ +// pnpm --filter @roo-code/types test src/__tests__/telemetry.isTelemetryOptedIn.test.ts + +import { isTelemetryOptedIn } from "../telemetry.js" + +describe("isTelemetryOptedIn", () => { + it("returns true for an explicit 'enabled' setting", () => { + expect(isTelemetryOptedIn("enabled")).toBe(true) + }) + + it("returns false for an explicit 'disabled' setting", () => { + expect(isTelemetryOptedIn("disabled")).toBe(false) + }) + + it("returns true for 'unset' (disclosed opt-out default applies)", () => { + expect(isTelemetryOptedIn("unset")).toBe(true) + }) + + it("returns true for undefined (treated the same as unset)", () => { + expect(isTelemetryOptedIn(undefined)).toBe(true) + }) +}) diff --git a/packages/types/src/telemetry.ts b/packages/types/src/telemetry.ts index 402cd571c8..d69476836e 100644 --- a/packages/types/src/telemetry.ts +++ b/packages/types/src/telemetry.ts @@ -13,6 +13,18 @@ export const telemetrySettingsSchema = z.enum(telemetrySettings) export type TelemetrySetting = z.infer +/** + * Whether telemetry should be captured for this install. + * + * Telemetry is on by default (disclosed opt-out): "unset" (no choice made yet) and + * "enabled" both mean telemetry may be captured. Only an explicit "disabled" opts out. + * The consent banner's dismiss/close action never writes a setting, so it stays neutral -- + * it just leaves the default in effect rather than recording an affirmative choice either way. + */ +export function isTelemetryOptedIn(telemetrySetting: TelemetrySetting | undefined): boolean { + return telemetrySetting !== "disabled" +} + /** * TelemetryEventName */ diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index c35a5da538..3417b6254f 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -359,6 +359,11 @@ export type ExtensionState = Pick< telemetrySetting: TelemetrySetting telemetryKey?: string machineId?: string + // Live vscode.env.isTelemetryEnabled, so the webview's own PostHog client can respect + // the VS Code global telemetry toggle the same way the extension-side gate does -- + // without this, an explicit user Accept can still send events while VS Code's global + // telemetry is disabled. + vscodeTelemetryEnabled?: boolean renderContext: "sidebar" | "editor" settingsImportedAt?: number diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index 8af352425a..e0a8cfac7d 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -36,6 +36,8 @@ vi.mock("vscode", () => ({ }, env: { language: "en", + isTelemetryEnabled: true, + onDidChangeTelemetryEnabled: vi.fn(), }, ExtensionMode: { Production: 1, @@ -72,19 +74,18 @@ vi.mock("@roo-code/cloud", () => ({ getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), })) +const mockTelemetryServiceInstance = { + register: vi.fn(), + setProvider: vi.fn(), + shutdown: vi.fn(), + updateTelemetryState: vi.fn(), +} + vi.mock("@roo-code/telemetry", () => ({ TelemetryService: { - createInstance: vi.fn().mockReturnValue({ - register: vi.fn(), - setProvider: vi.fn(), - shutdown: vi.fn(), - }), + createInstance: vi.fn().mockReturnValue(mockTelemetryServiceInstance), get instance() { - return { - register: vi.fn(), - setProvider: vi.fn(), - shutdown: vi.fn(), - } + return mockTelemetryServiceInstance }, }, PostHogTelemetryClient: vi.fn(), @@ -114,6 +115,7 @@ vi.mock("../core/config/ContextProxy", () => ({ setValue: vi.fn(), getValues: vi.fn().mockReturnValue({}), getProviderSettings: vi.fn().mockReturnValue({}), + getGlobalState: vi.fn().mockReturnValue("enabled"), }), }, })) @@ -286,7 +288,7 @@ describe("extension.ts", () => { telemetryClient: null, authService: null, hasActiveSession: vi.fn().mockReturnValue(false), - } as any + } as unknown as never }) vi.mocked(CloudService.hasInstance).mockReturnValue(true) @@ -295,7 +297,11 @@ describe("extension.ts", () => { const { activate } = await import("../extension") await activate(mockContext) - const provider = (ClineProvider as any).getVisibleInstance() + const provider = ( + ClineProvider as unknown as { + getVisibleInstance(): { postStateToWebviewWithoutClineMessages: ReturnType } + } + ).getVisibleInstance() provider.postStateToWebviewWithoutClineMessages.mockClear() await authStateChangedHandler!({ @@ -317,4 +323,143 @@ describe("extension.ts", () => { await expect(activate(mockContext)).resolves.toBeDefined() }) }) + + describe("telemetry level reactivity", () => { + beforeEach(async () => { + vi.resetModules() + const vscode = await import("vscode") + ;(vscode.env as { isTelemetryEnabled: boolean }).isTelemetryEnabled = true + }) + + test("registers a listener for vscode.env.onDidChangeTelemetryEnabled", async () => { + const vscode = await import("vscode") + + const { activate } = await import("../extension") + await activate(mockContext) + + expect(vscode.env.onDidChangeTelemetryEnabled).toHaveBeenCalledTimes(1) + expect(vscode.env.onDidChangeTelemetryEnabled).toHaveBeenCalledWith(expect.any(Function)) + }) + + test("re-evaluates telemetry state from stored settings when VS Code's global toggle changes", async () => { + const vscode = await import("vscode") + const { TelemetryService } = await import("@roo-code/telemetry") + const { ContextProxy } = await import("../core/config/ContextProxy") + + const mockContextProxyInstance = await ( + ContextProxy.getInstance as unknown as () => Promise<{ getGlobalState: ReturnType }> + )() + vi.mocked(mockContextProxyInstance.getGlobalState).mockReturnValue("enabled") + ;(vscode.env as { isTelemetryEnabled: boolean }).isTelemetryEnabled = true + + const { activate } = await import("../extension") + await activate(mockContext) + + const updateTelemetryStateMock = vi.mocked(TelemetryService.instance.updateTelemetryState) + updateTelemetryStateMock.mockClear() + + // The real vscode.env.onDidChangeTelemetryEnabled event carries no payload; the handler + // must read the current vscode.env.isTelemetryEnabled value, not any argument it's called with. + const onDidChangeHandler = vi.mocked(vscode.env.onDidChangeTelemetryEnabled).mock.calls[0][0] + onDidChangeHandler(undefined as never) + + expect(updateTelemetryStateMock).toHaveBeenCalledWith(true) + }) + + test("treats a disabled stored setting as opted out even when VS Code telemetry is enabled", async () => { + const vscode = await import("vscode") + const { TelemetryService } = await import("@roo-code/telemetry") + const { ContextProxy } = await import("../core/config/ContextProxy") + + const mockContextProxyInstance = await ( + ContextProxy.getInstance as unknown as () => Promise<{ getGlobalState: ReturnType }> + )() + vi.mocked(mockContextProxyInstance.getGlobalState).mockReturnValue("disabled") + ;(vscode.env as { isTelemetryEnabled: boolean }).isTelemetryEnabled = true + + const { activate } = await import("../extension") + await activate(mockContext) + + const updateTelemetryStateMock = vi.mocked(TelemetryService.instance.updateTelemetryState) + updateTelemetryStateMock.mockClear() + + const onDidChangeHandler = vi.mocked(vscode.env.onDidChangeTelemetryEnabled).mock.calls[0][0] + onDidChangeHandler(undefined as never) + + expect(updateTelemetryStateMock).toHaveBeenCalledWith(false) + }) + + test("treats VS Code's live telemetry-disabled signal as opted out even when the stored setting is enabled", async () => { + const vscode = await import("vscode") + const { TelemetryService } = await import("@roo-code/telemetry") + const { ContextProxy } = await import("../core/config/ContextProxy") + + const mockContextProxyInstance = await ( + ContextProxy.getInstance as unknown as () => Promise<{ getGlobalState: ReturnType }> + )() + vi.mocked(mockContextProxyInstance.getGlobalState).mockReturnValue("enabled") + ;(vscode.env as { isTelemetryEnabled: boolean }).isTelemetryEnabled = true + + const { activate } = await import("../extension") + await activate(mockContext) + + const updateTelemetryStateMock = vi.mocked(TelemetryService.instance.updateTelemetryState) + updateTelemetryStateMock.mockClear() + + // Simulate the user turning off VS Code's global telemetry toggle: the live env value + // flips before the event fires, and the handler must honor it rather than only the + // stored extension setting. + ;(vscode.env as { isTelemetryEnabled: boolean }).isTelemetryEnabled = false + + const onDidChangeHandler = vi.mocked(vscode.env.onDidChangeTelemetryEnabled).mock.calls[0][0] + onDidChangeHandler(undefined as never) + + expect(updateTelemetryStateMock).toHaveBeenCalledWith(false) + }) + + test("pushes a state update to the webview so its own PostHog client picks up the new vscode.env.isTelemetryEnabled value", async () => { + const vscode = await import("vscode") + const { ClineProvider } = await import("../core/webview/ClineProvider") + + const { activate } = await import("../extension") + await activate(mockContext) + + const visibleInstance = ( + ClineProvider as unknown as { + getVisibleInstance(): { postStateToWebviewWithoutClineMessages: ReturnType } + } + ).getVisibleInstance() + vi.mocked(visibleInstance.postStateToWebviewWithoutClineMessages).mockClear() + + const onDidChangeHandler = vi.mocked(vscode.env.onDidChangeTelemetryEnabled).mock.calls[0][0] + onDidChangeHandler(undefined as never) + + expect(visibleInstance.postStateToWebviewWithoutClineMessages).toHaveBeenCalled() + }) + }) + + describe("deactivate", () => { + beforeEach(() => { + vi.resetModules() + }) + + test("still runs terminal cleanup when telemetry shutdown rejects", async () => { + const { TelemetryService } = await import("@roo-code/telemetry") + const { Terminal } = await import("../integrations/terminal/Terminal") + const { TerminalRegistry } = await import("../integrations/terminal/TerminalRegistry") + + vi.mocked(TelemetryService.instance.shutdown).mockRejectedValue(new Error("shutdown failed")) + const setTerminalProfileSpy = vi.spyOn(Terminal, "setTerminalProfile") + + const { activate, deactivate } = await import("../extension") + await activate(mockContext) + + await expect(deactivate()).resolves.toBeUndefined() + + expect(setTerminalProfileSpy).toHaveBeenCalledWith(undefined) + expect(TerminalRegistry.cleanup).toHaveBeenCalledTimes(1) + + setTerminalProfileSpy.mockRestore() + }) + }) }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 2ee92edebc..65b2a3c0e8 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2413,6 +2413,7 @@ export class ClineProvider const telemetryKey = process.env.POSTHOG_API_KEY const machineId = vscode.env.machineId + const vscodeTelemetryEnabled = vscode.env.isTelemetryEnabled const mergedAllowedCommands = this.mergeAllowedCommands(allowedCommands) const mergedDeniedCommands = this.mergeDeniedCommands(deniedCommands) const cwd = this.cwd @@ -2513,6 +2514,7 @@ export class ClineProvider telemetrySetting, telemetryKey, machineId, + vscodeTelemetryEnabled, showRooIgnoredFiles: showRooIgnoredFiles ?? false, enableSubfolderRules: enableSubfolderRules ?? false, language: language ?? formatLanguage(vscode.env.language), diff --git a/src/core/webview/__tests__/telemetrySettingsTracking.spec.ts b/src/core/webview/__tests__/telemetrySettingsTracking.spec.ts index a99c8862a3..0f1e0e1315 100644 --- a/src/core/webview/__tests__/telemetrySettingsTracking.spec.ts +++ b/src/core/webview/__tests__/telemetrySettingsTracking.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { TelemetryService } from "@roo-code/telemetry" -import { TelemetryEventName, type TelemetrySetting } from "@roo-code/types" +import { TelemetryEventName, type TelemetrySetting, isTelemetryOptedIn } from "@roo-code/types" describe("Telemetry Settings Tracking", () => { let mockTelemetryService: { @@ -31,8 +31,8 @@ describe("Telemetry Settings Tracking", () => { const newSetting = "disabled" as TelemetrySetting // Simulate the logic from webviewMessageHandler - const isOptedIn = newSetting !== "disabled" - const wasPreviouslyOptedIn = previousSetting !== "disabled" + const isOptedIn = isTelemetryOptedIn(newSetting) + const wasPreviouslyOptedIn = isTelemetryOptedIn(previousSetting) // If turning telemetry OFF, fire event BEFORE disabling if (wasPreviouslyOptedIn && !isOptedIn && TelemetryService.hasInstance()) { @@ -50,12 +50,12 @@ describe("Telemetry Settings Tracking", () => { expect(mockTelemetryService.updateTelemetryState).toHaveBeenCalledWith(false) }) - it("should fire event when going from unset to disabled", () => { + it("should fire an opt-out event when going from unset to disabled (explicit Decline)", () => { const previousSetting = "unset" as TelemetrySetting const newSetting = "disabled" as TelemetrySetting - const isOptedIn = newSetting !== "disabled" - const wasPreviouslyOptedIn = previousSetting !== "disabled" + const isOptedIn = isTelemetryOptedIn(newSetting) + const wasPreviouslyOptedIn = isTelemetryOptedIn(previousSetting) if (wasPreviouslyOptedIn && !isOptedIn && TelemetryService.hasInstance()) { TelemetryService.instance.captureTelemetrySettingsChanged(previousSetting, newSetting) @@ -63,7 +63,10 @@ describe("Telemetry Settings Tracking", () => { TelemetryService.instance.updateTelemetryState(isOptedIn) + // "unset" is opted in under the disclosed opt-out default, so unset -> disabled + // is a genuine opt-out transition. expect(mockTelemetryService.captureTelemetrySettingsChanged).toHaveBeenCalledWith("unset", "disabled") + expect(mockTelemetryService.updateTelemetryState).toHaveBeenCalledWith(false) }) }) @@ -72,8 +75,8 @@ describe("Telemetry Settings Tracking", () => { const previousSetting = "disabled" as TelemetrySetting const newSetting = "enabled" as TelemetrySetting - const isOptedIn = newSetting !== "disabled" - const wasPreviouslyOptedIn = previousSetting !== "disabled" + const isOptedIn = isTelemetryOptedIn(newSetting) + const wasPreviouslyOptedIn = isTelemetryOptedIn(previousSetting) // Update the telemetry state first TelemetryService.instance.updateTelemetryState(isOptedIn) @@ -95,8 +98,8 @@ describe("Telemetry Settings Tracking", () => { const previousSetting = "enabled" as TelemetrySetting const newSetting = "enabled" as TelemetrySetting - const isOptedIn = newSetting !== "disabled" - const wasPreviouslyOptedIn = previousSetting !== "disabled" + const isOptedIn = isTelemetryOptedIn(newSetting) + const wasPreviouslyOptedIn = isTelemetryOptedIn(previousSetting) // Neither condition should be met if (wasPreviouslyOptedIn && !isOptedIn && TelemetryService.hasInstance()) { @@ -114,14 +117,13 @@ describe("Telemetry Settings Tracking", () => { expect(mockTelemetryService.updateTelemetryState).toHaveBeenCalledWith(true) }) - it("should fire event when going from unset to enabled (telemetry banner close)", () => { + it("should not fire an event when going from unset to enabled (already opted in by default)", () => { const previousSetting = "unset" as TelemetrySetting const newSetting = "enabled" as TelemetrySetting - const isOptedIn = newSetting !== "disabled" - const wasPreviouslyOptedIn = previousSetting !== "disabled" + const isOptedIn = isTelemetryOptedIn(newSetting) + const wasPreviouslyOptedIn = isTelemetryOptedIn(previousSetting) - // For unset -> enabled, both are opted in, so no event should fire if (wasPreviouslyOptedIn && !isOptedIn && TelemetryService.hasInstance()) { TelemetryService.instance.captureTelemetrySettingsChanged(previousSetting, newSetting) } @@ -132,8 +134,21 @@ describe("Telemetry Settings Tracking", () => { TelemetryService.instance.captureTelemetrySettingsChanged(previousSetting, newSetting) } - // unset is treated as opted-in, so no event should fire + // "unset" is already opted in under the disclosed opt-out default, so explicit + // Accept (unset -> enabled) is a no-op transition, not a new opt-in. expect(mockTelemetryService.captureTelemetrySettingsChanged).not.toHaveBeenCalled() + expect(mockTelemetryService.updateTelemetryState).toHaveBeenCalledWith(true) + }) + }) + + describe("neutral banner dismiss ('unset' left as-is)", () => { + it("leaves the disclosed opt-out default in effect while the setting remains unset", () => { + // A neutral dismiss of the consent banner sends no telemetrySetting message at + // all, so the stored setting stays "unset". Confirm "unset" alone -- with no + // transition, and no affirmative choice recorded either way -- resolves to the + // disclosed default (telemetry on) rather than silently opting the user in via + // dismissal itself. + expect(isTelemetryOptedIn("unset" as TelemetrySetting)).toBe(true) }) }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 64ad6804df..5bfa6b1b16 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -11,6 +11,10 @@ vi.mock("../../../api/providers/fetchers/lmstudio", () => ({ getLMStudioModels: vi.fn(), })) +vi.mock("../../../integrations/theme/getTheme", () => ({ + getTheme: vi.fn().mockResolvedValue({}), +})) + vi.mock("../../../integrations/openai-codex/oauth", () => ({ openAiCodexOAuthManager: { getAccessToken: vi.fn(), @@ -51,6 +55,16 @@ vi.mock("../rulesMessageHandler", () => ({ handleOpenRulesDirectory: vi.fn(), })) +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + hasInstance: vi.fn().mockReturnValue(false), + instance: { + updateTelemetryState: vi.fn(), + captureTelemetrySettingsChanged: vi.fn(), + }, + }, +})) + import type { ModelRecord } from "@roo-code/types" import { webviewMessageHandler } from "../webviewMessageHandler" @@ -127,6 +141,9 @@ vi.mock("vscode", () => { commands: { executeCommand: vi.fn().mockResolvedValue(undefined), }, + env: { + isTelemetryEnabled: true, + }, } }) @@ -1712,3 +1729,204 @@ describe("webviewMessageHandler - kimiCodeSignOut", () => { expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Kimi Code sign out failed.") }) }) + +describe("webviewMessageHandler - telemetrySetting", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(vscode.env).isTelemetryEnabled = true + }) + + // Regression test: TelemetryService.updateTelemetryState must be gated on + // vscode.env.isTelemetryEnabled in addition to the stored setting, matching + // extension.ts's onDidChangeTelemetryEnabled listener. Without this AND, a user + // clicking Accept in the webview could re-enable telemetry even while VS Code's + // global telemetry toggle is off. + it("does not enable telemetry when the user accepts but VS Code's global telemetry toggle is off", async () => { + const { TelemetryService } = await import("@roo-code/telemetry") + vi.mocked(TelemetryService.hasInstance).mockReturnValue(true) + vi.mocked(vscode.env).isTelemetryEnabled = false + vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue(undefined) + + await webviewMessageHandler(mockClineProvider, { type: "telemetrySetting", text: "enabled" }) + + expect(TelemetryService.instance.updateTelemetryState).toHaveBeenCalledWith(false) + }) + + it("enables telemetry when the user accepts and VS Code's global telemetry toggle is on", async () => { + const { TelemetryService } = await import("@roo-code/telemetry") + vi.mocked(TelemetryService.hasInstance).mockReturnValue(true) + vi.mocked(vscode.env).isTelemetryEnabled = true + vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue(undefined) + + await webviewMessageHandler(mockClineProvider, { type: "telemetrySetting", text: "enabled" }) + + expect(TelemetryService.instance.updateTelemetryState).toHaveBeenCalledWith(true) + }) + + it("keeps telemetry disabled when the user declines, regardless of VS Code's global toggle", async () => { + const { TelemetryService } = await import("@roo-code/telemetry") + vi.mocked(TelemetryService.hasInstance).mockReturnValue(true) + vi.mocked(vscode.env).isTelemetryEnabled = true + vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue(undefined) + + await webviewMessageHandler(mockClineProvider, { type: "telemetrySetting", text: "disabled" }) + + expect(TelemetryService.instance.updateTelemetryState).toHaveBeenCalledWith(false) + }) + + // Finding #12 regression: without serialization, two concurrent "telemetrySetting" messages + // each capture their own isOptedIn in a closure and apply it to TelemetryService whenever + // their own persistence write resolves -- with no ordering guarantee between the two + // invocations. A slow first write racing a fast second write could let the *first* + // message's (now-stale) intent win the live telemetry state, even though the *second* + // message reflects the user's actual final choice. + it("applies the most recently sent telemetrySetting last, even if an earlier message's write is slower", async () => { + const { TelemetryService } = await import("@roo-code/telemetry") + vi.mocked(TelemetryService.hasInstance).mockReturnValue(true) + vi.mocked(vscode.env).isTelemetryEnabled = true + + // Track the "stored" setting so the second call's getGlobalState read reflects + // whatever the first call has (or hasn't yet) written -- mirrors ContextProxy's real + // synchronous stateCache update inside setValue. + let storedSetting: string | undefined + vi.mocked(mockClineProvider.contextProxy.getValue).mockImplementation(() => storedSetting) + + let resolveSlowWrite!: () => void + const slowWrite = new Promise((resolve) => { + resolveSlowWrite = resolve + }) + + vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation(async (_key, value) => { + if (value === "disabled") { + // First message's write is slow -- resolves only after we explicitly release it + // below, once the second (fast) message has already been sent. + await slowWrite + } + storedSetting = value as string + }) + + // First message: turn telemetry off (slow write). + const first = webviewMessageHandler(mockClineProvider, { type: "telemetrySetting", text: "disabled" }) + + // Second message: turn telemetry back on (fast write), sent immediately after. + const second = webviewMessageHandler(mockClineProvider, { type: "telemetrySetting", text: "enabled" }) + + // Now let the first message's write proceed. + resolveSlowWrite() + + await Promise.all([first, second]) + + // The user's final, most-recently-sent choice was "enabled" -- the live telemetry + // state must reflect that, not "disabled" from the stale, slower first message. + const calls = vi.mocked(TelemetryService.instance.updateTelemetryState).mock.calls + expect(calls.at(-1)).toEqual([true]) + }) + + // CodeRabbit follow-up on the finding #12 fix: webviewDidLaunch's telemetry init read state + // via an async provider.getStateToPostToWebview().then(...) continuation, outside + // telemetrySettingQueue -- so it could resolve after a concurrent "telemetrySetting" message + // and clobber that message's queued (correct) update with a stale value. webviewDidLaunch now + // reads getGlobalState synchronously and is routed through the same queue. + it("does not let webviewDidLaunch's telemetry init race and clobber a concurrent telemetrySetting message", async () => { + const { TelemetryService } = await import("@roo-code/telemetry") + vi.mocked(TelemetryService.hasInstance).mockReturnValue(true) + vi.mocked(vscode.env).isTelemetryEnabled = true + + // webviewDidLaunch starts out "unset" (disclosed opt-out default -- opted in). Scoped to + // the "telemetrySetting" key specifically -- webviewDidLaunch also calls + // updateGlobalState("customModes", ...) through the same contextProxy mock, which must + // not clobber storedSetting. + let storedSetting: string | undefined = "unset" + vi.mocked(mockClineProvider.contextProxy.getValue).mockImplementation((key: string) => + key === "telemetrySetting" ? storedSetting : undefined, + ) + vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation(async (key: string, value) => { + if (key === "telemetrySetting") { + storedSetting = value as string + } + }) + + vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([]) + const providerForLaunch = mockClineProvider as unknown as { + getMcpHub: ReturnType + providerSettingsManager: { listConfig: ReturnType } + getStateToPostToWebview: ReturnType + } + providerForLaunch.getMcpHub = vi.fn().mockReturnValue(undefined) + providerForLaunch.providerSettingsManager = { + listConfig: vi.fn().mockResolvedValue(undefined), + } + + // Deferred-promise handshake instead of setTimeout delays, so ordering is enforced + // explicitly rather than by racing real clock delays. Signals when webviewDidLaunch has + // taken its (pre-fix) state snapshot -- only fires under the *old* code path + // (provider.getStateToPostToWebview().then(...)); the fix never calls it at all. + let snapshotTaken!: () => void + const snapshotTakenPromise = new Promise((resolve) => { + snapshotTaken = resolve + }) + let releaseSnapshot!: () => void + const snapshotReleased = new Promise((resolve) => { + releaseSnapshot = resolve + }) + + // Snapshots storedSetting at call time (mirroring the real ClineProvider building its + // state object synchronously before any internal awaits), signals it was taken, then + // waits until the test explicitly releases it -- by which point the concurrent + // telemetrySetting write below has already landed, making the snapshot genuinely stale + // once its .then() callback finally runs. + providerForLaunch.getStateToPostToWebview = vi.fn().mockImplementation(async () => { + const snapshot = storedSetting + snapshotTaken() + await snapshotReleased + return { telemetrySetting: snapshot } + }) + + // webviewDidLaunch fires first (e.g. webview reload) -- its telemetry init is now queued + // behind telemetrySettingQueue rather than resolving independently. + const launch = webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch" }) + + // Wait for webviewDidLaunch to either take its (pre-fix) snapshot, or flush a fixed + // number of microtask turns as a same-tick fallback for the fixed code path (which never + // triggers that signal) -- enough for its synchronous prefix (await getCustomModes(), + // await updateGlobalState()) to run, without relying on a wall-clock timer. + await Promise.race([ + snapshotTakenPromise, + (async () => { + for (let i = 0; i < 10; i++) { + await Promise.resolve() + } + })(), + ]) + + // A concurrent "telemetrySetting" message turns telemetry off, and is awaited to + // completion -- including its own updateTelemetryState(false) call -- *before* the + // deferred (pre-fix-only) snapshot below is released. Against the pre-fix code, this + // proves the snapshot it captured earlier ("unset") is genuinely stale by the time its + // .then() callback finally runs: the user's real, later choice already landed. + const disable = webviewMessageHandler(mockClineProvider, { type: "telemetrySetting", text: "disabled" }) + await disable + + // Now release the deferred snapshot so a getStateToPostToWebview() call, if the old code + // path is exercised, resolves (with its already-captured, now-stale value) only after + // the disable write above has fully landed. + const snapshotResolved = vi.mocked(providerForLaunch.getStateToPostToWebview).mock.results[0]?.value as + | Promise + | undefined + releaseSnapshot() + + await Promise.all([launch, snapshotResolved]) + + // webviewDidLaunch's telemetry init is fire-and-forget from the handler's own point of + // view (the "webviewDidLaunch" case doesn't await it), so even awaiting + // getStateToPostToWebview() directly isn't enough to observe its .then() callback -- + // flush one more microtask turn for that callback to run. + await Promise.resolve() + + // The user's explicit "disabled" choice must be the final state -- webviewDidLaunch's + // queued re-application of the (by-then-stale) "unset"/opted-in state must not run after + // and override it. + const calls = vi.mocked(TelemetryService.instance.updateTelemetryState).mock.calls + expect(calls.at(-1)).toEqual([false]) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 7009343573..578256a4ed 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -23,6 +23,7 @@ import { checkoutRestorePayloadSchema, getCompletionCheckpoint, providerIdentifiers, + isTelemetryOptedIn, } from "@roo-code/types" import { customToolRegistry } from "@roo-code/core" import { CloudService } from "@roo-code/cloud" @@ -87,6 +88,15 @@ import { getLMStudioModels } from "../../api/providers/fetchers/lmstudio" const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv"]) +// Serializes handling of "telemetrySetting" messages. Each invocation reads the previous +// setting, awaits a persistence write, then applies the new live telemetry state -- with no +// serialization, two rapid messages (e.g. a fast toggle) can interleave across those awaits: +// each invocation captures its own isOptedIn in a closure, so whichever invocation's tail end +// (TelemetryService.instance.updateTelemetryState) happens to resolve *last* wins, regardless +// of which message the user sent last. Chaining onto this promise ensures a given invocation's +// entire read-write-apply sequence completes before the next one starts. +let telemetrySettingQueue: Promise = Promise.resolve() + import { MarketplaceManager, MarketplaceItemType } from "../../services/marketplace" import { setPendingTodoList } from "../tools/UpdateTodoListTool" import { @@ -633,12 +643,27 @@ export const webviewMessageHandler = async ( ), ) - // Enable telemetry by default (when unset) or when explicitly enabled - await provider.getStateToPostToWebview().then((state) => { - const { telemetrySetting } = state - const isOptedIn = telemetrySetting !== "disabled" - TelemetryService.instance.updateTelemetryState(isOptedIn) - }) + // Telemetry is on by disclosed default: "unset" (no choice made yet) leaves that + // default in effect, same as "enabled". Only an explicit "disabled" opts out. + // vscode.env.isTelemetryEnabled is ANDed in (matching extension.ts's + // onDidChangeTelemetryEnabled listener) so a webview reload can't re-enable + // telemetry while VS Code's global toggle is off. + // + // Read the setting synchronously via getGlobalState (same as the "telemetrySetting" + // handler below) rather than awaiting provider.getStateToPostToWebview() -- that + // async gap let this continuation resolve after a concurrent "telemetrySetting" + // message's queued update and clobber it with a stale value, the same interleaving + // class of bug telemetrySettingQueue exists to prevent. Routing through the queue + // here too means webviewDidLaunch can't race a concurrent telemetrySetting message + // either. + telemetrySettingQueue = telemetrySettingQueue + .catch(() => undefined) + .then(async () => { + const telemetrySetting = getGlobalState("telemetrySetting") || "unset" + TelemetryService.instance.updateTelemetryState( + isTelemetryOptedIn(telemetrySetting) && vscode.env.isTelemetryEnabled, + ) + }) provider.isViewLaunched = true break @@ -2549,29 +2574,46 @@ export const webviewMessageHandler = async ( } break case "telemetrySetting": { - const telemetrySetting = message.text as TelemetrySetting - const previousSetting = getGlobalState("telemetrySetting") || "unset" - const isOptedIn = telemetrySetting !== "disabled" - const wasPreviouslyOptedIn = previousSetting !== "disabled" + // Chain onto the shared queue so a concurrent "telemetrySetting" message (e.g. a + // rapid toggle) can't interleave its read-write-apply sequence with this one -- see + // the telemetrySettingQueue comment above for why that matters. Swallow a prior + // link's rejection before chaining (rather than letting .then() propagate it) so one + // failed update can't permanently poison every subsequent telemetrySetting message + // for the rest of the session. + const thisUpdate = telemetrySettingQueue + .catch(() => undefined) + .then(async () => { + const telemetrySetting = message.text as TelemetrySetting + const previousSetting = getGlobalState("telemetrySetting") || "unset" + const isOptedIn = isTelemetryOptedIn(telemetrySetting) + const wasPreviouslyOptedIn = isTelemetryOptedIn(previousSetting) + + // If turning telemetry OFF, fire event BEFORE disabling + if (wasPreviouslyOptedIn && !isOptedIn && TelemetryService.hasInstance()) { + TelemetryService.instance.captureTelemetrySettingsChanged(previousSetting, telemetrySetting) + } - // If turning telemetry OFF, fire event BEFORE disabling - if (wasPreviouslyOptedIn && !isOptedIn && TelemetryService.hasInstance()) { - TelemetryService.instance.captureTelemetrySettingsChanged(previousSetting, telemetrySetting) - } + // Update the telemetry state. vscode.env.isTelemetryEnabled is ANDed in + // (matching extension.ts's onDidChangeTelemetryEnabled listener) so this can't + // re-enable telemetry while VS Code's global toggle is off -- the + // captureTelemetrySettingsChanged calls above/below still track the user's + // stored preference transition on its own, independent of that live toggle. + await updateGlobalState("telemetrySetting", telemetrySetting) - // Update the telemetry state - await updateGlobalState("telemetrySetting", telemetrySetting) + if (TelemetryService.hasInstance()) { + TelemetryService.instance.updateTelemetryState(isOptedIn && vscode.env.isTelemetryEnabled) + } - if (TelemetryService.hasInstance()) { - TelemetryService.instance.updateTelemetryState(isOptedIn) - } + // If turning telemetry ON, fire event AFTER enabling + if (!wasPreviouslyOptedIn && isOptedIn && TelemetryService.hasInstance()) { + TelemetryService.instance.captureTelemetrySettingsChanged(previousSetting, telemetrySetting) + } - // If turning telemetry ON, fire event AFTER enabling - if (!wasPreviouslyOptedIn && isOptedIn && TelemetryService.hasInstance()) { - TelemetryService.instance.captureTelemetrySettingsChanged(previousSetting, telemetrySetting) - } + await provider.postStateToWebview() + }) + telemetrySettingQueue = thisUpdate - await provider.postStateToWebview() + await thisUpdate break } case "debugSetting": { diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 608e190d04..8334363819 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -24,11 +24,6 @@ "count": 6 } }, - "__tests__/extension.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, "__tests__/history-resume-delegation.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 72 diff --git a/src/extension.ts b/src/extension.ts index f006b4b52b..add911b9a2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -16,6 +16,7 @@ if (fs.existsSync(envPath)) { } import type { CloudUserInfo, AuthState } from "@roo-code/types" +import { isTelemetryOptedIn } from "@roo-code/types" import { CloudService } from "@roo-code/cloud" import { TelemetryService, PostHogTelemetryClient } from "@roo-code/telemetry" import { customToolRegistry } from "@roo-code/core" @@ -173,6 +174,29 @@ export async function activate(context: vscode.ExtensionContext) { } const contextProxy = await ContextProxy.getInstance(context) + const updateTelemetryState = () => { + const telemetrySetting = contextProxy.getGlobalState("telemetrySetting") ?? "unset" + TelemetryService.instance.updateTelemetryState( + isTelemetryOptedIn(telemetrySetting) && vscode.env.isTelemetryEnabled, + ) + } + + updateTelemetryState() + + // React live to VS Code's global telemetry toggle (recommended over only reading + // telemetry.telemetryLevel, which PostHogTelemetryClient still checks as a secondary gate). + // vscode.env.isTelemetryEnabled is ANDed in directly because the deprecated + // telemetry.telemetryLevel setting the client checks doesn't reflect this live event. + context.subscriptions.push( + vscode.env.onDidChangeTelemetryEnabled(() => { + updateTelemetryState() + + // Push the new vscode.env.isTelemetryEnabled value to the webview too, so its + // own PostHog client (gated separately in TelemetryClient.ts) can't keep + // sending events after the global toggle flips off mid-session. + void ClineProvider.getVisibleInstance()?.postStateToWebviewWithoutClineMessages() + }), + ) // Initialize code index managers for all workspace folders. const codeIndexManagers: CodeIndexManager[] = [] @@ -388,7 +412,15 @@ export async function deactivate() { } await McpServerManager.cleanup(extensionContext) - TelemetryService.instance.shutdown() + + try { + await TelemetryService.instance.shutdown() + } catch (error) { + outputChannel.appendLine( + `Failed to shut down telemetry service: ${error instanceof Error ? error.message : String(error)}`, + ) + } + Terminal.setTerminalProfile(undefined) TerminalRegistry.cleanup() } diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 0521499dbb..9f1897e2af 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -59,6 +59,7 @@ const App = () => { telemetrySetting, telemetryKey, machineId, + vscodeTelemetryEnabled, renderContext, mdmCompliant, } = useExtensionState() @@ -186,9 +187,18 @@ const App = () => { useEffect(() => { if (didHydrateState) { - telemetryClient.updateTelemetryState(telemetrySetting, telemetryKey, machineId) + // vscodeTelemetryEnabled defaults to true when the extension hasn't sent a value + // yet (e.g. an old extension build during a mixed rollout), matching VS Code's own + // default -- it must never silently disable telemetry just because the field is + // momentarily undefined during hydration. + telemetryClient.updateTelemetryState( + telemetrySetting, + telemetryKey, + machineId, + vscodeTelemetryEnabled ?? true, + ) } - }, [telemetrySetting, telemetryKey, machineId, didHydrateState]) + }, [telemetrySetting, telemetryKey, machineId, vscodeTelemetryEnabled, didHydrateState]) // Tell the extension that we are ready to receive messages. useEffect(() => vscode.postMessage({ type: "webviewDidLaunch" }), []) diff --git a/webview-ui/src/__tests__/TelemetryClient.spec.ts b/webview-ui/src/__tests__/TelemetryClient.spec.ts index 968de4fdd6..0e1617a65e 100644 --- a/webview-ui/src/__tests__/TelemetryClient.spec.ts +++ b/webview-ui/src/__tests__/TelemetryClient.spec.ts @@ -77,13 +77,72 @@ describe("TelemetryClient", () => { expect(posthog.init).not.toHaveBeenCalled() }) - it("doesn't initialize PostHog when telemetry is unset", () => { + it("initializes PostHog when telemetry is unset and credentials are present (disclosed opt-out default)", () => { + // Arrange + const API_KEY = "test-api-key" + const DISTINCT_ID = "test-user-id" + + // Act + telemetryClient.updateTelemetryState("unset", API_KEY, DISTINCT_ID) + + // Assert + expect(posthog.init).toHaveBeenCalledWith( + API_KEY, + expect.objectContaining({ + api_host: "https://us.i.posthog.com", + persistence: "localStorage", + loaded: expect.any(Function), + }), + ) + }) + + it("doesn't initialize PostHog when telemetry is unset but credentials are missing", () => { // Act telemetryClient.updateTelemetryState("unset") // Assert expect(posthog.init).not.toHaveBeenCalled() }) + + it("doesn't initialize PostHog when vscode.env.isTelemetryEnabled is false, even with an explicit Accept", () => { + // Regression test: the webview's own PostHog client must respect the live VS + // Code global telemetry toggle the same way the extension-side gate does -- + // otherwise a user's explicit Accept can still send events while VS Code's + // global telemetry is disabled. + const API_KEY = "test-api-key" + const DISTINCT_ID = "test-user-id" + + telemetryClient.updateTelemetryState("enabled", API_KEY, DISTINCT_ID, false) + + expect(posthog.init).not.toHaveBeenCalled() + }) + + it("initializes PostHog when vscode.env.isTelemetryEnabled is true and the user opted in", () => { + const API_KEY = "test-api-key" + const DISTINCT_ID = "test-user-id" + + telemetryClient.updateTelemetryState("enabled", API_KEY, DISTINCT_ID, true) + + expect(posthog.init).toHaveBeenCalledWith( + API_KEY, + expect.objectContaining({ + api_host: "https://us.i.posthog.com", + persistence: "localStorage", + loaded: expect.any(Function), + }), + ) + }) + + it("defaults vscodeTelemetryEnabled to true when the argument is omitted", () => { + // Callers on an old build (or during a state-hydration race) that don't yet + // pass this argument must not have telemetry silently disabled as a side effect. + const API_KEY = "test-api-key" + const DISTINCT_ID = "test-user-id" + + telemetryClient.updateTelemetryState("enabled", API_KEY, DISTINCT_ID) + + expect(posthog.init).toHaveBeenCalled() + }) }) /** diff --git a/webview-ui/src/components/common/TelemetryBanner.tsx b/webview-ui/src/components/common/TelemetryBanner.tsx index 3d39b17115..99d7ba329f 100644 --- a/webview-ui/src/components/common/TelemetryBanner.tsx +++ b/webview-ui/src/components/common/TelemetryBanner.tsx @@ -1,6 +1,6 @@ import { memo, useState } from "react" import { Trans } from "react-i18next" -import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" +import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react" import type { TelemetrySetting } from "@roo-code/types" @@ -11,11 +11,24 @@ const TelemetryBanner = () => { const { t } = useAppTranslation() const [isDismissed, setIsDismissed] = useState(false) + // A neutral dismiss ("x") intentionally sends no message, leaving the setting + // "unset" so the disclosed opt-out default (telemetry on) stays in effect. + // Only an explicit Decline (-> "disabled") opts out. Only the explicit + // Accept/Decline actions record the user's choice. const handleClose = () => { setIsDismissed(true) + } + + const handleAccept = () => { + setIsDismissed(true) vscode.postMessage({ type: "telemetrySetting", text: "enabled" satisfies TelemetrySetting }) } + const handleDecline = () => { + setIsDismissed(true) + vscode.postMessage({ type: "telemetrySetting", text: "disabled" satisfies TelemetrySetting }) + } + const handleOpenSettings = () => { window.postMessage({ type: "action", @@ -30,7 +43,7 @@ const TelemetryBanner = () => { return (
- {/* Close button (X) */} + {/* Close button (X) - neutral dismiss, does not record a choice */}
{t("welcome:telemetry.helpImprove")}
-
+
{ }} />
+
+ + {t("welcome:telemetry.accept")} + + + {t("welcome:telemetry.decline")} + +
) } diff --git a/webview-ui/src/components/common/__tests__/TelemetryBanner.spec.tsx b/webview-ui/src/components/common/__tests__/TelemetryBanner.spec.tsx new file mode 100644 index 0000000000..309e09f2a9 --- /dev/null +++ b/webview-ui/src/components/common/__tests__/TelemetryBanner.spec.tsx @@ -0,0 +1,65 @@ +import { render, screen, fireEvent } from "@testing-library/react" +import { describe, it, expect, vi, beforeEach } from "vitest" + +import TelemetryBanner from "../TelemetryBanner" + +const mockPostMessage = vi.fn() +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: (message: any) => mockPostMessage(message), + }, +})) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + "welcome:telemetry.helpImprove": "Help Improve Zoo Code", + "welcome:telemetry.helpImproveMessage": "Zoo Code collects error and usage data...", + "welcome:telemetry.accept": "Accept", + "welcome:telemetry.decline": "Decline", + } + return translations[key] || key + }, + }), +})) + +describe("TelemetryBanner", () => { + beforeEach(() => { + mockPostMessage.mockClear() + }) + + it("renders explicit Accept and Decline actions", () => { + render() + + expect(screen.getByRole("button", { name: "Accept" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Decline" })).toBeInTheDocument() + }) + + it("sends an enabled setting and dismisses when Accept is clicked", () => { + const { container } = render() + + fireEvent.click(screen.getByRole("button", { name: "Accept" })) + + expect(mockPostMessage).toHaveBeenCalledWith({ type: "telemetrySetting", text: "enabled" }) + expect(container.firstChild).toBeNull() + }) + + it("sends a disabled setting and dismisses when Decline is clicked", () => { + const { container } = render() + + fireEvent.click(screen.getByRole("button", { name: "Decline" })) + + expect(mockPostMessage).toHaveBeenCalledWith({ type: "telemetrySetting", text: "disabled" }) + expect(container.firstChild).toBeNull() + }) + + it("dismisses without sending any message when the close (x) button is clicked", () => { + const { container } = render() + + fireEvent.click(screen.getByRole("button", { name: /close/i })) + + expect(mockPostMessage).not.toHaveBeenCalled() + expect(container.firstChild).toBeNull() + }) +}) diff --git a/webview-ui/src/components/settings/About.tsx b/webview-ui/src/components/settings/About.tsx index 7459d5b8df..f9075cd558 100644 --- a/webview-ui/src/components/settings/About.tsx +++ b/webview-ui/src/components/settings/About.tsx @@ -4,7 +4,7 @@ import { Trans } from "react-i18next" import { ArrowRightLeft, Download, Upload, TriangleAlert, Bug, Lightbulb, Shield, MessagesSquare } from "lucide-react" import { VSCodeButton, VSCodeCheckbox, VSCodeLink } from "@vscode/webview-ui-toolkit/react" -import type { ExtensionMessage, TelemetrySetting } from "@roo-code/types" +import { type ExtensionMessage, type TelemetrySetting, isTelemetryOptedIn } from "@roo-code/types" import { Package } from "@roo/package" @@ -108,7 +108,7 @@ export const About = ({ telemetrySetting, setTelemetrySetting, debug, setDebug, section="about" label={t("settings:footer.telemetry.label")}> { const checked = e.target.checked === true setTelemetrySetting(checked ? "enabled" : "disabled") diff --git a/webview-ui/src/components/settings/__tests__/About.spec.tsx b/webview-ui/src/components/settings/__tests__/About.spec.tsx index 5266be8bae..e9ca86be37 100644 --- a/webview-ui/src/components/settings/__tests__/About.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/About.spec.tsx @@ -151,6 +151,36 @@ describe("About", () => { ) }) + it("shows the telemetry checkbox as checked when the setting is explicitly enabled", () => { + render( + + + , + ) + + expect(screen.getByRole("checkbox", { name: /telemetry/i })).toBeChecked() + }) + + it("shows the telemetry checkbox as checked when the setting is unset (disclosed opt-out default)", () => { + render( + + + , + ) + + expect(screen.getByRole("checkbox", { name: /telemetry/i })).toBeChecked() + }) + + it("does not show the telemetry checkbox as checked when the setting is disabled", () => { + render( + + + , + ) + + expect(screen.getByRole("checkbox", { name: /telemetry/i })).not.toBeChecked() + }) + it("renders export, import, and reset buttons", () => { render( diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index b9644e0d12..b80d370714 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -976,8 +976,8 @@ }, "footer": { "telemetry": { - "label": "Permetre informes anònims d'errors i ús", - "description": "Ajuda a millorar Zoo Code enviant dades d'ús anònimes i informes d'error. Aquesta telemetria no recull codi, prompts o informació personal. Consulta la nostra política de privacitat per a més detalls. Pots desactivar-ho en qualsevol moment." + "label": "Permetre informes d'errors i ús", + "description": "Ajuda a millorar Zoo Code enviant dades d'ús i informes d'error, vinculades a un identificador d'instal·lació en lloc del teu nom o compte. Aquesta telemetria no recull el teu codi ni les teves indicacions. Consulta la nostra política de privacitat per a més detalls. Pots desactivar-ho en qualsevol moment." }, "settings": { "import": "Importar", diff --git a/webview-ui/src/i18n/locales/ca/welcome.json b/webview-ui/src/i18n/locales/ca/welcome.json index 06b3fca6ba..6e631fd1fb 100644 --- a/webview-ui/src/i18n/locales/ca/welcome.json +++ b/webview-ui/src/i18n/locales/ca/welcome.json @@ -12,7 +12,9 @@ }, "telemetry": { "helpImprove": "Ajuda a millorar Zoo Code", - "helpImproveMessage": "Zoo Code recopila dades d'errors i ús per ajudar-nos a corregir errors i millorar l'extensió. Aquesta telemetria no recopila codi, indicacions o informació personal. Pots desactivar-la a configuració." + "helpImproveMessage": "Zoo Code recopila dades d'errors i ús, vinculades a un identificador d'instal·lació, per ajudar-nos a corregir errors i millorar l'extensió. Aquesta telemetria no recopila el teu codi ni les teves indicacions. Pots desactivar-la a configuració.", + "accept": "Accepta", + "decline": "Rebutja" }, "importSettings": "Importa la configuració" } diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 46a8bd28e4..20d09b8cff 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -976,8 +976,8 @@ }, "footer": { "telemetry": { - "label": "Anonyme Fehler- und Nutzungsberichte zulassen", - "description": "Hilf mit, Zoo Code zu verbessern, indem du anonyme Nutzungsdaten und Fehlerberichte sendest. Diese Telemetrie sammelt keine Code-, Prompt- oder persönliche Informationen. Weitere Einzelheiten findest du in unserer Datenschutzrichtlinie." + "label": "Fehler- und Nutzungsberichte zulassen", + "description": "Hilf mit, Zoo Code zu verbessern, indem du Nutzungsdaten und Fehlerberichte sendest, die mit einer installationsbezogenen Kennung statt mit deinem Namen oder Konto verknüpft sind. Diese Telemetrie sammelt nicht deinen Code oder deine Prompts. Weitere Einzelheiten findest du in unserer Datenschutzrichtlinie." }, "settings": { "import": "Importieren", diff --git a/webview-ui/src/i18n/locales/de/welcome.json b/webview-ui/src/i18n/locales/de/welcome.json index 99eef8b9e7..5e1107bb05 100644 --- a/webview-ui/src/i18n/locales/de/welcome.json +++ b/webview-ui/src/i18n/locales/de/welcome.json @@ -12,7 +12,9 @@ }, "telemetry": { "helpImprove": "Hilf, Zoo Code zu verbessern", - "helpImproveMessage": "Zoo Code sammelt Fehler- und Nutzungsdaten, um uns dabei zu helfen, Fehler zu beheben und die Erweiterung zu verbessern. Diese Telemetrie sammelt keine Code-, Prompt- oder persönliche Informationen. Du kannst diese in den Einstellungen deaktivieren." + "helpImproveMessage": "Zoo Code sammelt Fehler- und Nutzungsdaten, verknüpft mit einer installationsbezogenen Kennung, um uns dabei zu helfen, Fehler zu beheben und die Erweiterung zu verbessern. Diese Telemetrie sammelt nicht deinen Code oder deine Prompts. Du kannst dies in den Einstellungen deaktivieren.", + "accept": "Akzeptieren", + "decline": "Ablehnen" }, "importSettings": "Einstellungen importieren" } diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 37d33f8172..e10753199c 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -1056,8 +1056,8 @@ }, "footer": { "telemetry": { - "label": "Allow anonymous error and usage reporting", - "description": "Help improve Zoo Code by sending anonymous usage data and error reports. This telemetry does not collect code, prompts or personal information. See our privacy policy for more details." + "label": "Allow error and usage reporting", + "description": "Help improve Zoo Code by sending usage data and error reports, linked to a per-install identifier rather than your name or account. This telemetry does not collect your code or prompts. See our privacy policy for more details." }, "settings": { "import": "Import", diff --git a/webview-ui/src/i18n/locales/en/welcome.json b/webview-ui/src/i18n/locales/en/welcome.json index f6bd95ee1c..6a6e977ab7 100644 --- a/webview-ui/src/i18n/locales/en/welcome.json +++ b/webview-ui/src/i18n/locales/en/welcome.json @@ -12,7 +12,9 @@ }, "telemetry": { "helpImprove": "Help Improve Zoo Code", - "helpImproveMessage": "Zoo Code collects error and usage data to help us fix bugs and improve the extension. This telemetry does not collect code, prompts or personal information. You can turn this off in settings." + "helpImproveMessage": "Zoo Code collects error and usage data, linked to a per-install identifier, to help us fix bugs and improve the extension. This telemetry does not collect your code or prompts. You can turn this off in settings.", + "accept": "Accept", + "decline": "Decline" }, "importSettings": "Import Settings" } diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index f9c1be76f4..47b626504e 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -976,8 +976,8 @@ }, "footer": { "telemetry": { - "label": "Permitir informes anónimos de errores y uso", - "description": "Ayuda a mejorar Zoo Code enviando datos de uso anónimos e informes de errores. Esta telemetría no recopila código, prompts o información personal. Consulta nuestra política de privacidad para más detalles." + "label": "Permitir informes de errores y uso", + "description": "Ayuda a mejorar Zoo Code enviando datos de uso e informes de errores, vinculados a un identificador de instalación en lugar de tu nombre o cuenta. Esta telemetría no recopila tu código ni tus prompts. Consulta nuestra política de privacidad para más detalles." }, "settings": { "import": "Importar", diff --git a/webview-ui/src/i18n/locales/es/welcome.json b/webview-ui/src/i18n/locales/es/welcome.json index ddc8b358fe..40820ca451 100644 --- a/webview-ui/src/i18n/locales/es/welcome.json +++ b/webview-ui/src/i18n/locales/es/welcome.json @@ -12,7 +12,9 @@ }, "telemetry": { "helpImprove": "Ayuda a mejorar Zoo Code", - "helpImproveMessage": "Zoo Code recopila datos de errores y uso para ayudarnos a corregir errores y mejorar la extensión. Esta telemetría no recopila código, prompts o información personal. Puedes desactivar esto en la configuración." + "helpImproveMessage": "Zoo Code recopila datos de errores y uso, vinculados a un identificador de instalación, para ayudarnos a corregir errores y mejorar la extensión. Esta telemetría no recopila tu código ni tus prompts. Puedes desactivar esto en la configuración.", + "accept": "Aceptar", + "decline": "Rechazar" }, "importSettings": "Importar configuración" } diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 6da211144b..8000dcc8a7 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -976,8 +976,8 @@ }, "footer": { "telemetry": { - "label": "Autoriser les rapports anonymes d'erreurs et d'utilisation", - "description": "Aidez à améliorer Zoo Code en envoyant des données d'utilisation anonymes et des rapports d'erreurs. Cette télémétrie ne collecte pas de code, de prompts ou d'informations personnelles. Consultez notre politique de confidentialité pour plus de détails." + "label": "Autoriser les rapports d'erreurs et d'utilisation", + "description": "Aidez à améliorer Zoo Code en envoyant des données d'utilisation et des rapports d'erreurs, associés à un identifiant lié à l'installation plutôt qu'à votre nom ou votre compte. Cette télémétrie ne collecte pas votre code ni vos prompts. Consultez notre politique de confidentialité pour plus de détails." }, "settings": { "import": "Importer", diff --git a/webview-ui/src/i18n/locales/fr/welcome.json b/webview-ui/src/i18n/locales/fr/welcome.json index b9af536a6b..7a0f14b7df 100644 --- a/webview-ui/src/i18n/locales/fr/welcome.json +++ b/webview-ui/src/i18n/locales/fr/welcome.json @@ -12,7 +12,9 @@ }, "telemetry": { "helpImprove": "Aide à améliorer Zoo Code", - "helpImproveMessage": "Zoo Code collecte des données d'erreurs et d'utilisation pour nous aider à corriger les bugs et améliorer l'extension. Cette télémétrie ne collecte pas de code, de prompts ou d'informations personnelles. Tu peux désactiver ceci dans les paramètres." + "helpImproveMessage": "Zoo Code collecte des données d'erreurs et d'utilisation, associées à un identifiant lié à l'installation, pour nous aider à corriger les bugs et améliorer l'extension. Cette télémétrie ne collecte pas ton code ni tes prompts. Tu peux désactiver ceci dans les paramètres.", + "accept": "Accepter", + "decline": "Refuser" }, "importSettings": "Importer les paramètres" } diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 522c68a586..7895149bb8 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -976,8 +976,8 @@ }, "footer": { "telemetry": { - "label": "गुमनाम त्रुटि और उपयोग रिपोर्टिंग की अनुमति दें", - "description": "गुमनाम उपयोग डेटा और त्रुटि रिपोर्ट भेजकर Zoo Code को बेहतर बनाने में मदद करें। यह टेलीमेट्री कोड, प्रॉम्प्ट या व्यक्तिगत जानकारी एकत्र नहीं करती। अधिक विवरण के लिए हमारी गोपनीयता नीति देखें। आप इसे कभी भी बंद कर सकते हैं।" + "label": "त्रुटि और उपयोग रिपोर्टिंग की अनुमति दें", + "description": "आपके नाम या खाते के बजाय एक इंस्टॉल-लिंक्ड पहचानकर्ता से जुड़े उपयोग डेटा और त्रुटि रिपोर्ट भेजकर Zoo Code को बेहतर बनाने में मदद करें। यह टेलीमेट्री आपका कोड या प्रॉम्प्ट एकत्र नहीं करती। अधिक विवरण के लिए हमारी गोपनीयता नीति देखें। आप इसे कभी भी बंद कर सकते हैं।" }, "settings": { "import": "इम्पोर्ट", diff --git a/webview-ui/src/i18n/locales/hi/welcome.json b/webview-ui/src/i18n/locales/hi/welcome.json index 3c3028fc0c..c683ca9818 100644 --- a/webview-ui/src/i18n/locales/hi/welcome.json +++ b/webview-ui/src/i18n/locales/hi/welcome.json @@ -12,7 +12,9 @@ }, "telemetry": { "helpImprove": "Zoo Code को बेहतर बनाने में मदद करें", - "helpImproveMessage": "Zoo Code बग ठीक करने और एक्सटेंशन को बेहतर बनाने में हमारी मदद करने के लिए त्रुटि और उपयोग डेटा एकत्र करता है। यह दूरसंचार कोड, प्रॉम्प्ट या व्यक्तिगत जानकारी एकत्र नहीं करता है। आप इसे सेटिंग्स में अक्षम कर सकते हैं।" + "helpImproveMessage": "Zoo Code बग ठीक करने और एक्सटेंशन को बेहतर बनाने में हमारी मदद करने के लिए, एक इंस्टॉल-लिंक्ड पहचानकर्ता से जुड़ा त्रुटि और उपयोग डेटा एकत्र करता है। यह टेलीमेट्री आपका कोड या प्रॉम्प्ट एकत्र नहीं करती है। आप इसे सेटिंग्स में अक्षम कर सकते हैं।", + "accept": "स्वीकार करें", + "decline": "अस्वीकार करें" }, "importSettings": "सेटिंग्स आयात करें" } diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index b2943e63bc..ae4de8e84d 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -976,8 +976,8 @@ }, "footer": { "telemetry": { - "label": "Izinkan pelaporan error dan penggunaan anonim", - "description": "Bantu tingkatkan Zoo Code dengan mengirimkan data penggunaan anonim dan laporan error. Telemetri ini tidak mengumpulkan kode, prompt, atau informasi pribadi. Lihat kebijakan privasi kami untuk detail lebih lanjut. Anda dapat menonaktifkannya kapan saja." + "label": "Izinkan pelaporan error dan penggunaan", + "description": "Bantu tingkatkan Zoo Code dengan mengirimkan data penggunaan dan laporan error, yang terkait dengan pengenal instalasi, bukan nama atau akun Anda. Telemetri ini tidak mengumpulkan kode atau prompt Anda. Lihat kebijakan privasi kami untuk detail lebih lanjut. Anda dapat menonaktifkannya kapan saja." }, "settings": { "import": "Impor", diff --git a/webview-ui/src/i18n/locales/id/welcome.json b/webview-ui/src/i18n/locales/id/welcome.json index 9b7acd777f..430c1ae89f 100644 --- a/webview-ui/src/i18n/locales/id/welcome.json +++ b/webview-ui/src/i18n/locales/id/welcome.json @@ -12,7 +12,9 @@ }, "telemetry": { "helpImprove": "Bantu Tingkatkan Zoo Code", - "helpImproveMessage": "Zoo Code mengumpulkan data kesalahan dan penggunaan untuk membantu kami memperbaiki bug dan meningkatkan ekstensi. Telemetri ini tidak mengumpulkan kode, prompt, atau informasi pribadi. Anda dapat menonaktifkannya di pengaturan." + "helpImproveMessage": "Zoo Code mengumpulkan data kesalahan dan penggunaan, yang terkait dengan pengenal instalasi, untuk membantu kami memperbaiki bug dan meningkatkan ekstensi. Telemetri ini tidak mengumpulkan kode atau prompt Anda. Anda dapat menonaktifkannya di pengaturan.", + "accept": "Terima", + "decline": "Tolak" }, "importSettings": "Impor Pengaturan" } diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 6bd638c796..c39e0ef6b3 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -976,8 +976,8 @@ }, "footer": { "telemetry": { - "label": "Consenti segnalazioni anonime di errori e utilizzo", - "description": "Aiuta a migliorare Zoo Code inviando dati di utilizzo anonimi e segnalazioni di errori. Questa telemetria non raccoglie codice, prompt o informazioni personali. Consulta la nostra informativa sulla privacy per maggiori dettagli." + "label": "Consenti segnalazioni di errori e utilizzo", + "description": "Aiuta a migliorare Zoo Code inviando dati di utilizzo e segnalazioni di errori, collegati a un identificatore di installazione anziché al tuo nome o account. Questa telemetria non raccoglie il tuo codice o i tuoi prompt. Consulta la nostra informativa sulla privacy per maggiori dettagli. Puoi disattivarla in qualsiasi momento." }, "settings": { "import": "Importa", diff --git a/webview-ui/src/i18n/locales/it/welcome.json b/webview-ui/src/i18n/locales/it/welcome.json index c5b4066964..cd7d418f76 100644 --- a/webview-ui/src/i18n/locales/it/welcome.json +++ b/webview-ui/src/i18n/locales/it/welcome.json @@ -12,7 +12,9 @@ }, "telemetry": { "helpImprove": "Aiuta a migliorare Zoo Code", - "helpImproveMessage": "Zoo Code raccoglie dati di errore e utilizzo per aiutarci a correggere i bug e migliorare l'estensione. Questa telemetria non raccoglie codice, prompt o informazioni personali. Puoi disabilitare questa funzione in impostazioni." + "helpImproveMessage": "Zoo Code raccoglie dati di errore e utilizzo, collegati a un identificatore di installazione, per aiutarci a correggere i bug e migliorare l'estensione. Questa telemetria non raccoglie il tuo codice o i tuoi prompt. Puoi disabilitare questa funzione in impostazioni.", + "accept": "Accetta", + "decline": "Rifiuta" }, "importSettings": "Importa impostazioni" } diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 96fba3521a..7322e0105e 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -976,8 +976,8 @@ }, "footer": { "telemetry": { - "label": "匿名のエラーと使用状況レポートを許可", - "description": "匿名の使用データとエラーレポートを送信してZoo Codeの改善にご協力ください。このテレメトリはコード、プロンプト、個人情報を収集しません。詳細についてはプライバシーポリシーをご覧ください。" + "label": "エラーと使用状況レポートを許可", + "description": "お名前やアカウントではなく、インストールに紐づく識別子と関連付けた使用データとエラーレポートを送信してZoo Codeの改善にご協力ください。このテレメトリはあなたのコードやプロンプトを収集しません。詳細についてはプライバシーポリシーをご覧ください。これはいつでも無効にできます。" }, "settings": { "import": "インポート", diff --git a/webview-ui/src/i18n/locales/ja/welcome.json b/webview-ui/src/i18n/locales/ja/welcome.json index 55e8b4e3dd..675a73a2eb 100644 --- a/webview-ui/src/i18n/locales/ja/welcome.json +++ b/webview-ui/src/i18n/locales/ja/welcome.json @@ -12,7 +12,9 @@ }, "telemetry": { "helpImprove": "Zoo Codeの改善にご協力ください", - "helpImproveMessage": "Zoo Codeは、バグを修正し、拡張機能を改善するために、エラーおよび使用データを収集します。このテレメトリはコード、プロンプト、または個人情報を収集しません。これは設定で無効にできます。" + "helpImproveMessage": "Zoo Codeは、バグを修正し、拡張機能を改善するために、インストールに紐づく識別子と関連付けたエラーおよび使用データを収集します。このテレメトリはあなたのコードやプロンプトを収集しません。これは設定で無効にできます。", + "accept": "同意する", + "decline": "拒否する" }, "importSettings": "設定のインポート" } diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 40313a32f5..2e0debf697 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -976,8 +976,8 @@ }, "footer": { "telemetry": { - "label": "익명 오류 및 사용 보고 허용", - "description": "익명 사용 데이터 및 오류 보고서를 전송하여 Zoo Code 개선에 도움을 주세요. 이 텔레메트리는 코드, 프롬프트 또는 개인 정보를 수집하지 않습니다. 자세한 내용은 개인정보 보호정책을 참조하세요." + "label": "오류 및 사용 보고 허용", + "description": "사용자의 이름이나 계정이 아닌 설치 식별자와 연결된 사용 데이터 및 오류 보고서를 전송하여 Zoo Code 개선에 도움을 주세요. 이 텔레메트리는 사용자의 코드나 프롬프트를 수집하지 않습니다. 자세한 내용은 개인정보 보호정책을 참조하세요. 언제든지 비활성화할 수 있습니다." }, "settings": { "import": "가져오기", diff --git a/webview-ui/src/i18n/locales/ko/welcome.json b/webview-ui/src/i18n/locales/ko/welcome.json index db9407a20a..9c7fe439dd 100644 --- a/webview-ui/src/i18n/locales/ko/welcome.json +++ b/webview-ui/src/i18n/locales/ko/welcome.json @@ -12,7 +12,9 @@ }, "telemetry": { "helpImprove": "Zoo Code 개선에 도움주세요", - "helpImproveMessage": "Zoo Code는 버그를 수정하고 확장을 개선하기 위해 오류 및 사용 데이터를 수집합니다. 이 원격 분석은 코드, 프롬프트 또는 개인 정보를 수집하지 않습니다. 설정에서 이를 비활성화할 수 있습니다." + "helpImproveMessage": "Zoo Code는 버그를 수정하고 확장을 개선하기 위해, 설치 식별자와 연결된 오류 및 사용 데이터를 수집합니다. 이 원격 분석은 사용자의 코드나 프롬프트를 수집하지 않습니다. 설정에서 이를 비활성화할 수 있습니다.", + "accept": "수락", + "decline": "거부" }, "importSettings": "설정 가져오기" } diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 6664eb3d74..710d206020 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -976,8 +976,8 @@ }, "footer": { "telemetry": { - "label": "Anonieme fout- en gebruiksrapportage toestaan", - "description": "Help Zoo Code te verbeteren door anonieme gebruiksgegevens en foutmeldingen te versturen. Deze telemetrie verzamelt geen code, prompts of persoonlijke informatie. Zie ons privacybeleid voor meer details." + "label": "Fout- en gebruiksrapportage toestaan", + "description": "Help Zoo Code te verbeteren door gebruiksgegevens en foutmeldingen te versturen, gekoppeld aan een installatie-identifier in plaats van je naam of account. Deze telemetrie verzamelt niet je code of prompts. Zie ons privacybeleid voor meer details." }, "settings": { "import": "Importeren", diff --git a/webview-ui/src/i18n/locales/nl/welcome.json b/webview-ui/src/i18n/locales/nl/welcome.json index d62f761496..6662db31c3 100644 --- a/webview-ui/src/i18n/locales/nl/welcome.json +++ b/webview-ui/src/i18n/locales/nl/welcome.json @@ -12,7 +12,9 @@ }, "telemetry": { "helpImprove": "Help Zoo Code te verbeteren", - "helpImproveMessage": "Zoo Code verzamelt fout- en gebruiksgegevens om ons te helpen bugs op te lossen en de extensie te verbeteren. Deze telemetrie verzamelt geen code, prompts of persoonlijke informatie. Je kunt dit in instellingen uitschakelen." + "helpImproveMessage": "Zoo Code verzamelt fout- en gebruiksgegevens, gekoppeld aan een installatie-identifier, om ons te helpen bugs op te lossen en de extensie te verbeteren. Deze telemetrie verzamelt niet je code of prompts. Je kunt dit in instellingen uitschakelen.", + "accept": "Accepteren", + "decline": "Weigeren" }, "importSettings": "Instellingen importeren" } diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 7a7ab3dcb0..77378a4982 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -976,8 +976,8 @@ }, "footer": { "telemetry": { - "label": "Zezwól na anonimowe raportowanie błędów i użycia", - "description": "Pomóż ulepszyć Zoo Code, wysyłając anonimowe dane użytkowania i raporty błędów. Ta telemetria nie zbiera kodu, promptów ani danych osobowych. Zobacz naszą politykę prywatności, aby uzyskać więcej szczegółów. Możesz to wyłączyć w dowolnym momencie." + "label": "Zezwól na raportowanie błędów i użycia", + "description": "Pomóż ulepszyć Zoo Code, wysyłając dane użytkowania i raporty błędów powiązane z identyfikatorem instalacji, a nie z Twoim imieniem czy kontem. Ta telemetria nie zbiera Twojego kodu ani podpowiedzi. Zobacz naszą politykę prywatności, aby uzyskać więcej szczegółów. Możesz to wyłączyć w dowolnym momencie." }, "settings": { "import": "Importuj", diff --git a/webview-ui/src/i18n/locales/pl/welcome.json b/webview-ui/src/i18n/locales/pl/welcome.json index 32037e759e..5f342c5860 100644 --- a/webview-ui/src/i18n/locales/pl/welcome.json +++ b/webview-ui/src/i18n/locales/pl/welcome.json @@ -12,7 +12,9 @@ }, "telemetry": { "helpImprove": "Pomóż ulepszyć Zoo Code", - "helpImproveMessage": "Zoo Code zbiera dane o błędach i użytkowaniu, aby pomóc nam naprawiać błędy i ulepszać rozszerzenie. Ta telemetria nie zbiera kodu, podpowiedzi ani danych osobowych. Możesz je wyłączyć w ustawieniach." + "helpImproveMessage": "Zoo Code zbiera dane o błędach i użytkowaniu, powiązane z identyfikatorem instalacji, aby pomóc nam naprawiać błędy i ulepszać rozszerzenie. Ta telemetria nie zbiera Twojego kodu ani podpowiedzi. Możesz ją wyłączyć w ustawieniach.", + "accept": "Akceptuj", + "decline": "Odrzuć" }, "importSettings": "Importuj ustawienia" } diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index bb44c1bcaa..b973961490 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -976,8 +976,8 @@ }, "footer": { "telemetry": { - "label": "Permitir relatórios anônimos de erros e uso", - "description": "Ajude a melhorar o Zoo Code enviando dados de uso anônimos e relatórios de erros. Esta telemetria não coleta código, prompts ou informações pessoais. Consulte nossa política de privacidade para mais detalhes." + "label": "Permitir relatórios de erros e uso", + "description": "Ajude a melhorar o Zoo Code enviando dados de uso e relatórios de erros, vinculados a um identificador de instalação em vez do seu nome ou conta. Esta telemetria não coleta seu código ou seus prompts. Consulte nossa política de privacidade para mais detalhes." }, "settings": { "import": "Importar", diff --git a/webview-ui/src/i18n/locales/pt-BR/welcome.json b/webview-ui/src/i18n/locales/pt-BR/welcome.json index c444ff4374..29ecf56e7e 100644 --- a/webview-ui/src/i18n/locales/pt-BR/welcome.json +++ b/webview-ui/src/i18n/locales/pt-BR/welcome.json @@ -12,7 +12,9 @@ }, "telemetry": { "helpImprove": "Ajude a melhorar o Zoo Code", - "helpImproveMessage": "Zoo Code coleta dados de erro e uso para nos ajudar a corrigir bugs e melhorar a extensão. Esta telemetria não coleta código, prompts ou informações pessoais. Você pode desabilitar isso nas configurações." + "helpImproveMessage": "Zoo Code coleta dados de erro e uso, vinculados a um identificador de instalação, para nos ajudar a corrigir bugs e melhorar a extensão. Esta telemetria não coleta seu código ou seus prompts. Você pode desabilitar isso nas configurações.", + "accept": "Aceitar", + "decline": "Recusar" }, "importSettings": "Importar configurações" } diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index ee02578534..dacd9e9502 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -976,8 +976,8 @@ }, "footer": { "telemetry": { - "label": "Разрешить анонимную отправку ошибок и статистики использования", - "description": "Помогите улучшить Zoo Code, отправляя анонимные данные об использовании и отчеты об ошибках. Эта телеметрия не собирает код, промпты или личную информацию. Смотрите нашу политику конфиденциальности для получения подробной информации." + "label": "Разрешить отправку ошибок и статистики использования", + "description": "Помогите улучшить Zoo Code, отправляя данные об использовании и отчеты об ошибках, связанные с идентификатором установки, а не с вашим именем или учетной записью. Эта телеметрия не собирает ваш код или запросы. Смотрите нашу политику конфиденциальности для получения подробной информации." }, "settings": { "import": "Импорт", diff --git a/webview-ui/src/i18n/locales/ru/welcome.json b/webview-ui/src/i18n/locales/ru/welcome.json index dade1e8ece..62a40077d8 100644 --- a/webview-ui/src/i18n/locales/ru/welcome.json +++ b/webview-ui/src/i18n/locales/ru/welcome.json @@ -12,7 +12,9 @@ }, "telemetry": { "helpImprove": "Помогите улучшить Zoo Code", - "helpImproveMessage": "Zoo Code собирает данные об ошибках и использовании, чтобы помочь нам исправлять ошибки и улучшать расширение. Эта телеметрия не собирает код, приглашения или личную информацию. Вы можете отключить это в настройках." + "helpImproveMessage": "Zoo Code собирает данные об ошибках и использовании, связанные с идентификатором установки, чтобы помочь нам исправлять ошибки и улучшать расширение. Эта телеметрия не собирает ваш код или запросы. Вы можете отключить это в настройках.", + "accept": "Принять", + "decline": "Отклонить" }, "importSettings": "Импортировать настройки" } diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index e37c154ead..fe54be7656 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -976,8 +976,8 @@ }, "footer": { "telemetry": { - "label": "Anonim hata ve kullanım raporlamaya izin ver", - "description": "Anonim kullanım verileri ve hata raporları göndererek Zoo Code'u geliştirmeye yardım edin. Bu telemetri kod, prompt veya kişisel bilgi toplamaz. Daha fazla ayrıntı için gizlilik politikamıza bakın. Bunu istediğiniz zaman kapatabilirsiniz." + "label": "Hata ve kullanım raporlamasına izin ver", + "description": "Adınız veya hesabınız yerine kuruluma bağlı bir tanımlayıcıyla ilişkilendirilmiş kullanım verileri ve hata raporları göndererek Zoo Code'u geliştirmeye yardım edin. Bu telemetri kodunuzu veya istemlerinizi toplamaz. Daha fazla ayrıntı için gizlilik politikamıza bakın. Bunu istediğiniz zaman kapatabilirsiniz." }, "settings": { "import": "İçe Aktar", diff --git a/webview-ui/src/i18n/locales/tr/welcome.json b/webview-ui/src/i18n/locales/tr/welcome.json index 0216342ed9..0f83de0f30 100644 --- a/webview-ui/src/i18n/locales/tr/welcome.json +++ b/webview-ui/src/i18n/locales/tr/welcome.json @@ -12,7 +12,9 @@ }, "telemetry": { "helpImprove": "Zoo Code'u iyileştirmeye yardımcı ol", - "helpImproveMessage": "Zoo Code, hataları düzeltmemize ve uzantıyı iyileştirmemize yardımcı olmak için hata ve kullanım verilerini toplar. Bu telemetri kod, komutlar veya kişisel bilgileri toplamaz. Bunu ayarlardan devre dışı bırakabilirsin." + "helpImproveMessage": "Zoo Code, hataları düzeltmemize ve uzantıyı iyileştirmemize yardımcı olmak için, kuruluma bağlı bir tanımlayıcıyla ilişkilendirilmiş hata ve kullanım verilerini toplar. Bu telemetri kodunu veya istemlerini toplamaz. Bunu ayarlardan devre dışı bırakabilirsin.", + "accept": "Kabul et", + "decline": "Reddet" }, "importSettings": "Ayarları İçe Aktar" } diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index c3b40a004a..353303d0ec 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -976,8 +976,8 @@ }, "footer": { "telemetry": { - "label": "Cho phép báo cáo lỗi và sử dụng ẩn danh", - "description": "Giúp cải thiện Zoo Code bằng cách gửi dữ liệu sử dụng ẩn danh và báo cáo lỗi. Telemetry này không thu thập mã, prompt hoặc thông tin cá nhân. Xem chính sách bảo mật của chúng tôi để biết thêm chi tiết. Bạn có thể tắt tính năng này bất cứ lúc nào." + "label": "Cho phép báo cáo lỗi và dữ liệu sử dụng", + "description": "Giúp cải thiện Zoo Code bằng cách gửi dữ liệu sử dụng và báo cáo lỗi, được liên kết với mã định danh cài đặt thay vì tên hoặc tài khoản của bạn. Telemetry này không thu thập mã nguồn hoặc lời nhắc của bạn. Xem chính sách bảo mật của chúng tôi để biết thêm chi tiết. Bạn có thể tắt tính năng này bất cứ lúc nào." }, "settings": { "import": "Nhập", diff --git a/webview-ui/src/i18n/locales/vi/welcome.json b/webview-ui/src/i18n/locales/vi/welcome.json index 93756a444e..76be9d097c 100644 --- a/webview-ui/src/i18n/locales/vi/welcome.json +++ b/webview-ui/src/i18n/locales/vi/welcome.json @@ -12,7 +12,9 @@ }, "telemetry": { "helpImprove": "Giúp cải thiện Zoo Code", - "helpImproveMessage": "Zoo Code thu thập dữ liệu lỗi và sử dụng để giúp chúng tôi sửa lỗi và cải thiện tiện ích mở rộng. Dữ liệu từ xa này không thu thập mã, lời nhắc hoặc thông tin cá nhân. Bạn có thể vô hiệu hóa nó trong cài đặt." + "helpImproveMessage": "Zoo Code thu thập dữ liệu lỗi và sử dụng, được liên kết với một mã định danh cài đặt, để giúp chúng tôi sửa lỗi và cải thiện tiện ích mở rộng. Dữ liệu từ xa này không thu thập mã nguồn hoặc lời nhắc của bạn. Bạn có thể vô hiệu hóa nó trong cài đặt.", + "accept": "Chấp nhận", + "decline": "Từ chối" }, "importSettings": "Nhập Cài đặt" } diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 2e8abf435e..ce1fd192c3 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -976,8 +976,8 @@ }, "footer": { "telemetry": { - "label": "允许匿名数据收集", - "description": "通过发送匿名使用数据和错误报告来帮助改进 Zoo Code。此遥测不会收集代码、提示 或个人信息。详细信息请参阅我们的隐私政策。您可以随时关闭此功能。" + "label": "允许发送错误和使用情况报告", + "description": "通过发送与安装标识符(而非您的姓名或帐户)关联的使用数据和错误报告来帮助改进 Zoo Code。此遥测不会收集您的代码或提示词。详细信息请参阅我们的隐私政策。您可以随时关闭此功能。" }, "settings": { "import": "导入", diff --git a/webview-ui/src/i18n/locales/zh-CN/welcome.json b/webview-ui/src/i18n/locales/zh-CN/welcome.json index 1506c0c959..17740e8302 100644 --- a/webview-ui/src/i18n/locales/zh-CN/welcome.json +++ b/webview-ui/src/i18n/locales/zh-CN/welcome.json @@ -12,7 +12,9 @@ }, "telemetry": { "helpImprove": "帮助改进 Zoo Code", - "helpImproveMessage": "Zoo Code 收集错误和使用数据以帮助我们修复 Bug 并改进扩展。此遥测不收集代码、提示或个人信息。你可以在设置中将其关闭。" + "helpImproveMessage": "Zoo Code 收集与安装标识符关联的错误和使用数据,以帮助我们修复 Bug 并改进扩展。此遥测不会收集你的代码或提示词。你可以在设置中将其关闭。", + "accept": "接受", + "decline": "拒绝" }, "importSettings": "导入设置" } diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 481bab5301..ef5899ccb1 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -1003,8 +1003,8 @@ }, "footer": { "telemetry": { - "label": "允許匿名錯誤與使用情況回報", - "description": "透過發送匿名使用資料和錯誤回報來協助改善 Zoo Code。此遙測不會收集程式碼、提示詞或個人資訊。查看我們的 隱私權政策 以了解更多資訊。" + "label": "允許錯誤與使用情況回報", + "description": "透過發送與安裝識別碼(而非您的姓名或帳戶)相關聯的使用資料和錯誤回報來協助改善 Zoo Code。此遙測不會收集您的程式碼或提示詞。查看我們的 隱私權政策 以了解更多資訊。您可以隨時關閉此功能。" }, "settings": { "import": "匯入", diff --git a/webview-ui/src/i18n/locales/zh-TW/welcome.json b/webview-ui/src/i18n/locales/zh-TW/welcome.json index 4ba8548a62..eba96dc00a 100644 --- a/webview-ui/src/i18n/locales/zh-TW/welcome.json +++ b/webview-ui/src/i18n/locales/zh-TW/welcome.json @@ -12,7 +12,9 @@ }, "telemetry": { "helpImprove": "幫助改進 Zoo Code", - "helpImproveMessage": "Zoo Code 會收集錯誤和使用情況資料以幫助我們修復錯誤並改進擴充功能。此遙測不會收集程式碼、提示詞或個人資訊。您可以在 設定 中關閉。" + "helpImproveMessage": "Zoo Code 會收集與安裝識別碼相關聯的錯誤和使用情況資料,以幫助我們修復錯誤並改進擴充功能。此遙測不會收集您的程式碼或提示詞。您可以在 設定 中關閉。", + "accept": "接受", + "decline": "拒絕" }, "importSettings": "匯入設定" } diff --git a/webview-ui/src/utils/TelemetryClient.ts b/webview-ui/src/utils/TelemetryClient.ts index 3e28a4f234..f3a70da29f 100644 --- a/webview-ui/src/utils/TelemetryClient.ts +++ b/webview-ui/src/utils/TelemetryClient.ts @@ -1,15 +1,20 @@ import posthog from "posthog-js" -import type { TelemetrySetting } from "@roo-code/types" +import { type TelemetrySetting, isTelemetryOptedIn } from "@roo-code/types" class TelemetryClient { private static instance: TelemetryClient private static telemetryEnabled: boolean = false - public updateTelemetryState(telemetrySetting: TelemetrySetting, apiKey?: string, distinctId?: string) { + public updateTelemetryState( + telemetrySetting: TelemetrySetting, + apiKey?: string, + distinctId?: string, + vscodeTelemetryEnabled: boolean = true, + ) { posthog.reset() - if (telemetrySetting !== "disabled" && apiKey && distinctId) { + if (isTelemetryOptedIn(telemetrySetting) && apiKey && distinctId && vscodeTelemetryEnabled) { TelemetryClient.telemetryEnabled = true posthog.init(apiKey, { From 2375591184e85ef469aa26858c446677136e77e4 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Fri, 31 Jul 2026 03:11:22 +0000 Subject: [PATCH 2/4] test(webview-ui): add visual snapshot for TelemetryBanner --- webview-ui/playwright/vscode-theme-dark.css | 24 +++++++++ .../TelemetryBanner.visual.fixture.tsx | 50 ++++++++++++++++++ .../__tests__/TelemetryBanner.visual.tsx | 15 ++++++ .../__screenshots__/telemetry-banner-dark.png | Bin 0 -> 12762 bytes 4 files changed, 89 insertions(+) create mode 100644 webview-ui/src/components/common/__tests__/TelemetryBanner.visual.fixture.tsx create mode 100644 webview-ui/src/components/common/__tests__/TelemetryBanner.visual.tsx create mode 100644 webview-ui/src/components/common/__tests__/__screenshots__/telemetry-banner-dark.png diff --git a/webview-ui/playwright/vscode-theme-dark.css b/webview-ui/playwright/vscode-theme-dark.css index 30d49f968b..0621461655 100644 --- a/webview-ui/playwright/vscode-theme-dark.css +++ b/webview-ui/playwright/vscode-theme-dark.css @@ -87,3 +87,27 @@ body { #root { padding: 16px; } + +/* The @vscode/webview-ui-toolkit/react mock (aliased in for CT, same as Vitest) renders + VSCodeButton as a bare + ), })) vi.mock("@/components/ui", async (importOriginal) => { diff --git a/webview-ui/src/components/common/__tests__/TelemetryBanner.visual.fixture.tsx b/webview-ui/src/components/common/__tests__/TelemetryBanner.visual.fixture.tsx index 4c205d43bb..0df2d5b226 100644 --- a/webview-ui/src/components/common/__tests__/TelemetryBanner.visual.fixture.tsx +++ b/webview-ui/src/components/common/__tests__/TelemetryBanner.visual.fixture.tsx @@ -1,47 +1,15 @@ import React from "react" -import i18next from "i18next" -import { I18nextProvider, initReactI18next } from "react-i18next" +import { I18nextProvider } from "react-i18next" import { TranslationContext } from "@src/i18n/TranslationContext" import TelemetryBanner from "../TelemetryBanner" - -const translations: Record = { - "welcome:telemetry.helpImprove": "Help Improve Zoo Code", - "welcome:telemetry.helpImproveMessage": - "Zoo Code collects error and usage data, linked to a per-install identifier, to help us fix bugs and improve the extension. This telemetry does not collect your code or prompts. You can turn this off in settings.", - "welcome:telemetry.accept": "Accept", - "welcome:telemetry.decline": "Decline", -} - -// Trans reads from its own react-i18next instance rather than the useAppTranslation -// context, so it needs a real (if minimal) i18next init to resolve helpImproveMessage -// and the settingsLink interpolation instead of rendering nothing. -const visualTestI18n = i18next.createInstance() -void visualTestI18n.use(initReactI18next).init({ - lng: "en", - fallbackLng: "en", - ns: ["welcome"], - defaultNS: "welcome", - resources: { - en: { - welcome: { - telemetry: { - helpImprove: translations["welcome:telemetry.helpImprove"], - helpImproveMessage: translations["welcome:telemetry.helpImproveMessage"], - accept: translations["welcome:telemetry.accept"], - decline: translations["welcome:telemetry.decline"], - }, - }, - }, - }, - interpolation: { escapeValue: false }, -}) +import { visualTestI18n, visualTestTranslations } from "./TelemetryBanner.visual.i18n" export const TelemetryBannerFixture = () => ( translations[key] ?? key, + t: (key) => visualTestTranslations[key] ?? key, i18n: null as unknown as typeof import("../../../i18n/setup").default, }}> diff --git a/webview-ui/src/components/common/__tests__/TelemetryBanner.visual.i18n.ts b/webview-ui/src/components/common/__tests__/TelemetryBanner.visual.i18n.ts new file mode 100644 index 0000000000..95e7a8d1b6 --- /dev/null +++ b/webview-ui/src/components/common/__tests__/TelemetryBanner.visual.i18n.ts @@ -0,0 +1,36 @@ +import i18next from "i18next" +import { initReactI18next } from "react-i18next" + +export const visualTestTranslations: Record = { + "welcome:telemetry.helpImprove": "Help Improve Zoo Code", + "welcome:telemetry.helpImproveMessage": + "Zoo Code collects error and usage data, linked to a per-install identifier, to help us fix bugs and improve the extension. This telemetry does not collect your code or prompts. You can turn this off in settings.", + "welcome:telemetry.accept": "Accept", + "welcome:telemetry.decline": "Decline", +} + +// Trans reads from its own react-i18next instance rather than the useAppTranslation +// context, so it needs a real (if minimal) i18next init to resolve helpImproveMessage +// and the settingsLink interpolation instead of rendering nothing. init() returns a +// promise even for inline resources, so callers must await it before mounting. +export const visualTestI18n = i18next.createInstance() + +export const visualTestI18nReady = visualTestI18n.use(initReactI18next).init({ + lng: "en", + fallbackLng: "en", + ns: ["welcome"], + defaultNS: "welcome", + resources: { + en: { + welcome: { + telemetry: { + helpImprove: visualTestTranslations["welcome:telemetry.helpImprove"], + helpImproveMessage: visualTestTranslations["welcome:telemetry.helpImproveMessage"], + accept: visualTestTranslations["welcome:telemetry.accept"], + decline: visualTestTranslations["welcome:telemetry.decline"], + }, + }, + }, + }, + interpolation: { escapeValue: false }, +}) diff --git a/webview-ui/src/components/common/__tests__/TelemetryBanner.visual.tsx b/webview-ui/src/components/common/__tests__/TelemetryBanner.visual.tsx index ae52183970..c8889415fe 100644 --- a/webview-ui/src/components/common/__tests__/TelemetryBanner.visual.tsx +++ b/webview-ui/src/components/common/__tests__/TelemetryBanner.visual.tsx @@ -2,8 +2,11 @@ import React from "react" import { expect, test } from "../../../../playwright/coverage-fixture" import { TelemetryBannerFixture } from "./TelemetryBanner.visual.fixture" +import { visualTestI18nReady } from "./TelemetryBanner.visual.i18n" test("renders the telemetry consent banner in the VS Code dark theme", async ({ mount }) => { + await visualTestI18nReady + const component = await mount() await component.evaluate(async () => { From 0eb3b62f43acb4f5828fc277a937abf994744dfd Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Fri, 31 Jul 2026 04:18:40 +0000 Subject: [PATCH 4/4] fix(codecov): cover telemetry guard branch, exempt CT-only fixtures --- codecov.yml | 8 +++++ .../__tests__/webviewMessageHandler.spec.ts | 30 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/codecov.yml b/codecov.yml index 7dd22dfdc2..0d45b50be0 100644 --- a/codecov.yml +++ b/codecov.yml @@ -42,6 +42,14 @@ flag_management: - packages/core/src/ carryforward: true +ignore: + # Playwright CT-only fixtures/helpers: exercised by the webview-ui-ct flag's browser run, not + # Vitest, and excluded from that flag's own lcov by playwright-ct.config.ts's sourceFilter (same + # ".visual." match). Without this, patch coverage sees 0% for these paths on any PR that adds or + # touches one, since no flag's lcov contains them. + - "webview-ui/src/**/*.visual.fixture.tsx" + - "webview-ui/src/**/*.visual.i18n.ts" + component_management: individual_components: - component_id: webview_components diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 5bfa6b1b16..e2800efd14 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -1929,4 +1929,34 @@ describe("webviewMessageHandler - telemetrySetting", () => { const calls = vi.mocked(TelemetryService.instance.updateTelemetryState).mock.calls expect(calls.at(-1)).toEqual([false]) }) + + // CodeRabbit finding: webviewDidLaunch's queued telemetry update called + // TelemetryService.instance directly, unlike the "telemetrySetting" case a few lines + // below which checks hasInstance() first. If webviewDidLaunch fires before the service + // is created (e.g. during activation), TelemetryService.instance throws -- and since + // this whole chain isn't awaited by the "webviewDidLaunch" case, that throw becomes an + // unhandled promise rejection instead of a no-op. + it("does not throw or update telemetry state when webviewDidLaunch fires before TelemetryService exists", async () => { + const { TelemetryService } = await import("@roo-code/telemetry") + vi.mocked(TelemetryService.hasInstance).mockReturnValue(false) + + vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue("unset") + vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([]) + const providerForLaunch = mockClineProvider as unknown as { + getMcpHub: ReturnType + providerSettingsManager: { listConfig: ReturnType } + getStateToPostToWebview: ReturnType + } + providerForLaunch.getMcpHub = vi.fn().mockReturnValue(undefined) + providerForLaunch.providerSettingsManager = { listConfig: vi.fn().mockResolvedValue(undefined) } + providerForLaunch.getStateToPostToWebview = vi.fn().mockResolvedValue({ telemetrySetting: "unset" }) + + await expect(webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch" })).resolves.not.toThrow() + + // The queued telemetry update is fire-and-forget from the handler's own point of + // view -- flush a microtask turn so its .then() callback runs before asserting. + await Promise.resolve() + + expect(TelemetryService.instance.updateTelemetryState).not.toHaveBeenCalled() + }) })