Skip to content
45 changes: 36 additions & 9 deletions frontend/taskdeck-web/src/store/featureFlagStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<keyof FeatureFlags>

function normalizeFeatureFlags(raw: unknown): FeatureFlags {
const normalized: FeatureFlags = { ...defaultFeatureFlags }
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return normalized

const candidate = raw as Record<string, unknown>
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<FeatureFlags>({ ...defaultFeatureFlags })
const persistenceError = ref<string | null>(null)
let hasUnsavedChanges = false

function isEnabled(flag: keyof FeatureFlags): boolean {
return flags.value[flag]
Expand All @@ -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)
Comment thread
Chris0Jeky marked this conversation as resolved.
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,
Expand Down
146 changes: 145 additions & 1 deletion frontend/taskdeck-web/src/tests/store/featureFlagStore.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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)[]) {
Expand Down Expand Up @@ -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<string, unknown>).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()
Expand Down
11 changes: 11 additions & 0 deletions frontend/taskdeck-web/src/tests/views/ProfileSettingsView.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -38,6 +39,7 @@ vi.mock('../../store/featureFlagStore', () => ({
isEnabled: featureFlagMocks.isEnabled,
setFlag: featureFlagMocks.setFlag,
resetAll: featureFlagMocks.resetAll,
persistenceError: featureFlagMocks.persistenceError,
}),
}))

Expand Down Expand Up @@ -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 ──
Expand Down
3 changes: 3 additions & 0 deletions frontend/taskdeck-web/src/views/ProfileSettingsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,9 @@ const flagLabels: Record<keyof FeatureFlags, string> = {
<section class="paper-profile__panel">
<h2 class="tk-h3 paper-profile__panel-title">Feature Flags</h2>
<p class="paper-profile__panel-desc">Toggle feature flags to enable or disable new features.</p>
<p v-if="featureFlags.persistenceError" class="paper-profile__alert paper-profile__alert--error" role="alert">
{{ featureFlags.persistenceError }}
</p>
<div class="paper-profile__flags-grid">
<div v-for="(label, key) in flagLabels" :key="key" class="paper-profile__flag-row">
<label :for="`flag-${key}`" class="paper-profile__flag-label">{{ label }}</label>
Expand Down
Loading