mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 14:31:56 +00:00
sveltekit: Add support for feature flags (and improve testing) (#55474)
This PR adds a new store to make feature flags available throughout the app. Unlike the implementation in the web app, this implementation fetches all user flags when the page loads and updates them in intervals. In the future we might have to add more functionality to force update the flags, but we can add those as needed. Overriding feature flags via the URL is not supported yet (but I guess I will use the same approach as the web app). This PR also updates the testing setup to make testing simpler and more deterministic: - Faker is now automatically initialized with a fixed reference date. - Functionality for mocking feature flags has been added - Added helpers for using fake timers with a fixed reference/system date (it turned out that enabling fake timers by default doesn't work well when testing user interaction). ## Test plan - New unit tests - Can access app with `pnpm dev`
This commit is contained in:
parent
443d754481
commit
ee74c030fb
@ -15,7 +15,7 @@
|
||||
# gazelle:js_test_files **/fixtures/**/*.{ts,tsx}
|
||||
# gazelle:js_test_files **/WebStory.{ts,tsx}
|
||||
# TODO(bazel): sveltekit tests
|
||||
# gazelle:exclude **/web-sveltekit/**/*.test.ts
|
||||
# gazelle:exclude **/web-sveltekit/**/*.ts
|
||||
|
||||
# TODO(bazel): put fixtures + testutils + ? into own rules
|
||||
# js_{fixture}_files **/*.{fixture,fixtures}.{ts,tsx}
|
||||
|
||||
@ -37,6 +37,7 @@
|
||||
"prettier-plugin-svelte": "^3.0.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"signale": "^1.4.0",
|
||||
"storybook": "^7.1.1",
|
||||
"storybook-dark-mode": "^3.0.0",
|
||||
"svelte": "^4.1.1",
|
||||
|
||||
23
client/web-sveltekit/src/lib/featureflags/api.ts
Normal file
23
client/web-sveltekit/src/lib/featureflags/api.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import type { EvaluatedFeatureFlagsResult } from '$lib/graphql-operations'
|
||||
import { dataOrThrowErrors, getDocumentNode, gql, type GraphQLClient } from '$lib/http-client'
|
||||
|
||||
export interface FeatureFlag {
|
||||
name: string
|
||||
value: boolean
|
||||
}
|
||||
|
||||
const FEATUREFLAGS_QUERY = gql`
|
||||
query EvaluatedFeatureFlags {
|
||||
evaluatedFeatureFlags {
|
||||
name
|
||||
value
|
||||
}
|
||||
}
|
||||
`
|
||||
export async function fetchEvaluatedFeatureFlags(client: GraphQLClient): Promise<FeatureFlag[]> {
|
||||
return dataOrThrowErrors(
|
||||
await client.query<EvaluatedFeatureFlagsResult>({
|
||||
query: getDocumentNode(FEATUREFLAGS_QUERY),
|
||||
})
|
||||
).evaluatedFeatureFlags
|
||||
}
|
||||
2
client/web-sveltekit/src/lib/featureflags/index.ts
Normal file
2
client/web-sveltekit/src/lib/featureflags/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './stores'
|
||||
export * from './api'
|
||||
50
client/web-sveltekit/src/lib/featureflags/stores.test.ts
Normal file
50
client/web-sveltekit/src/lib/featureflags/stores.test.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { describe, test, vi, expect } from 'vitest'
|
||||
|
||||
import { mockFeatureFlags, unmockFeatureFlags, useFakeTimers, useRealTimers } from '$mocks'
|
||||
|
||||
import { createFeatureFlagStore, featureFlag } from './stores'
|
||||
|
||||
describe('featureflags', () => {
|
||||
describe('createFeatureFlagStore()', () => {
|
||||
test('update feature flags periodically', async () => {
|
||||
useFakeTimers()
|
||||
|
||||
const store = createFeatureFlagStore(
|
||||
[{ name: 'sentinel', value: true }],
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([{ name: 'sentinel', value: false }])
|
||||
.mockResolvedValueOnce([{ name: 'sentinel', value: true }])
|
||||
)
|
||||
|
||||
const sub = vi.fn()
|
||||
store.subscribe(sub)
|
||||
expect(sub).toHaveBeenLastCalledWith([{ name: 'sentinel', value: true }])
|
||||
|
||||
await vi.advanceTimersToNextTimerAsync()
|
||||
expect(sub).toHaveBeenLastCalledWith([{ name: 'sentinel', value: false }])
|
||||
|
||||
await vi.advanceTimersToNextTimerAsync()
|
||||
expect(sub).toHaveBeenLastCalledWith([{ name: 'sentinel', value: true }])
|
||||
|
||||
useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('featureFlag()', () => {
|
||||
test('returns the current feature flag value', () => {
|
||||
mockFeatureFlags({ sentinel: false })
|
||||
|
||||
const store = featureFlag('sentinel')
|
||||
|
||||
const sub = vi.fn()
|
||||
store.subscribe(sub)
|
||||
expect(sub).toHaveBeenLastCalledWith(false)
|
||||
|
||||
mockFeatureFlags({ sentinel: true })
|
||||
expect(sub).toHaveBeenLastCalledWith(true)
|
||||
|
||||
unmockFeatureFlags()
|
||||
})
|
||||
})
|
||||
})
|
||||
36
client/web-sveltekit/src/lib/featureflags/stores.ts
Normal file
36
client/web-sveltekit/src/lib/featureflags/stores.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { derived, readable, type Readable } from 'svelte/store'
|
||||
|
||||
import { getStores } from '$lib/stores'
|
||||
import type { FeatureFlagName } from '$lib/web'
|
||||
|
||||
import type { FeatureFlag } from './api'
|
||||
|
||||
const MINUTE = 60000
|
||||
const FEATURE_FLAG_CACHE_TTL = MINUTE * 10
|
||||
|
||||
const defaultValues: Partial<Record<FeatureFlagName, boolean>> = {
|
||||
'repository-metadata': true,
|
||||
}
|
||||
|
||||
export function createFeatureFlagStore(
|
||||
initialFeatureFlags: FeatureFlag[],
|
||||
fetchEvaluatedFeatureFlags: () => Promise<FeatureFlag[]>
|
||||
): Readable<FeatureFlag[]> {
|
||||
return readable<FeatureFlag[]>(initialFeatureFlags, set => {
|
||||
const timer = globalThis.setInterval(() => {
|
||||
fetchEvaluatedFeatureFlags().then(set)
|
||||
}, FEATURE_FLAG_CACHE_TTL)
|
||||
|
||||
return () => {
|
||||
globalThis.clearInterval(timer)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function featureFlag(name: FeatureFlagName): Readable<boolean> {
|
||||
// TODO: add support for overrides
|
||||
return derived(
|
||||
getStores().featureFlags,
|
||||
$featureFlags => $featureFlags.find(flag => flag.name === name)?.value ?? defaultValues[name] ?? false
|
||||
)
|
||||
}
|
||||
@ -3,20 +3,24 @@ import { readable, writable, type Readable, type Writable } from 'svelte/store'
|
||||
|
||||
import type { GraphQLClient } from '$lib/http-client'
|
||||
import type { SettingsCascade, AuthenticatedUser, TemporarySettingsStorage } from '$lib/shared'
|
||||
import { getWebGraphQLClient } from '$lib/web'
|
||||
|
||||
import type { FeatureFlag } from './featureflags'
|
||||
|
||||
export interface SourcegraphContext {
|
||||
settings: Readable<SettingsCascade['final'] | null>
|
||||
user: Readable<AuthenticatedUser | null>
|
||||
isLightTheme: Readable<boolean>
|
||||
temporarySettingsStorage: Readable<TemporarySettingsStorage>
|
||||
featureFlags: Readable<FeatureFlag[]>
|
||||
client: Readable<GraphQLClient>
|
||||
}
|
||||
|
||||
export const KEY = '__sourcegraph__'
|
||||
|
||||
export function getStores(): SourcegraphContext {
|
||||
const { settings, user, isLightTheme, temporarySettingsStorage } = getContext<SourcegraphContext>(KEY)
|
||||
return { settings, user, isLightTheme, temporarySettingsStorage }
|
||||
const { settings, user, isLightTheme, temporarySettingsStorage, featureFlags, client } =
|
||||
getContext<SourcegraphContext>(KEY)
|
||||
return { settings, user, isLightTheme, temporarySettingsStorage, featureFlags, client }
|
||||
}
|
||||
|
||||
export const user = {
|
||||
@ -40,20 +44,22 @@ export const isLightTheme = {
|
||||
},
|
||||
}
|
||||
|
||||
export const graphqlClient = {
|
||||
subscribe(subscriber: (client: GraphQLClient) => void) {
|
||||
const { client } = getStores()
|
||||
return client.subscribe(subscriber)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* A store that updates every second to return the current time.
|
||||
*/
|
||||
export const currentDate: Readable<Date> = readable(new Date(), set => {
|
||||
set(new Date())
|
||||
const interval = setInterval(() => set(new Date()), 1000)
|
||||
return () => clearInterval(interval)
|
||||
})
|
||||
|
||||
export const graphqlClient = readable<GraphQLClient | null>(null, set => {
|
||||
// no-void conflicts with no-floating-promises
|
||||
// eslint-disable-next-line no-void
|
||||
void getWebGraphQLClient().then(client => set(client))
|
||||
})
|
||||
|
||||
/**
|
||||
* This store syncs the provided value with localStorage. Values must be JSON (de)seralizable.
|
||||
*/
|
||||
|
||||
@ -8,17 +8,17 @@ exports[`getRelativeTime > random times 3`] = `"last year"`;
|
||||
|
||||
exports[`getRelativeTime > random times 4`] = `"last year"`;
|
||||
|
||||
exports[`getRelativeTime > random times 5`] = `"10 months ago"`;
|
||||
exports[`getRelativeTime > random times 5`] = `"last year"`;
|
||||
|
||||
exports[`getRelativeTime > random times 6`] = `"10 months ago"`;
|
||||
exports[`getRelativeTime > random times 6`] = `"12 months ago"`;
|
||||
|
||||
exports[`getRelativeTime > random times 7`] = `"6 months ago"`;
|
||||
exports[`getRelativeTime > random times 7`] = `"7 months ago"`;
|
||||
|
||||
exports[`getRelativeTime > random times 8`] = `"5 months ago"`;
|
||||
exports[`getRelativeTime > random times 8`] = `"29 days ago"`;
|
||||
|
||||
exports[`getRelativeTime > random times 9`] = `"5 months ago"`;
|
||||
exports[`getRelativeTime > random times 9`] = `"12 days ago"`;
|
||||
|
||||
exports[`getRelativeTime > random times 10`] = `"last month"`;
|
||||
exports[`getRelativeTime > random times 10`] = `"2 hours ago"`;
|
||||
|
||||
exports[`getRelativeTime > specific times > days 1`] = `"12 days ago"`;
|
||||
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { faker } from '@faker-js/faker'
|
||||
import { it, vi, beforeAll, afterAll, expect, describe } from 'vitest'
|
||||
import { it, beforeEach, afterEach, expect, describe } from 'vitest'
|
||||
|
||||
import { useFakeTimers, useRealTimers } from '$mocks'
|
||||
|
||||
import { getRelativeTime } from './time'
|
||||
|
||||
@ -17,16 +19,15 @@ function d(options?: Partial<typeof defaults>): Date {
|
||||
return new Date(combined.Y, combined.M, combined.D, combined.h, combined.m, combined.s)
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(d())
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('getRelativeTime', () => {
|
||||
beforeEach(() => {
|
||||
useFakeTimers(d())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useRealTimers()
|
||||
})
|
||||
|
||||
it('uses the current time as reference by default', () => {
|
||||
expect(getRelativeTime(d({ h: 3 }))).toMatchInlineSnapshot('"9 hours ago"')
|
||||
})
|
||||
@ -55,14 +56,8 @@ describe('getRelativeTime', () => {
|
||||
})
|
||||
|
||||
it('random times', () => {
|
||||
faker.seed(42)
|
||||
faker.setDefaultRefDate(d())
|
||||
|
||||
for (const date of faker.date.betweens({ from: d({ Y: 2021 }), to: d(), count: 10 })) {
|
||||
expect(getRelativeTime(date)).toMatchSnapshot()
|
||||
}
|
||||
|
||||
faker.seed()
|
||||
faker.setDefaultRefDate()
|
||||
})
|
||||
})
|
||||
|
||||
@ -25,6 +25,7 @@ export {
|
||||
|
||||
export type RepoResolvedRevision = ResolvedRevision & Repo
|
||||
export { ResolvedRevision, Repo }
|
||||
export type { FeatureFlagName } from '@sourcegraph/web/src/featureFlags/featureFlags'
|
||||
|
||||
// Copy of non-reusable code
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
import { beforeNavigate } from '$app/navigation'
|
||||
|
||||
import type { LayoutData, Snapshot } from './$types'
|
||||
import { createFeatureFlagStore, fetchEvaluatedFeatureFlags } from '$lib/featureflags'
|
||||
|
||||
export let data: LayoutData
|
||||
|
||||
@ -46,6 +47,8 @@
|
||||
settings,
|
||||
isLightTheme,
|
||||
temporarySettingsStorage,
|
||||
featureFlags: createFeatureFlagStore(data.featureFlags, () => fetchEvaluatedFeatureFlags(data.graphqlClient)),
|
||||
client: data.graphqlClient,
|
||||
})
|
||||
|
||||
// Update stores when data changes
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { browser } from '$app/environment'
|
||||
import { fetchEvaluatedFeatureFlags } from '$lib/featureflags'
|
||||
import type { CurrentAuthStateResult } from '$lib/graphql/shared'
|
||||
import { getDocumentNode } from '$lib/http-client'
|
||||
import { currentAuthStateQuery } from '$lib/loader/auth'
|
||||
@ -31,5 +32,6 @@ export const load: LayoutLoad = () => {
|
||||
.then(result => result.data.currentUser),
|
||||
// Initial user settings
|
||||
settings: graphqlClient.then(fetchUserSettings),
|
||||
featureFlags: graphqlClient.then(fetchEvaluatedFeatureFlags),
|
||||
}
|
||||
}
|
||||
|
||||
98
client/web-sveltekit/src/testing/mocks.ts
Normal file
98
client/web-sveltekit/src/testing/mocks.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { faker } from '@faker-js/faker'
|
||||
import signale from 'signale'
|
||||
import { writable, type Readable, type Writable } from 'svelte/store'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
import { KEY, type SourcegraphContext } from '$lib/stores'
|
||||
import type { FeatureFlagName } from '$lib/web'
|
||||
|
||||
let fakerRefDate: Date
|
||||
|
||||
/**
|
||||
* Use fake timers and optionally set the current date and reference date for data generation.
|
||||
*/
|
||||
export function useFakeTimers(refDate?: Date) {
|
||||
if (!refDate) {
|
||||
refDate = faker.defaultRefDate()
|
||||
} else {
|
||||
fakerRefDate = faker.defaultRefDate()
|
||||
faker.setDefaultRefDate(refDate)
|
||||
}
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(refDate)
|
||||
faker.setDefaultRefDate(refDate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Use real timers. The reference date for date generation will be
|
||||
* restored to a fixed default value.
|
||||
*/
|
||||
export function useRealTimers() {
|
||||
faker.setDefaultRefDate(fakerRefDate)
|
||||
vi.useFakeTimers()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
|
||||
// Stores all mocked context values
|
||||
export let mockedContexts = new Map<any, any>()
|
||||
|
||||
type SourcegraphContextKey = keyof SourcegraphContext
|
||||
type MockedSourcegraphContextValue<T> = T extends Readable<infer U> ? Writable<U> : T
|
||||
|
||||
// Sets up stubs for mocking the Sourcegraph context. The sourcegraph context makes
|
||||
// certain values available app-wide by using Svelte context API.
|
||||
const unmocked: unique symbol = Symbol('unmocked')
|
||||
const mockedSourcgraphContext: {
|
||||
[key in SourcegraphContextKey]: MockedSourcegraphContextValue<SourcegraphContext[key]> | typeof unmocked
|
||||
} = {
|
||||
user: writable(null),
|
||||
client: unmocked,
|
||||
settings: writable({}),
|
||||
featureFlags: writable([]),
|
||||
isLightTheme: writable(true),
|
||||
temporarySettingsStorage: unmocked,
|
||||
}
|
||||
|
||||
// Creates a proxy object for the mocked Sourcegraph context object.
|
||||
// If a value hasn't been mocked a warning is printed.
|
||||
mockedContexts.set(
|
||||
KEY,
|
||||
Object.defineProperties(
|
||||
{},
|
||||
Object.fromEntries(
|
||||
Object.keys(mockedSourcgraphContext).map(key => [
|
||||
key,
|
||||
{
|
||||
get: () => {
|
||||
if (mockedSourcgraphContext[key as SourcegraphContextKey] === unmocked) {
|
||||
signale.warn(`Sourcegraph context ${key} is unmocked`)
|
||||
}
|
||||
return mockedSourcgraphContext[key as SourcegraphContextKey]
|
||||
},
|
||||
},
|
||||
])
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* Sets the app's feature flags to the provided value. If the function is called multiple times without
|
||||
* calling `unmockFeatureFlags` in between then subsequent calls will update the underlying feature flag
|
||||
* store, updating all subscribers.
|
||||
*/
|
||||
export function mockFeatureFlags(evaluatedFeatureFlags: Partial<Record<FeatureFlagName, boolean>>) {
|
||||
const flags = Object.entries(evaluatedFeatureFlags).map(([name, value]) => ({ name, value }))
|
||||
|
||||
if (mockedSourcgraphContext.featureFlags === unmocked) {
|
||||
mockedSourcgraphContext.featureFlags = writable(flags)
|
||||
} else {
|
||||
mockedSourcgraphContext.featureFlags.set(flags)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmock all feature flags.
|
||||
*/
|
||||
export function unmockFeatureFlags() {
|
||||
mockedSourcgraphContext.featureFlags = unmocked
|
||||
}
|
||||
54
client/web-sveltekit/src/testing/setup.ts
Normal file
54
client/web-sveltekit/src/testing/setup.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { faker } from '@faker-js/faker'
|
||||
import { vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'
|
||||
|
||||
import { mockedContexts } from '$mocks'
|
||||
|
||||
type SvelteAPI = typeof import('svelte')
|
||||
|
||||
// Mock Svelte's context API. This API is usually only available
|
||||
// when rendering components.
|
||||
vi.mock('svelte', async (actual: () => Promise<SvelteAPI>): Promise<SvelteAPI> => {
|
||||
const svelte = await actual()
|
||||
return {
|
||||
...svelte,
|
||||
setContext(key, value) {
|
||||
if (mockedContexts.has(key)) {
|
||||
mockedContexts.set(key, value)
|
||||
} else {
|
||||
svelte.setContext(key, value)
|
||||
}
|
||||
return value
|
||||
},
|
||||
getContext(key) {
|
||||
if (mockedContexts.has(key)) {
|
||||
return mockedContexts.get(key)
|
||||
}
|
||||
try {
|
||||
return svelte.getContext(key)
|
||||
} catch {
|
||||
throw new Error(`Unable to get context '${key}'. Maybe you want to mock it?`)
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
beforeAll(() => {
|
||||
// window.context is accessed by some existing modules
|
||||
vi.stubGlobal('context', {})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
// Set fixed date and faker seed for each tests
|
||||
const date = new Date(2021, 4, 24, 12, 0, 0)
|
||||
faker.setDefaultRefDate(date)
|
||||
faker.seed(24)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
faker.setDefaultRefDate()
|
||||
faker.seed()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
@ -17,8 +17,10 @@ const config = {
|
||||
alias: {
|
||||
// Makes it easier to refer to files outside packages (such as images)
|
||||
$root: '../../',
|
||||
// Makes it easier to refer to files outside packages (such as images)
|
||||
// Used inside tests for easy access to helpers
|
||||
$testdata: 'src/testdata.ts',
|
||||
// Makes it easier to refer to files outside packages (such as images)
|
||||
$mocks: 'src/testing/mocks.ts',
|
||||
// Somehow these aliases are necessary to make CSS imports work. Otherwise
|
||||
// Vite/postcss/whatever tries to the import these relative to the
|
||||
// importing file.
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"jsx": "react",
|
||||
"jsx": "react-jsx",
|
||||
},
|
||||
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
|
||||
//
|
||||
|
||||
@ -32,6 +32,10 @@ const config = defineConfig(({ mode }) => ({
|
||||
'linguist-languages',
|
||||
],
|
||||
},
|
||||
|
||||
test: {
|
||||
setupFiles: './src/testing/setup.ts',
|
||||
},
|
||||
}))
|
||||
|
||||
export default config
|
||||
|
||||
@ -1705,6 +1705,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: ^18.2.0
|
||||
version: 18.2.0(react@18.2.0)
|
||||
signale:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
storybook:
|
||||
specifier: ^7.1.1
|
||||
version: 7.1.1
|
||||
|
||||
Loading…
Reference in New Issue
Block a user