fix: harden legacy release channel fallback

This commit is contained in:
lucaronin
2026-04-13 12:35:02 +02:00
parent 8f408d9ef0
commit b4abc05f0e
4 changed files with 134 additions and 33 deletions

View File

@@ -71,6 +71,20 @@ describe('SettingsPanel', () => {
expect(screen.queryByText(/Beta\/Stable/i)).not.toBeInTheDocument()
})
it('treats a legacy beta release channel as stable', () => {
render(
<SettingsPanel
open={true}
settings={{ ...emptySettings, release_channel: 'beta' }}
onSave={onSave}
onClose={onClose}
/>
)
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,

View File

@@ -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())

View File

@@ -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')
})
})

View File

@@ -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<void> {
return isTauri() ? invoke<void>(command) : mockInvoke<void>(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