diff --git a/frontend/taskdeck-web/src/store/featureFlagStore.ts b/frontend/taskdeck-web/src/store/featureFlagStore.ts index 324c1d0b2..77ef8f60c 100644 --- a/frontend/taskdeck-web/src/store/featureFlagStore.ts +++ b/frontend/taskdeck-web/src/store/featureFlagStore.ts @@ -4,9 +4,23 @@ import type { FeatureFlags } from '../types/feature-flags' import { defaultFeatureFlags } from '../types/feature-flags' const FLAGS_KEY = 'taskdeck_feature_flags' +const FEATURE_FLAG_KEYS = Object.keys(defaultFeatureFlags) as Array + +function normalizeFeatureFlags(raw: unknown): FeatureFlags { + const normalized: FeatureFlags = { ...defaultFeatureFlags } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return normalized + + const candidate = raw as Record + for (const key of FEATURE_FLAG_KEYS) { + if (typeof candidate[key] === 'boolean') normalized[key] = candidate[key] + } + return normalized +} export const useFeatureFlagStore = defineStore('featureFlags', () => { const flags = ref({ ...defaultFeatureFlags }) + const persistenceError = ref(null) + let hasUnsavedChanges = false function isEnabled(flag: keyof FeatureFlags): boolean { return flags.value[flag] @@ -23,27 +37,40 @@ export const useFeatureFlagStore = defineStore('featureFlags', () => { } function persist() { - localStorage.setItem(FLAGS_KEY, JSON.stringify(flags.value)) + try { + localStorage.setItem(FLAGS_KEY, JSON.stringify(normalizeFeatureFlags(flags.value))) + hasUnsavedChanges = false + persistenceError.value = null + } catch { + hasUnsavedChanges = true + persistenceError.value = 'Feature flag changes are active for this session but could not be saved in browser storage.' + } } function restore() { - const saved = localStorage.getItem(FLAGS_KEY) - if (saved) { - try { - const parsed = JSON.parse(saved) - flags.value = { ...defaultFeatureFlags, ...parsed } - } catch { - flags.value = { ...defaultFeatureFlags } + if (hasUnsavedChanges) return + + try { + const saved = localStorage.getItem(FLAGS_KEY) + if (!saved) { + persistenceError.value = null + return } + flags.value = normalizeFeatureFlags(JSON.parse(saved) as unknown) + persistenceError.value = null + } catch { + flags.value = { ...defaultFeatureFlags } + persistenceError.value = 'Feature flags could not be loaded from browser storage; defaults are active.' } } const allEnabled = computed(() => - Object.values(flags.value).every(v => v) + FEATURE_FLAG_KEYS.every((key) => flags.value[key]) ) return { flags, + persistenceError, isEnabled, setFlag, resetAll, diff --git a/frontend/taskdeck-web/src/tests/store/featureFlagStore.spec.ts b/frontend/taskdeck-web/src/tests/store/featureFlagStore.spec.ts index 572ce5e6b..e1d95591e 100644 --- a/frontend/taskdeck-web/src/tests/store/featureFlagStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/featureFlagStore.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setActivePinia, createPinia } from 'pinia' import { useFeatureFlagStore } from '../../store/featureFlagStore' import { defaultFeatureFlags } from '../../types/feature-flags' @@ -12,6 +12,10 @@ describe('featureFlagStore', () => { store = useFeatureFlagStore() }) + afterEach(() => { + vi.restoreAllMocks() + }) + describe('default flags', () => { it('should match configured defaults', () => { for (const key of Object.keys(defaultFeatureFlags) as (keyof typeof defaultFeatureFlags)[]) { @@ -67,6 +71,146 @@ describe('featureFlagStore', () => { } }) + it('clears a previous load error after a later restore succeeds', () => { + localStorage.setItem('taskdeck_feature_flags', 'not-valid-json') + store.restore() + expect(store.persistenceError).toContain('could not be loaded') + + localStorage.setItem('taskdeck_feature_flags', JSON.stringify({ newAuth: false })) + store.restore() + + expect(store.persistenceError).toBeNull() + expect(store.isEnabled('newAuth')).toBe(false) + }) + + it('restores only declared boolean flag values', () => { + localStorage.setItem('taskdeck_feature_flags', JSON.stringify({ + newShell: 0, + newAuth: 'false', + newAccess: false, + devTools: 'true', + ollama: true, + unknownFlag: true, + })) + + store.restore() + + expect(store.isEnabled('newShell')).toBe(defaultFeatureFlags.newShell) + expect(store.isEnabled('newAuth')).toBe(defaultFeatureFlags.newAuth) + expect(store.isEnabled('devTools')).toBe(defaultFeatureFlags.devTools) + expect(store.isEnabled('newAccess')).toBe(false) + expect(store.isEnabled('ollama')).toBe(true) + expect(Object.keys(store.flags).sort()).toEqual(Object.keys(defaultFeatureFlags).sort()) + expect((store.flags as Record).unknownFlag).toBeUndefined() + }) + + it.each(['null', '[false]', '"text"', '42'])( + 'uses defaults for a non-object persisted payload %s', + (payload) => { + localStorage.setItem('taskdeck_feature_flags', payload) + + store.restore() + + expect(store.flags).toEqual(defaultFeatureFlags) + }, + ) + + it('uses defaults when storage cannot be read', () => { + store.setFlag('newAuth', false) + const getItem = vi.spyOn(localStorage, 'getItem').mockImplementation(() => { + throw new DOMException('Storage blocked', 'SecurityError') + }) + + try { + expect(() => store.restore()).not.toThrow() + expect(store.flags).toEqual(defaultFeatureFlags) + } finally { + getItem.mockRestore() + } + }) + + it('keeps an in-memory flag update when persistence fails', () => { + const setItem = vi.spyOn(localStorage, 'setItem').mockImplementation(() => { + throw new DOMException('Storage full', 'QuotaExceededError') + }) + + try { + expect(() => store.setFlag('newAuth', false)).not.toThrow() + expect(store.isEnabled('newAuth')).toBe(false) + expect(store.persistenceError).toBe('Feature flag changes are active for this session but could not be saved in browser storage.') + } finally { + setItem.mockRestore() + } + }) + + it('clears the persistence error after a later write succeeds', () => { + const setItem = vi.spyOn(localStorage, 'setItem').mockImplementationOnce(() => { + throw new DOMException('Storage full', 'QuotaExceededError') + }) + + try { + store.setFlag('newAuth', false) + expect(store.persistenceError).toBeTruthy() + } finally { + setItem.mockRestore() + } + + store.setFlag('newAuth', true) + expect(store.persistenceError).toBeNull() + }) + + it('does not let stale storage overwrite an unsaved flag update', () => { + localStorage.setItem('taskdeck_feature_flags', JSON.stringify({ newAuth: true })) + store.restore() + const setItem = vi.spyOn(localStorage, 'setItem').mockImplementation(() => { + throw new DOMException('Storage full', 'QuotaExceededError') + }) + + try { + store.setFlag('newAuth', false) + expect(store.isEnabled('newAuth')).toBe(false) + + store.restore() + + expect(store.isEnabled('newAuth')).toBe(false) + } finally { + setItem.mockRestore() + } + }) + + it('keeps reset defaults when persistence fails', () => { + store.setFlag('newAuth', false) + const setItem = vi.spyOn(localStorage, 'setItem').mockImplementation(() => { + throw new DOMException('Storage full', 'QuotaExceededError') + }) + + try { + expect(() => store.resetAll()).not.toThrow() + expect(store.flags).toEqual(defaultFeatureFlags) + } finally { + setItem.mockRestore() + } + }) + + it('does not let stale storage overwrite an unsaved reset', () => { + localStorage.setItem('taskdeck_feature_flags', JSON.stringify({ devTools: true })) + store.restore() + const setItem = vi.spyOn(localStorage, 'setItem').mockImplementation(() => { + throw new DOMException('Storage full', 'QuotaExceededError') + }) + + try { + store.resetAll() + expect(store.isEnabled('devTools')).toBe(defaultFeatureFlags.devTools) + + store.restore() + + expect(store.isEnabled('devTools')).toBe(defaultFeatureFlags.devTools) + } finally { + setItem.mockRestore() + } + }) + it('should use defaults when no flags are saved in localStorage', () => { // localStorage is clear from beforeEach store.restore() diff --git a/frontend/taskdeck-web/src/tests/views/ProfileSettingsView.spec.ts b/frontend/taskdeck-web/src/tests/views/ProfileSettingsView.spec.ts index f9088bcaa..a10fad609 100644 --- a/frontend/taskdeck-web/src/tests/views/ProfileSettingsView.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/ProfileSettingsView.spec.ts @@ -19,6 +19,7 @@ const featureFlagMocks = vi.hoisted(() => ({ isEnabled: vi.fn(() => false), setFlag: vi.fn(), resetAll: vi.fn(), + persistenceError: null as string | null, })) vi.mock('../../store/sessionStore', () => ({ @@ -38,6 +39,7 @@ vi.mock('../../store/featureFlagStore', () => ({ isEnabled: featureFlagMocks.isEnabled, setFlag: featureFlagMocks.setFlag, resetAll: featureFlagMocks.resetAll, + persistenceError: featureFlagMocks.persistenceError, }), })) @@ -115,6 +117,15 @@ describe('ProfileSettingsView', () => { expect(wrapper.text()).toContain('Editor') expect(wrapper.text()).toContain('Can run editor-safe Ops templates; admin templates are restricted.') }) + + it('surfaces feature-flag persistence failures', () => { + featureFlagMocks.persistenceError = 'Feature flag changes are active for this session but could not be saved in browser storage.' + + const wrapper = mount(ProfileSettingsView) + + expect(wrapper.get('[role="alert"]').text()).toContain('could not be saved') + featureFlagMocks.persistenceError = null + }) }) // ── #1808 review (MEDIUM): Legacy ("off") mode substrate guard ── diff --git a/frontend/taskdeck-web/src/views/ProfileSettingsView.vue b/frontend/taskdeck-web/src/views/ProfileSettingsView.vue index a7bdfa060..a2a6ef74d 100644 --- a/frontend/taskdeck-web/src/views/ProfileSettingsView.vue +++ b/frontend/taskdeck-web/src/views/ProfileSettingsView.vue @@ -329,6 +329,9 @@ const flagLabels: Record = {

Feature Flags

Toggle feature flags to enable or disable new features.

+