From c6d211e5af7d80bde729b99101d8de5624ad014c Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:24:55 +0100 Subject: [PATCH 1/8] test(flags): reproduce persisted flag boundary failures --- .../src/tests/store/featureFlagStore.spec.ts | 67 ++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/frontend/taskdeck-web/src/tests/store/featureFlagStore.spec.ts b/frontend/taskdeck-web/src/tests/store/featureFlagStore.spec.ts index 572ce5e6b4..796b3dfd5c 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,67 @@ describe('featureFlagStore', () => { } }) + 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', '[]', '"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) + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new DOMException('Storage blocked', 'SecurityError') + }) + + expect(() => store.restore()).not.toThrow() + expect(store.flags).toEqual(defaultFeatureFlags) + }) + + it('keeps an in-memory flag update when persistence fails', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('Storage full', 'QuotaExceededError') + }) + + expect(() => store.setFlag('newAuth', false)).not.toThrow() + expect(store.isEnabled('newAuth')).toBe(false) + }) + + it('keeps reset defaults when persistence fails', () => { + store.setFlag('newAuth', false) + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('Storage full', 'QuotaExceededError') + }) + + expect(() => store.resetAll()).not.toThrow() + expect(store.flags).toEqual(defaultFeatureFlags) + }) + it('should use defaults when no flags are saved in localStorage', () => { // localStorage is clear from beforeEach store.restore() From c702ba11ac4883a5d4423b1c51cf02d7d248ec95 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:32:43 +0100 Subject: [PATCH 2/8] test(flags): make storage failures and arrays load-bearing --- .../taskdeck-web/src/tests/store/featureFlagStore.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/taskdeck-web/src/tests/store/featureFlagStore.spec.ts b/frontend/taskdeck-web/src/tests/store/featureFlagStore.spec.ts index 796b3dfd5c..36b6f6fbae 100644 --- a/frontend/taskdeck-web/src/tests/store/featureFlagStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/featureFlagStore.spec.ts @@ -92,7 +92,7 @@ describe('featureFlagStore', () => { expect((store.flags as Record).unknownFlag).toBeUndefined() }) - it.each(['null', '[]', '"text"', '42'])( + it.each(['null', '[false]', '"text"', '42'])( 'uses defaults for a non-object persisted payload %s', (payload) => { localStorage.setItem('taskdeck_feature_flags', payload) @@ -105,7 +105,7 @@ describe('featureFlagStore', () => { it('uses defaults when storage cannot be read', () => { store.setFlag('newAuth', false) - vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + vi.spyOn(localStorage, 'getItem').mockImplementation(() => { throw new DOMException('Storage blocked', 'SecurityError') }) @@ -114,7 +114,7 @@ describe('featureFlagStore', () => { }) it('keeps an in-memory flag update when persistence fails', () => { - vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + vi.spyOn(localStorage, 'setItem').mockImplementation(() => { throw new DOMException('Storage full', 'QuotaExceededError') }) @@ -124,7 +124,7 @@ describe('featureFlagStore', () => { it('keeps reset defaults when persistence fails', () => { store.setFlag('newAuth', false) - vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + vi.spyOn(localStorage, 'setItem').mockImplementation(() => { throw new DOMException('Storage full', 'QuotaExceededError') }) From dbc5843690bde72471b89ec928e17ba7c7f93c44 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:41:03 +0100 Subject: [PATCH 3/8] test(flags): restore storage spies within each case --- .../src/tests/store/featureFlagStore.spec.ts | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/frontend/taskdeck-web/src/tests/store/featureFlagStore.spec.ts b/frontend/taskdeck-web/src/tests/store/featureFlagStore.spec.ts index 36b6f6fbae..17e6303a37 100644 --- a/frontend/taskdeck-web/src/tests/store/featureFlagStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/featureFlagStore.spec.ts @@ -105,31 +105,43 @@ describe('featureFlagStore', () => { it('uses defaults when storage cannot be read', () => { store.setFlag('newAuth', false) - vi.spyOn(localStorage, 'getItem').mockImplementation(() => { + const getItem = vi.spyOn(localStorage, 'getItem').mockImplementation(() => { throw new DOMException('Storage blocked', 'SecurityError') }) - expect(() => store.restore()).not.toThrow() - expect(store.flags).toEqual(defaultFeatureFlags) + try { + expect(() => store.restore()).not.toThrow() + expect(store.flags).toEqual(defaultFeatureFlags) + } finally { + getItem.mockRestore() + } }) it('keeps an in-memory flag update when persistence fails', () => { - vi.spyOn(localStorage, 'setItem').mockImplementation(() => { + const setItem = vi.spyOn(localStorage, 'setItem').mockImplementation(() => { throw new DOMException('Storage full', 'QuotaExceededError') }) - expect(() => store.setFlag('newAuth', false)).not.toThrow() - expect(store.isEnabled('newAuth')).toBe(false) + try { + expect(() => store.setFlag('newAuth', false)).not.toThrow() + expect(store.isEnabled('newAuth')).toBe(false) + } finally { + setItem.mockRestore() + } }) it('keeps reset defaults when persistence fails', () => { store.setFlag('newAuth', false) - vi.spyOn(localStorage, 'setItem').mockImplementation(() => { + const setItem = vi.spyOn(localStorage, 'setItem').mockImplementation(() => { throw new DOMException('Storage full', 'QuotaExceededError') }) - expect(() => store.resetAll()).not.toThrow() - expect(store.flags).toEqual(defaultFeatureFlags) + try { + expect(() => store.resetAll()).not.toThrow() + expect(store.flags).toEqual(defaultFeatureFlags) + } finally { + setItem.mockRestore() + } }) it('should use defaults when no flags are saved in localStorage', () => { From 66a0e9d429664cfd7a461e5da8f91365cf631618 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:50:25 +0100 Subject: [PATCH 4/8] fix(flags): validate persisted values and tolerate storage failure --- .../src/store/featureFlagStore.ts | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/frontend/taskdeck-web/src/store/featureFlagStore.ts b/frontend/taskdeck-web/src/store/featureFlagStore.ts index 324c1d0b26..88b35c33cd 100644 --- a/frontend/taskdeck-web/src/store/featureFlagStore.ts +++ b/frontend/taskdeck-web/src/store/featureFlagStore.ts @@ -4,6 +4,18 @@ 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 }) @@ -23,23 +35,25 @@ 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))) + } catch { + // Storage can be unavailable or full. Keep the valid in-memory choice. + } } function restore() { - const saved = localStorage.getItem(FLAGS_KEY) - if (saved) { - try { - const parsed = JSON.parse(saved) - flags.value = { ...defaultFeatureFlags, ...parsed } - } catch { - flags.value = { ...defaultFeatureFlags } - } + try { + const saved = localStorage.getItem(FLAGS_KEY) + if (!saved) return + flags.value = normalizeFeatureFlags(JSON.parse(saved) as unknown) + } catch { + flags.value = { ...defaultFeatureFlags } } } const allEnabled = computed(() => - Object.values(flags.value).every(v => v) + FEATURE_FLAG_KEYS.every((key) => flags.value[key]) ) return { From adb0db38aab9ddd528b6a975c8bcb2b9e703e052 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:35:24 +0100 Subject: [PATCH 5/8] test(flags): preserve unsaved state across later restores --- .../src/tests/store/featureFlagStore.spec.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/frontend/taskdeck-web/src/tests/store/featureFlagStore.spec.ts b/frontend/taskdeck-web/src/tests/store/featureFlagStore.spec.ts index 17e6303a37..df27f06c08 100644 --- a/frontend/taskdeck-web/src/tests/store/featureFlagStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/featureFlagStore.spec.ts @@ -130,6 +130,25 @@ describe('featureFlagStore', () => { } }) + 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(() => { @@ -144,6 +163,25 @@ describe('featureFlagStore', () => { } }) + 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() From 5bff5abe4befa8ec7d267b6d50aba0038b97ed5c Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:48:14 +0100 Subject: [PATCH 6/8] fix(flags): preserve unsaved state across later restores --- frontend/taskdeck-web/src/store/featureFlagStore.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/taskdeck-web/src/store/featureFlagStore.ts b/frontend/taskdeck-web/src/store/featureFlagStore.ts index 88b35c33cd..129a483be7 100644 --- a/frontend/taskdeck-web/src/store/featureFlagStore.ts +++ b/frontend/taskdeck-web/src/store/featureFlagStore.ts @@ -19,6 +19,7 @@ function normalizeFeatureFlags(raw: unknown): FeatureFlags { export const useFeatureFlagStore = defineStore('featureFlags', () => { const flags = ref({ ...defaultFeatureFlags }) + let hasUnsavedChanges = false function isEnabled(flag: keyof FeatureFlags): boolean { return flags.value[flag] @@ -37,12 +38,16 @@ export const useFeatureFlagStore = defineStore('featureFlags', () => { function persist() { try { localStorage.setItem(FLAGS_KEY, JSON.stringify(normalizeFeatureFlags(flags.value))) + hasUnsavedChanges = false } catch { + hasUnsavedChanges = true // Storage can be unavailable or full. Keep the valid in-memory choice. } } function restore() { + if (hasUnsavedChanges) return + try { const saved = localStorage.getItem(FLAGS_KEY) if (!saved) return From 71f2c291a10f260fc7769d8217c3044ccc1761b0 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 21 Sep 2026 23:28:43 +0100 Subject: [PATCH 7/8] fix(flags): surface persistence failures --- .../taskdeck-web/src/store/featureFlagStore.ts | 6 +++++- .../src/tests/store/featureFlagStore.spec.ts | 17 +++++++++++++++++ .../src/tests/views/ProfileSettingsView.spec.ts | 11 +++++++++++ .../src/views/ProfileSettingsView.vue | 3 +++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/frontend/taskdeck-web/src/store/featureFlagStore.ts b/frontend/taskdeck-web/src/store/featureFlagStore.ts index 129a483be7..471a67e304 100644 --- a/frontend/taskdeck-web/src/store/featureFlagStore.ts +++ b/frontend/taskdeck-web/src/store/featureFlagStore.ts @@ -19,6 +19,7 @@ function normalizeFeatureFlags(raw: unknown): FeatureFlags { export const useFeatureFlagStore = defineStore('featureFlags', () => { const flags = ref({ ...defaultFeatureFlags }) + const persistenceError = ref(null) let hasUnsavedChanges = false function isEnabled(flag: keyof FeatureFlags): boolean { @@ -39,9 +40,10 @@ export const useFeatureFlagStore = defineStore('featureFlags', () => { try { localStorage.setItem(FLAGS_KEY, JSON.stringify(normalizeFeatureFlags(flags.value))) hasUnsavedChanges = false + persistenceError.value = null } catch { hasUnsavedChanges = true - // Storage can be unavailable or full. Keep the valid in-memory choice. + persistenceError.value = 'Feature flag changes are active for this session but could not be saved in browser storage.' } } @@ -54,6 +56,7 @@ export const useFeatureFlagStore = defineStore('featureFlags', () => { flags.value = normalizeFeatureFlags(JSON.parse(saved) as unknown) } catch { flags.value = { ...defaultFeatureFlags } + persistenceError.value = 'Feature flags could not be loaded from browser storage; defaults are active.' } } @@ -63,6 +66,7 @@ export const useFeatureFlagStore = defineStore('featureFlags', () => { 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 df27f06c08..647f8b06ad 100644 --- a/frontend/taskdeck-web/src/tests/store/featureFlagStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/featureFlagStore.spec.ts @@ -125,11 +125,28 @@ describe('featureFlagStore', () => { 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() diff --git a/frontend/taskdeck-web/src/tests/views/ProfileSettingsView.spec.ts b/frontend/taskdeck-web/src/tests/views/ProfileSettingsView.spec.ts index f9088bcaa6..a10fad6099 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 a7bdfa060a..a2a6ef74dc 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.

