diff --git a/src/guards.test.ts b/src/guards.test.ts new file mode 100644 index 0000000..918e715 --- /dev/null +++ b/src/guards.test.ts @@ -0,0 +1,75 @@ +import { launchDarklyGuard } from './guards' + +const existingFlagKey = 'random-flag-key' +const existingFlagValue = 'random-flag-value' + +jest.mock('./utils', () => ({ + watchEffectOnceAsync: () => Promise.resolve(), +})) + +jest.mock('.', () => ({ + launchDarklyClient: { + value: { + variation: (flagKey: string, defaultValue?: unknown) => { + if (existingFlagKey === flagKey) { + return existingFlagValue + } + + return defaultValue + }, + }, + }, +})) + +describe('guards', () => { + const genericFlag = 'myFlag' + const genericDefaultValue = 'default-value' + const genericArgs: unknown = [] + const genericCallback = () => genericDefaultValue + + const warnSpy = jest.spyOn(global.console, 'warn').mockImplementation(() => undefined) + + test('should return a function', () => { + expect(launchDarklyGuard(existingFlagKey)).toMatchInlineSnapshot(`[Function]`) + }) + + test('should return a promise after invocation', () => { + expect(launchDarklyGuard(existingFlagKey)(genericArgs)).toMatchInlineSnapshot(`Promise {}`) + }) + + test('should return undefined when the flag does not exist', async () => { + const result = await launchDarklyGuard(genericFlag)(genericArgs) + expect(result).toMatchInlineSnapshot(`undefined`) + }) + + test('should return the default value', async () => { + const result = await launchDarklyGuard(genericFlag, null, genericDefaultValue)(genericArgs) + expect(result).toBe(genericDefaultValue) + }) + + test('should return the callback value', async () => { + const result = await launchDarklyGuard(genericFlag, genericCallback)(genericArgs) + expect(result).toBe(genericDefaultValue) + }) + + test('should return the callback value (true)', async () => { + const callbackValue = true + const result = await launchDarklyGuard(genericFlag, () => callbackValue)(genericArgs) + expect(result).toBe(callbackValue) + }) + + test('should return the callback value (false)', async () => { + const callbackValue = false + const result = await launchDarklyGuard(genericFlag, () => callbackValue)(genericArgs) + expect(result).toBe(callbackValue) + }) + + test('should return the existing value (simulates a Launch Darkly real value)', async () => { + const result = await launchDarklyGuard(existingFlagKey)(genericArgs) + expect(result).toBe(existingFlagValue) + }) + + test('should have called console.warn)', async () => { + expect(warnSpy).toHaveBeenCalled() + }) +}) diff --git a/src/guards.ts b/src/guards.ts new file mode 100644 index 0000000..59f057a --- /dev/null +++ b/src/guards.ts @@ -0,0 +1,38 @@ +import { unref } from 'vue' +import { launchDarklyClient, launchDarklyReady } from '.' +import { watchEffectOnceAsync } from './utils' + +/** + * Vue Router Guard. + * + * Provides a way to access flag values in the vue-router guards. + * [per-route-guard](https://router.vuejs.org/guide/advanced/navigation-guards.html#per-route-guard) + * Usage: `beforeEnter: launchDarklyGuard(flagKey, callback, defaultValue),` + * + * Custom logic can be apply using the callback parameter, when this parameter is passed it will be + * in charge of the guard logic. The 'callback' function will hold the resulted flag and the injected + * Vue Router params, check: [NavigationGuard](https://router.vuejs.org/api/interfaces/NavigationGuard.html) + */ +export function launchDarklyGuard(flagKey: string, callback?: ((arg0: boolean, args: unknown) => void) | null, defaultValue?: T): (args: unknown) => Promise { + return async (...args) => { + const fn = () => { + const flag = launchDarklyClient.value.variation(flagKey, defaultValue) + + if (typeof flag !== 'boolean' && typeof callback !== 'function') { + console.warn(`LaunchDarkly guard warning: Be careful, flag key '${flagKey}' does not returns boolean value (${flag}), therefore it can lead to unexpected results.`) + } + + if (typeof callback === 'function') { + return callback(flag, ...args) + } + + return flag + } + + if (!unref(launchDarklyReady)) { + await watchEffectOnceAsync(() => unref(launchDarklyReady)) + } + + return fn() + } +} diff --git a/src/index.ts b/src/index.ts index a77f5d4..1aaf6eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { getLDFlag, type FlagRef } from './getLDFlag' import { version } from '../package.json' import { getContextOrUser } from './utils' export { useLDReady, useLDFlag, ldInit, useLDClient } from './hooks' +export { launchDarklyGuard } from './guards' // Export required types from the base SDK. export type { @@ -75,6 +76,16 @@ export const LD_CLIENT = Symbol() as InjectionKey */ export const LD_FLAG = Symbol() as InjectionKey<(flagKey: string, defaultFlagValue?: T | undefined) => FlagRef> +/** + * Will hold the LaunchDarkly instance. + */ +export const launchDarklyClient = ref() as Ref + +/** + * Will hold the LaunchDarkly readiness. + */ +export const launchDarklyReady = ref(false) as Ref + /** * Vue plugin wrapper for the LaunchDarkly JavaScript SDK. * @@ -104,7 +115,15 @@ export const LDPlugin = { const enableStreaming = pluginOptions.streaming === false || initOptions.streaming === false ? false : true app.provide(LD_FLAG, getLDFlag(ldReady, $ldClient, enableStreaming)) - $ldClient.on('ready', () => (ldReady.value = true)) + // On Launch Darkly client ready + $ldClient.on('ready', () => { + ldReady.value = true + launchDarklyReady.value = true + }) + + // Launch Darkly client instance assignation + launchDarklyClient.value = $ldClient + return [$ldReady, $ldClient] } diff --git a/src/utils.test.ts b/src/utils.test.ts new file mode 100644 index 0000000..8bddf04 --- /dev/null +++ b/src/utils.test.ts @@ -0,0 +1,36 @@ +import { ref, unref, nextTick } from 'vue' +import { watchEffectOnceAsync, watchEffectOnce } from './utils' + +describe('utils', () => { + const genericReactiveValue = ref(false) + let genericCallback = jest.fn() + let genericWatcher = () => unref(genericReactiveValue) + + function reset() { + genericCallback.mockReset() + genericReactiveValue.value = false + genericCallback = jest.fn() + genericWatcher = () => unref(genericReactiveValue) + } + + test('watchEffectOnce', async () => { + reset() + watchEffectOnce(genericWatcher, genericCallback) + genericReactiveValue.value = true + await nextTick() + + expect(genericCallback).toBeCalled() + }) + + test('watchEffectOnceAsync', async () => { + reset() + const fulfilledStatus = 'fulfilled' + const resultPromise = watchEffectOnceAsync(genericWatcher) + genericReactiveValue.value = true + await nextTick() + const resultPromiseSettlement = await Promise.allSettled([resultPromise]) + const resultPromiseStatus = (resultPromiseSettlement.pop() || {}).status + + expect(resultPromiseStatus).toBe(fulfilledStatus) + }) +}) diff --git a/src/utils.ts b/src/utils.ts index 071af65..38a08d7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,37 @@ +import { watchEffect } from 'vue' + import type { LDContext } from 'launchdarkly-js-client-sdk' + import type { LDPluginOptions } from './index' +/** + * @ignore + * Run watchEffect until the watcher returns true, then stop the watch. + * Once it returns true, the promise will resolve. + * [Reference](https://github.com/auth0/auth0-vue/blob/main/src/utils.ts) + */ +export function watchEffectOnceAsync(watcher: () => T) { + return new Promise(resolve => { + watchEffectOnce(watcher, resolve) + }) +} + +/** + * @ignore + * Run watchEffect until the watcher returns true, then stop the watch. + * Once it returns true, it will call the provided function. + * [Reference](https://github.com/auth0/auth0-vue/blob/main/src/utils.ts) + */ +export function watchEffectOnce(watcher: () => T, fn: (value: void | PromiseLike) => void) { + const stopWatch = watchEffect(() => { + if (watcher()) { + fn() + stopWatch() + } + }) +} + + /** * Helper function to get the context or fallback to classic user. * Safe to remove when the user property is deprecated.