diff --git a/client/BUILD.bazel b/client/BUILD.bazel index 07e92772acd..186baa7dde2 100644 --- a/client/BUILD.bazel +++ b/client/BUILD.bazel @@ -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} diff --git a/client/web-sveltekit/package.json b/client/web-sveltekit/package.json index 2c62a7241ee..00d80e1b4ac 100644 --- a/client/web-sveltekit/package.json +++ b/client/web-sveltekit/package.json @@ -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", diff --git a/client/web-sveltekit/src/lib/featureflags/api.ts b/client/web-sveltekit/src/lib/featureflags/api.ts new file mode 100644 index 00000000000..e938944e31e --- /dev/null +++ b/client/web-sveltekit/src/lib/featureflags/api.ts @@ -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 { + return dataOrThrowErrors( + await client.query({ + query: getDocumentNode(FEATUREFLAGS_QUERY), + }) + ).evaluatedFeatureFlags +} diff --git a/client/web-sveltekit/src/lib/featureflags/index.ts b/client/web-sveltekit/src/lib/featureflags/index.ts new file mode 100644 index 00000000000..c8c98b0e5fb --- /dev/null +++ b/client/web-sveltekit/src/lib/featureflags/index.ts @@ -0,0 +1,2 @@ +export * from './stores' +export * from './api' diff --git a/client/web-sveltekit/src/lib/featureflags/stores.test.ts b/client/web-sveltekit/src/lib/featureflags/stores.test.ts new file mode 100644 index 00000000000..955150569f4 --- /dev/null +++ b/client/web-sveltekit/src/lib/featureflags/stores.test.ts @@ -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() + }) + }) +}) diff --git a/client/web-sveltekit/src/lib/featureflags/stores.ts b/client/web-sveltekit/src/lib/featureflags/stores.ts new file mode 100644 index 00000000000..5c31ad23ea9 --- /dev/null +++ b/client/web-sveltekit/src/lib/featureflags/stores.ts @@ -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> = { + 'repository-metadata': true, +} + +export function createFeatureFlagStore( + initialFeatureFlags: FeatureFlag[], + fetchEvaluatedFeatureFlags: () => Promise +): Readable { + return readable(initialFeatureFlags, set => { + const timer = globalThis.setInterval(() => { + fetchEvaluatedFeatureFlags().then(set) + }, FEATURE_FLAG_CACHE_TTL) + + return () => { + globalThis.clearInterval(timer) + } + }) +} + +export function featureFlag(name: FeatureFlagName): Readable { + // TODO: add support for overrides + return derived( + getStores().featureFlags, + $featureFlags => $featureFlags.find(flag => flag.name === name)?.value ?? defaultValues[name] ?? false + ) +} diff --git a/client/web-sveltekit/src/lib/stores.ts b/client/web-sveltekit/src/lib/stores.ts index d29aa657e15..5d573ce88ee 100644 --- a/client/web-sveltekit/src/lib/stores.ts +++ b/client/web-sveltekit/src/lib/stores.ts @@ -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 user: Readable isLightTheme: Readable temporarySettingsStorage: Readable + featureFlags: Readable + client: Readable } export const KEY = '__sourcegraph__' export function getStores(): SourcegraphContext { - const { settings, user, isLightTheme, temporarySettingsStorage } = getContext(KEY) - return { settings, user, isLightTheme, temporarySettingsStorage } + const { settings, user, isLightTheme, temporarySettingsStorage, featureFlags, client } = + getContext(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 = readable(new Date(), set => { + set(new Date()) const interval = setInterval(() => set(new Date()), 1000) return () => clearInterval(interval) }) -export const graphqlClient = readable(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. */ diff --git a/client/web-sveltekit/src/lib/utils/__snapshots__/time.test.ts.snap b/client/web-sveltekit/src/lib/utils/__snapshots__/time.test.ts.snap index b798287c9ca..c396ff1c9ae 100644 --- a/client/web-sveltekit/src/lib/utils/__snapshots__/time.test.ts.snap +++ b/client/web-sveltekit/src/lib/utils/__snapshots__/time.test.ts.snap @@ -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"`; diff --git a/client/web-sveltekit/src/lib/utils/time.test.ts b/client/web-sveltekit/src/lib/utils/time.test.ts index 199fca6d2f4..2825aca88c6 100644 --- a/client/web-sveltekit/src/lib/utils/time.test.ts +++ b/client/web-sveltekit/src/lib/utils/time.test.ts @@ -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): 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() }) }) diff --git a/client/web-sveltekit/src/lib/web.ts b/client/web-sveltekit/src/lib/web.ts index 46119eac162..e99e627d4ab 100644 --- a/client/web-sveltekit/src/lib/web.ts +++ b/client/web-sveltekit/src/lib/web.ts @@ -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 diff --git a/client/web-sveltekit/src/routes/+layout.svelte b/client/web-sveltekit/src/routes/+layout.svelte index d25471ca9b3..3fa37d8db73 100644 --- a/client/web-sveltekit/src/routes/+layout.svelte +++ b/client/web-sveltekit/src/routes/+layout.svelte @@ -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 diff --git a/client/web-sveltekit/src/routes/+layout.ts b/client/web-sveltekit/src/routes/+layout.ts index 23e0fbb62c4..0629afad0e6 100644 --- a/client/web-sveltekit/src/routes/+layout.ts +++ b/client/web-sveltekit/src/routes/+layout.ts @@ -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), } } diff --git a/client/web-sveltekit/src/testing/mocks.ts b/client/web-sveltekit/src/testing/mocks.ts new file mode 100644 index 00000000000..cbcdf41a29c --- /dev/null +++ b/client/web-sveltekit/src/testing/mocks.ts @@ -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() + +type SourcegraphContextKey = keyof SourcegraphContext +type MockedSourcegraphContextValue = T extends Readable ? Writable : 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 | 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>) { + 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 +} diff --git a/client/web-sveltekit/src/testing/setup.ts b/client/web-sveltekit/src/testing/setup.ts new file mode 100644 index 00000000000..4df6776e9da --- /dev/null +++ b/client/web-sveltekit/src/testing/setup.ts @@ -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): Promise => { + 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() +}) diff --git a/client/web-sveltekit/svelte.config.js b/client/web-sveltekit/svelte.config.js index 45f3a390752..c75c58d4e70 100644 --- a/client/web-sveltekit/svelte.config.js +++ b/client/web-sveltekit/svelte.config.js @@ -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. diff --git a/client/web-sveltekit/tsconfig.json b/client/web-sveltekit/tsconfig.json index 75777d057a7..e1c6091e9c4 100644 --- a/client/web-sveltekit/tsconfig.json +++ b/client/web-sveltekit/tsconfig.json @@ -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 // diff --git a/client/web-sveltekit/vite.config.ts b/client/web-sveltekit/vite.config.ts index 3e7da6ea5fd..041d06e43d7 100644 --- a/client/web-sveltekit/vite.config.ts +++ b/client/web-sveltekit/vite.config.ts @@ -32,6 +32,10 @@ const config = defineConfig(({ mode }) => ({ 'linguist-languages', ], }, + + test: { + setupFiles: './src/testing/setup.ts', + }, })) export default config diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70494037c84..83b0da96c6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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