+
From ebe289e0b89f99bb60cc5f3e965ef89a08c54f37 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 21 Sep 2026 23:53:05 +0100 Subject: [PATCH 8/8] fix: clear recovered feature flag load errors --- frontend/taskdeck-web/src/store/featureFlagStore.ts | 6 +++++- .../src/tests/store/featureFlagStore.spec.ts | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/frontend/taskdeck-web/src/store/featureFlagStore.ts b/frontend/taskdeck-web/src/store/featureFlagStore.ts index 471a67e304..77ef8f60c7 100644 --- a/frontend/taskdeck-web/src/store/featureFlagStore.ts +++ b/frontend/taskdeck-web/src/store/featureFlagStore.ts @@ -52,8 +52,12 @@ export const useFeatureFlagStore = defineStore('featureFlags', () => { try { const saved = localStorage.getItem(FLAGS_KEY) - if (!saved) return + 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.' diff --git a/frontend/taskdeck-web/src/tests/store/featureFlagStore.spec.ts b/frontend/taskdeck-web/src/tests/store/featureFlagStore.spec.ts index 647f8b06ad..e1d95591e6 100644 --- a/frontend/taskdeck-web/src/tests/store/featureFlagStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/featureFlagStore.spec.ts @@ -71,6 +71,18 @@ 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,