Skip to content
Merged
98 changes: 98 additions & 0 deletions src/composables/useFeatureFlags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ServerFeatureFlag,
useFeatureFlags
} from '@/composables/useFeatureFlags'
import { httpSupportsModelTypeTags } from '@/platform/assets/composables/useModelTypeTagsRefresh'
import * as distributionTypes from '@/platform/distribution/types'
import {
cachedBillingControlEnabled,
Expand Down Expand Up @@ -383,6 +384,103 @@ describe('useFeatureFlags', () => {
})
})

describe('supportsModelTypeTags', () => {
afterEach(() => {
httpSupportsModelTypeTags.value = undefined
localStorage.clear()
})

it('prefers the HTTP /features value over the websocket flag', () => {
vi.mocked(api.getServerFeature).mockReturnValue(true)
httpSupportsModelTypeTags.value = false

const { flags } = useFeatureFlags()

expect(flags.supportsModelTypeTags).toBe(false)
expect(api.getServerFeature).not.toHaveBeenCalled()
})

it('lets a dev override beat the HTTP /features value', () => {
vi.mocked(api.getServerFeature).mockReturnValue(false)
httpSupportsModelTypeTags.value = false
localStorage.setItem(
`ff:${ServerFeatureFlag.SUPPORTS_MODEL_TYPE_TAGS}`,
'true'
)

const { flags } = useFeatureFlags()

expect(flags.supportsModelTypeTags).toBe(true)
})

it('ignores a non-boolean dev override and falls through to HTTP resolution', () => {
vi.mocked(api.getServerFeature).mockReturnValue(true)
httpSupportsModelTypeTags.value = false
localStorage.setItem(
`ff:${ServerFeatureFlag.SUPPORTS_MODEL_TYPE_TAGS}`,
'"false"'
)

const { flags } = useFeatureFlags()

expect(flags.supportsModelTypeTags).toBe(false)
expect(api.getServerFeature).not.toHaveBeenCalled()
})

it('uses an HTTP-served true value', () => {
vi.mocked(api.getServerFeature).mockReturnValue(false)
httpSupportsModelTypeTags.value = true

const { flags } = useFeatureFlags()

expect(flags.supportsModelTypeTags).toBe(true)
})

it('falls back to the websocket flag when HTTP has not served the key', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.SUPPORTS_MODEL_TYPE_TAGS) return true
return defaultValue
}
)

const { flags } = useFeatureFlags()

expect(flags.supportsModelTypeTags).toBe(true)
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.SUPPORTS_MODEL_TYPE_TAGS,
false
)
})

it('defaults to false when neither source has the flag', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(_path, defaultValue) => defaultValue
)

const { flags } = useFeatureFlags()

expect(flags.supportsModelTypeTags).toBe(false)
})

it('leaves other server flags on the websocket path', () => {
httpSupportsModelTypeTags.value = false
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA) return true
return defaultValue
}
)

const { flags } = useFeatureFlags()

expect(flags.supportsPreviewMetadata).toBe(true)
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA
)
})
})

