diff --git a/src/components/SettingsPanel.test.tsx b/src/components/SettingsPanel.test.tsx index fd8335fa..464baab7 100644 --- a/src/components/SettingsPanel.test.tsx +++ b/src/components/SettingsPanel.test.tsx @@ -71,6 +71,20 @@ describe('SettingsPanel', () => { expect(screen.queryByText(/Beta\/Stable/i)).not.toBeInTheDocument() }) + it('treats a legacy beta release channel as stable', () => { + render( + + ) + + expect(screen.getByTestId('settings-release-channel')).toHaveAttribute('data-value', 'stable') + expect(screen.queryByText('Beta')).not.toBeInTheDocument() + }) + it('preserves alpha when alpha is already selected', () => { const alphaSettings: Settings = { ...emptySettings, diff --git a/src/hooks/useSettings.test.ts b/src/hooks/useSettings.test.ts index 3807eda2..da44322f 100644 --- a/src/hooks/useSettings.test.ts +++ b/src/hooks/useSettings.test.ts @@ -68,6 +68,21 @@ describe('useSettings', () => { expect(mockInvokeFn).toHaveBeenCalledWith('get_settings', {}) }) + it('normalizes a legacy beta release channel back to stable on load', async () => { + mockSettingsStore = { + ...savedSettings, + release_channel: 'beta', + } + + const { result } = renderHook(() => useSettings()) + + await waitFor(() => { + expect(result.current.loaded).toBe(true) + }) + + expect(result.current.settings.release_channel).toBeNull() + }) + it('saves settings via backend', async () => { const { result } = renderHook(() => useSettings()) diff --git a/src/hooks/useTelemetry.test.ts b/src/hooks/useTelemetry.test.ts index 1a867bdb..f103ae25 100644 --- a/src/hooks/useTelemetry.test.ts +++ b/src/hooks/useTelemetry.test.ts @@ -7,14 +7,16 @@ const mockInitSentry = vi.fn() const mockTeardownSentry = vi.fn() const mockInitPostHog = vi.fn() const mockTeardownPostHog = vi.fn() +const mockUpdatePostHogIdentify = vi.fn() +const mockSetReleaseChannel = vi.fn() vi.mock('../lib/telemetry', () => ({ initSentry: (...args: unknown[]) => mockInitSentry(...args), teardownSentry: () => mockTeardownSentry(), initPostHog: (...args: unknown[]) => mockInitPostHog(...args), teardownPostHog: () => mockTeardownPostHog(), - updatePostHogIdentify: vi.fn(), - setReleaseChannel: vi.fn(), + updatePostHogIdentify: (...args: unknown[]) => mockUpdatePostHogIdentify(...args), + setReleaseChannel: (...args: unknown[]) => mockSetReleaseChannel(...args), })) const baseSettings: Settings = { @@ -23,35 +25,49 @@ const baseSettings: Settings = { analytics_enabled: null, anonymous_id: null, release_channel: null, } +function renderTelemetry(settings: Settings, loaded = true) { + return renderHook(() => useTelemetry(settings, loaded)) +} + +function analyticsSettings(releaseChannel: string | null = null): Settings { + return { + ...baseSettings, + analytics_enabled: true, + anonymous_id: 'test-uuid', + release_channel: releaseChannel, + } +} + describe('useTelemetry', () => { beforeEach(() => { vi.clearAllMocks() }) it('does nothing when settings are not loaded', () => { - renderHook(() => useTelemetry(baseSettings, false)) + renderTelemetry(baseSettings, false) expect(mockInitSentry).not.toHaveBeenCalled() expect(mockInitPostHog).not.toHaveBeenCalled() }) it('does nothing when consent is not granted', () => { - renderHook(() => useTelemetry({ ...baseSettings, telemetry_consent: false }, true)) + renderTelemetry({ ...baseSettings, telemetry_consent: false }) expect(mockInitSentry).not.toHaveBeenCalled() expect(mockInitPostHog).not.toHaveBeenCalled() }) it('initializes Sentry when crash reporting is enabled', () => { - renderHook(() => - useTelemetry({ ...baseSettings, crash_reporting_enabled: true, anonymous_id: 'test-uuid' }, true) - ) + renderTelemetry({ ...baseSettings, crash_reporting_enabled: true, anonymous_id: 'test-uuid' }) expect(mockInitSentry).toHaveBeenCalledWith('test-uuid') }) - it('initializes PostHog when analytics is enabled', () => { - renderHook(() => - useTelemetry({ ...baseSettings, analytics_enabled: true, anonymous_id: 'test-uuid' }, true) - ) - expect(mockInitPostHog).toHaveBeenCalledWith('test-uuid', 'stable') + it.each([ + { name: 'stable defaults', releaseChannel: null, expectedChannel: 'stable' }, + { name: 'legacy beta release channels', releaseChannel: 'beta', expectedChannel: 'stable' }, + ])('initializes PostHog for $name', ({ releaseChannel, expectedChannel }) => { + renderTelemetry(analyticsSettings(releaseChannel)) + + expect(mockInitPostHog).toHaveBeenCalledWith('test-uuid', expectedChannel) + expect(mockSetReleaseChannel).toHaveBeenCalledWith(expectedChannel) }) it('tears down Sentry when crash reporting is disabled after being enabled', () => { @@ -68,9 +84,36 @@ describe('useTelemetry', () => { }) it('does not initialize without anonymous_id', () => { - renderHook(() => - useTelemetry({ ...baseSettings, crash_reporting_enabled: true, anonymous_id: null }, true) - ) + renderTelemetry({ ...baseSettings, crash_reporting_enabled: true, anonymous_id: null }) expect(mockInitSentry).not.toHaveBeenCalled() }) + + it('updates the analytics identity when the normalized channel changes', () => { + const { rerender } = renderHook( + ({ settings, loaded }) => useTelemetry(settings, loaded), + { + initialProps: { + settings: { + ...baseSettings, + analytics_enabled: true, + anonymous_id: 'test-uuid', + release_channel: 'alpha', + }, + loaded: true, + }, + }, + ) + + rerender({ + settings: { + ...baseSettings, + analytics_enabled: true, + anonymous_id: 'test-uuid', + release_channel: 'beta', + }, + loaded: true, + }) + + expect(mockUpdatePostHogIdentify).toHaveBeenCalledWith('stable') + }) }) diff --git a/src/hooks/useTelemetry.ts b/src/hooks/useTelemetry.ts index f407ffd5..9267b65b 100644 --- a/src/hooks/useTelemetry.ts +++ b/src/hooks/useTelemetry.ts @@ -2,12 +2,54 @@ import { useEffect, useRef } from 'react' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' import { initSentry, teardownSentry, initPostHog, teardownPostHog, updatePostHogIdentify, setReleaseChannel } from '../lib/telemetry' +import { normalizeReleaseChannel, type ReleaseChannel } from '../lib/releaseChannel' import type { Settings } from '../types' function tauriCall(command: string): Promise { return isTauri() ? invoke(command) : mockInvoke(command) } +function resolveReleaseChannel(settings: Settings): ReleaseChannel { + return normalizeReleaseChannel(settings.release_channel) +} + +function syncCrashReporting( + crashEnabled: boolean, + anonymousId: string | null, + wasEnabled: boolean, +): void { + if (crashEnabled && anonymousId) { + if (!wasEnabled) initSentry(anonymousId) + return + } + + if (!wasEnabled) return + + teardownSentry() + tauriCall('reinit_telemetry').catch(() => {}) +} + +function syncAnalytics( + analyticsEnabled: boolean, + anonymousId: string | null, + releaseChannel: ReleaseChannel, + wasEnabled: boolean, +): void { + if (!analyticsEnabled) { + if (wasEnabled) teardownPostHog() + return + } + + if (!anonymousId) return + + if (wasEnabled) { + updatePostHogIdentify(releaseChannel) + return + } + + initPostHog(anonymousId, releaseChannel) +} + /** * Initializes / tears down Sentry and PostHog reactively based on user settings. * Call once in App after settings are loaded. @@ -20,25 +62,12 @@ export function useTelemetry(settings: Settings, loaded: boolean): void { if (!loaded) return const crashEnabled = settings.crash_reporting_enabled === true const analyticsEnabled = settings.analytics_enabled === true - const id = settings.anonymous_id + const anonymousId = settings.anonymous_id + const releaseChannel = resolveReleaseChannel(settings) - if (crashEnabled && id && !prevCrash.current) { - initSentry(id) - } else if (!crashEnabled && prevCrash.current) { - teardownSentry() - tauriCall('reinit_telemetry').catch(() => {}) - } - - const channel = settings.release_channel ?? 'stable' - setReleaseChannel(channel) - - if (analyticsEnabled && id && !prevAnalytics.current) { - initPostHog(id, channel) - } else if (!analyticsEnabled && prevAnalytics.current) { - teardownPostHog() - } else if (analyticsEnabled && id) { - updatePostHogIdentify(channel) - } + syncCrashReporting(crashEnabled, anonymousId, prevCrash.current) + setReleaseChannel(releaseChannel) + syncAnalytics(analyticsEnabled, anonymousId, releaseChannel, prevAnalytics.current) prevCrash.current = crashEnabled prevAnalytics.current = analyticsEnabled