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:
Felix Kling 2023-08-01 13:44:22 +02:00 committed by GitHub
parent 443d754481
commit ee74c030fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 314 additions and 34 deletions

View File

@ -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}

View File

@ -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",

View 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
}

View File

@ -0,0 +1,2 @@
export * from './stores'
export * from './api'

View 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()
})
})
})

View 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
)
}

View File

@ -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.
*/

View File

@ -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"`;

View File

@ -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()
})
})

View File

@ -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

View File

@ -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

View File

@ -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),
}
}

View 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
}

View 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()
})

View File

@ -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.

View 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
//

View File

@ -32,6 +32,10 @@ const config = defineConfig(({ mode }) => ({
'linguist-languages',
],
},
test: {
setupFiles: './src/testing/setup.ts',
},
}))
export default config

View File

@ -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