Merge pull request #31 from scub-france/feature/feature-flipping

Add feature flipping mechanism
This commit is contained in:
Pier-Jean Malandrino 2026-04-02 11:29:57 +02:00 committed by GitHub
commit 29d36b9fc2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 154 additions and 1 deletions

2
.gitignore vendored
View file

@ -12,7 +12,7 @@ site/
.run/
.claude/
CLAUDE.md
*/CLAUDE.md
**/CLAUDE.md
*.iml
# OS

View file

@ -1,9 +1,14 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { router } from './router'
import { useFeatureFlagStore } from '../features/feature-flags'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.use(router)
const featureFlags = useFeatureFlagStore()
featureFlags.load()
app.mount('#app')

View file

@ -0,0 +1,2 @@
export { useFeatureFlagStore, type FeatureFlag } from './store'
export { useFeatureFlag } from './useFeatureFlag'

View file

@ -0,0 +1,51 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useFeatureFlagStore } from './store'
const mockApiFetch = vi.fn()
vi.mock('../../shared/api/http', () => ({
apiFetch: (...args: unknown[]) => mockApiFetch(...args),
}))
vi.mock('../settings/store', () => ({
useSettingsStore: () => ({ apiUrl: 'http://localhost:8000' }),
}))
describe('useFeatureFlagStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockApiFetch.mockReset()
})
it('starts unloaded with flags disabled', () => {
const store = useFeatureFlagStore()
expect(store.loaded).toBe(false)
expect(store.isEnabled('chunking')).toBe(false)
})
it('enables chunking when engine is local', async () => {
mockApiFetch.mockResolvedValue({ status: 'ok', engine: 'local' })
const store = useFeatureFlagStore()
await store.load()
expect(store.engine).toBe('local')
expect(store.loaded).toBe(true)
expect(store.isEnabled('chunking')).toBe(true)
})
it('disables chunking when engine is remote', async () => {
mockApiFetch.mockResolvedValue({ status: 'ok', engine: 'remote' })
const store = useFeatureFlagStore()
await store.load()
expect(store.engine).toBe('remote')
expect(store.isEnabled('chunking')).toBe(false)
})
it('handles health endpoint failure gracefully', async () => {
mockApiFetch.mockRejectedValue(new Error('Network error'))
const store = useFeatureFlagStore()
await store.load()
expect(store.loaded).toBe(true)
expect(store.error).toBe('Network error')
expect(store.isEnabled('chunking')).toBe(false)
})
})

View file

@ -0,0 +1,60 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { apiFetch } from '../../shared/api/http'
import { useSettingsStore } from '../settings/store'
type ConversionEngine = 'local' | 'remote'
interface HealthResponse {
status: string
engine: ConversionEngine
}
export type FeatureFlag = 'chunking'
interface FeatureFlagDef {
description: string
isEnabled: (ctx: FeatureFlagContext) => boolean
}
interface FeatureFlagContext {
engine: ConversionEngine | null
}
const featureRegistry: Record<FeatureFlag, FeatureFlagDef> = {
chunking: {
description: 'Document chunking for RAG preparation',
isEnabled: (ctx) => ctx.engine === 'local',
},
}
export const useFeatureFlagStore = defineStore('feature-flags', () => {
const engine = ref<ConversionEngine | null>(null)
const loaded = ref(false)
const error = ref<string | null>(null)
const context = computed<FeatureFlagContext>(() => ({
engine: engine.value,
}))
function isEnabled(flag: FeatureFlag): boolean {
if (!loaded.value) return false
const def = featureRegistry[flag]
return def.isEnabled(context.value)
}
async function load(): Promise<void> {
const settings = useSettingsStore()
try {
const data = await apiFetch<HealthResponse>(`${settings.apiUrl}/health`)
engine.value = data.engine
loaded.value = true
error.value = null
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load feature flags'
loaded.value = true
}
}
return { engine, loaded, error, isEnabled, load }
})

View file

@ -0,0 +1,28 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useFeatureFlag } from './useFeatureFlag'
import { useFeatureFlagStore } from './store'
vi.mock('@/shared/api/http', () => ({ apiFetch: vi.fn() }))
describe('useFeatureFlag', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('returns false before flags are loaded', () => {
const flag = useFeatureFlag('chunking')
expect(flag.value).toBe(false)
})
it('returns reactive value matching store state', () => {
const store = useFeatureFlagStore()
store.$patch({ loaded: true, engine: 'local' })
const flag = useFeatureFlag('chunking')
expect(flag.value).toBe(true)
store.$patch({ engine: 'remote' })
expect(flag.value).toBe(false)
})
})

View file

@ -0,0 +1,7 @@
import { computed } from 'vue'
import { useFeatureFlagStore, type FeatureFlag } from './store'
export function useFeatureFlag(flag: FeatureFlag) {
const store = useFeatureFlagStore()
return computed(() => store.isEnabled(flag))
}