describe('churnkeyAppId', () => {
afterEach(() => {
vi.mocked(distributionTypes).isCloud = false
Expand Down
20 changes: 17 additions & 3 deletions src/composables/useFeatureFlags.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { computed, reactive, readonly } from 'vue'
import type { Ref } from 'vue'

import { httpSupportsModelTypeTags } from '@/platform/assets/composables/useModelTypeTagsRefresh'
import { isCloud, isNightly } from '@/platform/distribution/types'
import {
cachedBillingControlEnabled,
Expand Down Expand Up @@ -247,10 +248,23 @@ export function useFeatureFlags() {
'off'
)
},
/**
* Server capability, deliberately not resolved through remoteConfig:
* refreshRemoteConfig clears to {} on any fetch error (would flap this
* flag and trigger spurious model reloads), lacks reconnect/visibility
* triggers, and has no response-ordering guard (FE-1439 tracks
* consolidation). The HTTP `/features` value is preferred because it can
* refresh mid-session; the websocket copy only arrives once per
* connection and covers backends without the HTTP key.
*/
get supportsModelTypeTags() {
return api.getServerFeature(
ServerFeatureFlag.SUPPORTS_MODEL_TYPE_TAGS,
false
const override = getDevOverride<unknown>(
ServerFeatureFlag.SUPPORTS_MODEL_TYPE_TAGS
)
if (typeof override === 'boolean') return override
return (
httpSupportsModelTypeTags.value ??

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why different pattern for this flag?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This flag resolves differently because it's a server capability that must be able to change mid-session: the WS copy (api.getServerFeature) arrives once per connection and is never re-pushed, and the remoteConfig-based resolution used by the other flags inherits refreshRemoteConfig's problems — it clears remoteConfig to {} on any fetch error (which would flap this flag and cause spurious model-library reloads), has no reconnect/visibility triggers, and has no out-of-order response guard. So this one prefers a dedicated refreshable HTTP value and falls back to the WS copy for backends that don't serve the key over HTTP.

To be clear, the earlier code comment here claiming a dynamic-config entry could "shadow" the capability was wrong (remoteConfig is the verbatim /features response) and has been rewritten at head 837eaaa with the real rationale above. The duplication is acknowledged as temporary: hardening refreshRemoteConfig and folding the dedicated poller into it is tracked in FE-1439 (assigned to Simon), after which this flag can resolve through the common path.


Generated by Claude Code

Comment thread
claude[bot] marked this conversation as resolved.
Outdated
api.getServerFeature(ServerFeatureFlag.SUPPORTS_MODEL_TYPE_TAGS, false)
)
}
})
Expand Down
228 changes: 228 additions & 0 deletions src/platform/assets/composables/useModelTypeTagsRefresh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { effectScope } from 'vue'
import type { EffectScope } from 'vue'

import {
httpSupportsModelTypeTags,
refreshSupportsModelTypeTags,
useSupportsModelTypeTagsRefresh
} from '@/platform/assets/composables/useModelTypeTagsRefresh'
import { api } from '@/scripts/api'

function buildResponse(
body: unknown,
init: { ok?: boolean; status?: number } = {}
): Response {
return {
ok: init.ok ?? true,
status: init.status ?? 200,
json: vi.fn().mockResolvedValue(body)
} as unknown as Response
}

const fetchApiSpy = vi.spyOn(api, 'fetchApi')

beforeEach(() => {
vi.resetAllMocks()
httpSupportsModelTypeTags.value = undefined
})

describe('refreshSupportsModelTypeTags', () => {
it('reads the flag from the raw /features response', async () => {
fetchApiSpy.mockResolvedValue(
buildResponse({ supports_model_type_tags: true })
)

await refreshSupportsModelTypeTags()

expect(fetchApiSpy).toHaveBeenCalledWith('/features', {
cache: 'no-store',
signal: expect.any(AbortSignal)
})
expect(httpSupportsModelTypeTags.value).toBe(true)
})

it('tracks a flip to false', async () => {
httpSupportsModelTypeTags.value = true
fetchApiSpy.mockResolvedValue(
buildResponse({ supports_model_type_tags: false })
)

await refreshSupportsModelTypeTags()

expect(httpSupportsModelTypeTags.value).toBe(false)
})

it('clears the value when the backend stops serving the key', async () => {
httpSupportsModelTypeTags.value = true
fetchApiSpy.mockResolvedValue(buildResponse({ other_flag: true }))

await refreshSupportsModelTypeTags()

expect(httpSupportsModelTypeTags.value).toBeUndefined()
})

it('ignores a non-boolean value', async () => {
fetchApiSpy.mockResolvedValue(
buildResponse({ supports_model_type_tags: 'yes' })
)

await refreshSupportsModelTypeTags()

expect(httpSupportsModelTypeTags.value).toBeUndefined()
})

it('keeps the last known value on an error response', async () => {
httpSupportsModelTypeTags.value = true
fetchApiSpy.mockResolvedValue(buildResponse({}, { ok: false, status: 500 }))

await refreshSupportsModelTypeTags()

expect(httpSupportsModelTypeTags.value).toBe(true)
})

it('keeps the last known value when the fetch fails', async () => {
httpSupportsModelTypeTags.value = false
fetchApiSpy.mockRejectedValue(new Error('offline'))

await refreshSupportsModelTypeTags()

expect(httpSupportsModelTypeTags.value).toBe(false)
})

it('coalesces overlapping refreshes into a single request', async () => {
let resolveFetch: (response: Response) => void
fetchApiSpy.mockReturnValueOnce(
new Promise<Response>((resolve) => {
resolveFetch = resolve
})
)
const first = refreshSupportsModelTypeTags()
const second = refreshSupportsModelTypeTags()
expect(fetchApiSpy).toHaveBeenCalledTimes(1)

resolveFetch!(buildResponse({ supports_model_type_tags: true }))
await Promise.all([first, second])

expect(fetchApiSpy).toHaveBeenCalledTimes(1)
expect(httpSupportsModelTypeTags.value).toBe(true)
})

it('fetches again once the previous refresh has settled', async () => {
fetchApiSpy.mockResolvedValue(
buildResponse({ supports_model_type_tags: true })
)

await refreshSupportsModelTypeTags()
await refreshSupportsModelTypeTags()

expect(fetchApiSpy).toHaveBeenCalledTimes(2)
})

it('aborts a stalled request on timeout so the next trigger can fetch', async () => {
const timeoutController = new AbortController()
const timeoutSpy = vi
.spyOn(AbortSignal, 'timeout')
.mockReturnValueOnce(timeoutController.signal)
fetchApiSpy.mockImplementationOnce(
(_route, options) =>
new Promise<Response>((_resolve, reject) => {
options?.signal?.addEventListener('abort', () =>
reject(new DOMException('The operation timed out.', 'TimeoutError'))
)
})
)
const stalled = refreshSupportsModelTypeTags()
expect(timeoutSpy).toHaveBeenCalledWith(10_000)

timeoutController.abort()
await stalled
expect(httpSupportsModelTypeTags.value).toBeUndefined()

fetchApiSpy.mockResolvedValueOnce(
buildResponse({ supports_model_type_tags: true })
)
await refreshSupportsModelTypeTags()

expect(fetchApiSpy).toHaveBeenCalledTimes(2)
expect(httpSupportsModelTypeTags.value).toBe(true)
timeoutSpy.mockRestore()
})
})

describe('useSupportsModelTypeTagsRefresh', () => {
let scope: EffectScope

beforeEach(() => {
fetchApiSpy.mockResolvedValue(
buildResponse({ supports_model_type_tags: true })
)
scope = effectScope()
})

afterEach(() => {
scope.stop()
vi.useRealTimers()
})

it('fetches immediately and again on websocket reconnect', async () => {
scope.run(() => useSupportsModelTypeTagsRefresh())
expect(fetchApiSpy).toHaveBeenCalledTimes(1)
await vi.waitFor(() => expect(httpSupportsModelTypeTags.value).toBe(true))

api.dispatchCustomEvent('reconnected')

expect(fetchApiSpy).toHaveBeenCalledTimes(2)
})

it('re-fetches when the tab becomes visible again', async () => {
scope.run(() => useSupportsModelTypeTagsRefresh())
expect(fetchApiSpy).toHaveBeenCalledTimes(1)
await vi.waitFor(() => expect(httpSupportsModelTypeTags.value).toBe(true))

document.dispatchEvent(new Event('visibilitychange'))

expect(fetchApiSpy).toHaveBeenCalledTimes(2)
})

it('re-fetches on the polling interval', async () => {
vi.useFakeTimers()
scope.run(() => useSupportsModelTypeTagsRefresh())
expect(fetchApiSpy).toHaveBeenCalledTimes(1)

await vi.advanceTimersByTimeAsync(120_000)
expect(fetchApiSpy).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(120_000)
expect(fetchApiSpy).toHaveBeenCalledTimes(3)
})

it('does not fetch on visibility events or interval ticks while hidden', async () => {
vi.useFakeTimers()
const hiddenSpy = vi.spyOn(document, 'hidden', 'get').mockReturnValue(false)
scope.run(() => useSupportsModelTypeTagsRefresh())
expect(fetchApiSpy).toHaveBeenCalledTimes(1)

hiddenSpy.mockReturnValue(true)
document.dispatchEvent(new Event('visibilitychange'))
await vi.advanceTimersByTimeAsync(120_000)
expect(fetchApiSpy).toHaveBeenCalledTimes(1)

hiddenSpy.mockReturnValue(false)
document.dispatchEvent(new Event('visibilitychange'))
expect(fetchApiSpy).toHaveBeenCalledTimes(2)
hiddenSpy.mockRestore()
})

it('stops all refresh triggers when the scope is disposed', async () => {
vi.useFakeTimers()
scope.run(() => useSupportsModelTypeTagsRefresh())
expect(fetchApiSpy).toHaveBeenCalledTimes(1)

scope.stop()
api.dispatchCustomEvent('reconnected')
document.dispatchEvent(new Event('visibilitychange'))
await vi.advanceTimersByTimeAsync(120_000)

expect(fetchApiSpy).toHaveBeenCalledTimes(1)
})
})
Loading
Loading