From 26f564ee80379ce8c882dd5b91c5b83fcc0062b4 Mon Sep 17 00:00:00 2001 From: Robinson Marquez Date: Wed, 23 Oct 2024 17:04:58 -0300 Subject: [PATCH 1/7] add initial SDK library boilerplate and basic svelte LD SDK --- package.json | 1 + packages/sdk/svelte/.gitignore | 13 + packages/sdk/svelte/package.json | 89 +++++++ packages/sdk/svelte/playwright.config.ts | 12 + packages/sdk/svelte/src/app.d.ts | 13 + packages/sdk/svelte/src/app.html | 11 + packages/sdk/svelte/src/lib/LDFlag.svelte | 14 + .../svelte/src/lib/client/SvelteLDClient.ts | 105 ++++++++ .../client/__tests__/SvelteLDClient.test.ts | 244 ++++++++++++++++++ packages/sdk/svelte/src/lib/client/index.ts | 1 + packages/sdk/svelte/src/lib/index.ts | 6 + .../svelte/src/lib/provider/LDProvider.svelte | 19 ++ packages/sdk/svelte/svelte.config.js | 18 ++ packages/sdk/svelte/tsconfig.eslint.json | 5 + packages/sdk/svelte/tsconfig.json | 15 ++ packages/sdk/svelte/tsconfig.ref.json | 7 + packages/sdk/svelte/tsconfig.test.json | 13 + packages/sdk/svelte/typedoc.json | 5 + packages/sdk/svelte/vite.config.ts | 17 ++ tsconfig.json | 3 + 20 files changed, 611 insertions(+) create mode 100644 packages/sdk/svelte/.gitignore create mode 100644 packages/sdk/svelte/package.json create mode 100644 packages/sdk/svelte/playwright.config.ts create mode 100644 packages/sdk/svelte/src/app.d.ts create mode 100644 packages/sdk/svelte/src/app.html create mode 100644 packages/sdk/svelte/src/lib/LDFlag.svelte create mode 100644 packages/sdk/svelte/src/lib/client/SvelteLDClient.ts create mode 100644 packages/sdk/svelte/src/lib/client/__tests__/SvelteLDClient.test.ts create mode 100644 packages/sdk/svelte/src/lib/client/index.ts create mode 100644 packages/sdk/svelte/src/lib/index.ts create mode 100644 packages/sdk/svelte/src/lib/provider/LDProvider.svelte create mode 100644 packages/sdk/svelte/svelte.config.js create mode 100644 packages/sdk/svelte/tsconfig.eslint.json create mode 100644 packages/sdk/svelte/tsconfig.json create mode 100644 packages/sdk/svelte/tsconfig.ref.json create mode 100644 packages/sdk/svelte/tsconfig.test.json create mode 100644 packages/sdk/svelte/typedoc.json create mode 100644 packages/sdk/svelte/vite.config.ts diff --git a/package.json b/package.json index 2778c9e9d..f3ed775d8 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "packages/sdk/react-universal", "packages/sdk/react-universal/example", "packages/sdk/vercel", + "packages/sdk/svelte", "packages/sdk/akamai-base", "packages/sdk/akamai-base/example", "packages/sdk/akamai-edgekv", diff --git a/packages/sdk/svelte/.gitignore b/packages/sdk/svelte/.gitignore new file mode 100644 index 000000000..5396c65c3 --- /dev/null +++ b/packages/sdk/svelte/.gitignore @@ -0,0 +1,13 @@ +.DS_Store +node_modules +/build +/dist +/.svelte-kit +/package +.env +.env.* +!.env.example +vite.config.js.timestamp-* + +# Playwright +/test-results \ No newline at end of file diff --git a/packages/sdk/svelte/package.json b/packages/sdk/svelte/package.json new file mode 100644 index 000000000..86bd18b28 --- /dev/null +++ b/packages/sdk/svelte/package.json @@ -0,0 +1,89 @@ +{ + "name": "@launchdarkly/svelte-client-sdk", + "version": "1.0.0", + "description": "Svelte LaunchDarkly SDK", + "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/svelte", + "repository": { + "type": "git", + "url": "https://github.com/launchdarkly/js-core.git" + }, + "license": "Apache-2.0", + "packageManager": "yarn@3.4.1", + "keywords": [ + "launchdarkly", + "svelte" + ], + "type": "module", + "svelte": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "svelte": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "!dist/**/*.test.*", + "!dist/**/*.spec.*" + ], + "scripts": { + "clean": "rimraf dist", + "dev": "vite dev", + "build": "vite build && npm run package", + "preview": "vite preview", + "package": "svelte-kit sync && svelte-package && publint", + "prepublishOnly": "npm run package", + "lint": "eslint . --ext .ts,.tsx", + "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../.prettierignore", + "check": "yarn prettier && yarn lint && yarn build && yarn test", + "test": "playwright test", + "test:unit": "vitest", + "test:unit-ui": "vitest --ui" + }, + "peerDependencies": { + "@launchdarkly/js-client-sdk-common": "^1.1.4", + "@launchdarkly/node-server-sdk": "^9.4.6", + "launchdarkly-js-client-sdk": "^3.4.0", + "svelte": "^4.0.0" + }, + "dependencies": { + "@launchdarkly/js-client-sdk-common": "1.1.4", + "esm-env": "^1.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.28.1", + "@sveltejs/adapter-auto": "^3.0.0", + "@sveltejs/kit": "^2.0.0", + "@sveltejs/package": "^2.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0", + "@testing-library/svelte": "^5.2.0", + "@types/jest": "^29.5.11", + "@typescript-eslint/eslint-plugin": "^6.20.0", + "@typescript-eslint/parser": "^6.20.0", + "@vitest/ui": "^1.6.0", + "eslint": "^8.45.0", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-airbnb-typescript": "^17.1.0", + "eslint-config-prettier": "^8.8.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-jest": "^27.6.3", + "eslint-plugin-prettier": "^5.0.0", + "eslint-plugin-svelte": "^2.35.1", + "jsdom": "^24.0.0", + "launchdarkly-js-test-helpers": "^2.2.0", + "prettier": "^3.0.0", + "prettier-plugin-svelte": "^3.1.2", + "publint": "^0.1.9", + "rimraf": "^5.0.5", + "svelte": "^4.2.7", + "svelte-check": "^3.6.0", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.2", + "typedoc": "0.25.0", + "typescript": "5.1.6", + "vite": "^5.2.6", + "vitest": "^1.6.0" + } +} diff --git a/packages/sdk/svelte/playwright.config.ts b/packages/sdk/svelte/playwright.config.ts new file mode 100644 index 000000000..1c5d7a1fd --- /dev/null +++ b/packages/sdk/svelte/playwright.config.ts @@ -0,0 +1,12 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; + +const config: PlaywrightTestConfig = { + webServer: { + command: 'npm run build && npm run preview', + port: 4173 + }, + testDir: 'tests', + testMatch: /(.+\.)?(test|spec)\.[jt]s/ +}; + +export default config; diff --git a/packages/sdk/svelte/src/app.d.ts b/packages/sdk/svelte/src/app.d.ts new file mode 100644 index 000000000..ede601ab9 --- /dev/null +++ b/packages/sdk/svelte/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://kit.svelte.dev/docs/types#app +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/packages/sdk/svelte/src/app.html b/packages/sdk/svelte/src/app.html new file mode 100644 index 000000000..f90b0a64e --- /dev/null +++ b/packages/sdk/svelte/src/app.html @@ -0,0 +1,11 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/packages/sdk/svelte/src/lib/LDFlag.svelte b/packages/sdk/svelte/src/lib/LDFlag.svelte new file mode 100644 index 000000000..230e78908 --- /dev/null +++ b/packages/sdk/svelte/src/lib/LDFlag.svelte @@ -0,0 +1,14 @@ + + + {#if $flagValue === matches} + + {:else} + + {/if} diff --git a/packages/sdk/svelte/src/lib/client/SvelteLDClient.ts b/packages/sdk/svelte/src/lib/client/SvelteLDClient.ts new file mode 100644 index 000000000..3ec4515bd --- /dev/null +++ b/packages/sdk/svelte/src/lib/client/SvelteLDClient.ts @@ -0,0 +1,105 @@ +import { initialize } from 'launchdarkly-js-client-sdk'; +import type { + LDClient, + LDFlagSet, + LDFlagValue, + LDContext as NodeLDContext, +} from 'launchdarkly-js-client-sdk'; +import { derived, get, type Readable, readonly, writable, type Writable } from 'svelte/store'; + +/** Client ID for LaunchDarkly */ +export type LDClientID = string; + +/** Context for LaunchDarkly */ +export type LDContext = NodeLDContext; + +/** Value of LaunchDarkly flags */ +export type LDFlagsValue = LDFlagValue; + +/** Flags for LaunchDarkly */ +export type LDFlags = LDFlagSet; + +/** + * Checks if the LaunchDarkly client is initialized. + * @param {LDClient | undefined} client - The LaunchDarkly client. + * @throws {Error} If the client is not initialized. + */ +function isClientInitialized(client: LDClient | undefined): asserts client is LDClient { + if (!client) { + throw new Error('LaunchDarkly client not initialized'); + } +} + +/** + * Creates a LaunchDarkly instance. + * @returns {Object} The LaunchDarkly instance object. + */ +function createLD() { + let jsSdk: LDClient | undefined; + const loading = writable(true); + const flagsWritable = writable({}); + + /** + * Initializes the LaunchDarkly client. + * @param {LDClientID} clientId - The client ID. + * @param {LDContext} context - The context. + * @returns {Writable} An object with the initialization status store. + */ + function LDInitialize(clientId: LDClientID, context: LDContext) { + jsSdk = initialize(clientId, context); + + jsSdk.waitUntilReady().then(() => { + loading.set(false); + flagsWritable.set(jsSdk!.allFlags()); + }); + + jsSdk.on('change', () => { + flagsWritable.set(jsSdk!.allFlags()); + }); + + return { + initializing: loading, + }; + } + + /** + * Identifies the user context. + * @param {LDContext} context - The user context. + * @returns {Promise} A promise that resolves when the user is identified. + */ + async function identify(context: LDContext) { + isClientInitialized(jsSdk); + return jsSdk.identify(context); + } + + /** + * Watches a flag for changes. + * @param {string} flagKey - The key of the flag to watch. + * @returns {Readable} A readable store of the flag value. + */ + const watch = (flagKey: string): Readable => + derived, LDFlagsValue>(flagsWritable, ($flags) => $flags[flagKey]); + + /** + * Checks if a flag is on. + * @param {string} flagKey - The key of the flag to check. + * @returns {boolean} True if the flag is on, false otherwise. + */ + const isOn = (flagKey: string): boolean => { + isClientInitialized(jsSdk); + const currentFlags = get(flagsWritable); + return !!currentFlags[flagKey]; + }; + + return { + identify, + flags: readonly(flagsWritable), + initialize: LDInitialize, + initializing: readonly(loading), + watch, + isOn, + }; +} + +/** The LaunchDarkly instance */ +export const LD = createLD(); diff --git a/packages/sdk/svelte/src/lib/client/__tests__/SvelteLDClient.test.ts b/packages/sdk/svelte/src/lib/client/__tests__/SvelteLDClient.test.ts new file mode 100644 index 000000000..7624bb5fe --- /dev/null +++ b/packages/sdk/svelte/src/lib/client/__tests__/SvelteLDClient.test.ts @@ -0,0 +1,244 @@ +import * as LDClient from 'launchdarkly-js-client-sdk'; +import { get } from 'svelte/store'; +import { afterAll, afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; + +import { LD } from '../SvelteLDClient.js'; + +vi.mock('launchdarkly-js-client-sdk', async (importActual) => { + const actual = (await importActual()) as typeof LDClient; + return { + ...actual, + initialize: vi.fn(), + }; +}); + +const clientSideID = 'test-client-side-id'; +const rawFlags = { 'test-flag': true, 'another-test-flag': true }; +const mockLDClient = { + on: vi.fn((e: string, cb: () => void) => { + cb(); + }), + off: vi.fn(), + allFlags: vi.fn().mockReturnValue({}), + variation: vi.fn(), + waitForInitialization: vi.fn(), + waitUntilReady: vi.fn().mockResolvedValue(undefined), + identify: vi.fn(), +}; +const mockInitialize = LDClient.initialize as Mock; +const mockAllFlags = mockLDClient.allFlags as Mock; + +describe('launchDarkly', () => { + describe('createLD', () => { + it('should create a LaunchDarkly instance with correct properties', () => { + const ld = LD; + expect(typeof ld).toBe('object'); + expect(ld).toHaveProperty('identify'); + expect(ld).toHaveProperty('flags'); + expect(ld).toHaveProperty('initialize'); + expect(ld).toHaveProperty('initializing'); + expect(ld).toHaveProperty('watch'); + expect(ld).toHaveProperty('isOn'); + }); + + describe('initialize', async () => { + let ld = LD; + beforeEach(() => { + mockInitialize.mockImplementation(() => mockLDClient); + mockAllFlags.mockImplementation(() => rawFlags); + }); + + afterEach(() => { + mockInitialize.mockClear(); + mockAllFlags.mockClear(); + }); + + afterAll(() => { + vi.clearAllMocks(); + }); + + it('should throw an error if the client is not initialized', async () => { + ld = LD; + expect(() => ld.isOn('test-flag')).toThrow('LaunchDarkly client not initialized'); + await expect(() => ld.identify({ key: 'user1' })).rejects.toThrow( + 'LaunchDarkly client not initialized', + ); + }); + + it('should set the loading status to false when the client is ready', async () => { + const { initializing } = ld; + ld.initialize('clientId', { key: 'user1' }); + + // wait for next tick + await new Promise((r) => { + setTimeout(r); + }); + + const initializingValue = get(initializing); + expect(initializingValue).toBe(false); + }); + it('should initialize the LaunchDarkly SDK instance', () => { + const initializeSpy = vi.spyOn(LDClient, 'initialize'); + + ld.initialize('clientId', { key: 'user1' }); + expect(initializeSpy).toHaveBeenCalledWith('clientId', { key: 'user1' }); + }); + + it('should call waitUntilReady when initializing', () => { + const waitUntilReadySpy = vi.spyOn(mockLDClient, 'waitUntilReady'); + + ld.initialize('clientId', { key: 'user1' }); + + expect(waitUntilReadySpy).toHaveBeenCalled(); + }); + + it('should register an event listener for the "change" event', () => { + const onSpy = vi.spyOn(mockLDClient, 'on'); + + ld.initialize('clientId ', { key: 'user1' }); + + expect(onSpy).toHaveBeenCalled(); + expect(onSpy).toHaveBeenCalledWith('change', expect.any(Function)); + }); + + it('should set flags when the client is ready', () => { + const flagSubscriber = vi.fn(); + ld.initialize('clientId', { key: 'user1' }); + + const subscribeSpy = vi.spyOn(ld.flags, 'subscribe'); + ld.flags.subscribe(flagSubscriber); + + expect(subscribeSpy).toBeDefined(); + expect(flagSubscriber).toHaveBeenCalledTimes(1); + expect(flagSubscriber).toHaveBeenCalledWith(rawFlags); + }); + }); + describe('watch function', () => { + const ld = LD; + beforeEach(() => { + mockInitialize.mockImplementation(() => mockLDClient); + mockAllFlags.mockImplementation(() => rawFlags); + }); + + it('should return a derived store that reflects the value of the specified flag', () => { + const flagKey = 'test-flag'; + ld.initialize(clientSideID, { key: 'user1' }); + + const flagStore = ld.watch(flagKey); + + expect(get(flagStore)).toBe(true); + }); + + it('should update the flag store when the flag value changes', () => { + const flagKey = 'test-flag'; + ld.initialize(clientSideID, { key: 'user1' }); + + const flagStore = ld.watch(flagKey); + + expect(get(flagStore)).toBe(true); + + mockAllFlags.mockReturnValue({ ...rawFlags, 'test-flag': false }); + + // dispatch a change event on ldClient + const changeCallback = mockLDClient.on.mock.calls[0][1]; + changeCallback(); + + expect(get(flagStore)).toBe(false); + }); + + it('should return undefined if the flag is not found', () => { + const flagKey = 'non-existent-flag'; + ld.initialize(clientSideID, { key: 'user1' }); + + const flagStore = ld.watch(flagKey); + + expect(get(flagStore)).toBeUndefined(); + }); + }); + + describe('isOn function', () => { + const ld = LD; + beforeEach(() => { + mockInitialize.mockImplementation(() => mockLDClient); + mockAllFlags.mockImplementation(() => rawFlags); + }); + + it('should return true if the flag is on', () => { + const flagKey = 'test-flag'; + ld.initialize(clientSideID, { key: 'user1' }); + + expect(ld.isOn(flagKey)).toBe(true); + }); + + it('should return false if the flag is off', () => { + const flagKey = 'test-flag'; + ld.initialize(clientSideID, { key: 'user1' }); + + mockAllFlags.mockReturnValue({ ...rawFlags, 'test-flag': false }); + + // dispatch a change event on ldClient + const changeCallback = mockLDClient.on.mock.calls[0][1]; + changeCallback(); + + expect(ld.isOn(flagKey)).toBe(false); + }); + + it('should return false if the flag is not found', () => { + const flagKey = 'non-existent-flag'; + ld.initialize(clientSideID, { key: 'user1' }); + + expect(ld.isOn(flagKey)).toBe(false); + }); + }); + + describe('identify function', () => { + const ld = LD; + beforeEach(() => { + mockInitialize.mockImplementation(() => mockLDClient); + mockAllFlags.mockImplementation(() => rawFlags); + }); + + it('should call the identify method on the LaunchDarkly client', () => { + const user = { key: 'user1' }; + ld.initialize(clientSideID, user); + + ld.identify(user); + + expect(mockLDClient.identify).toHaveBeenCalledWith(user); + }); + }); + + describe('flags store', () => { + const ld = LD; + beforeEach(() => { + mockInitialize.mockImplementation(() => mockLDClient); + mockAllFlags.mockImplementation(() => rawFlags); + }); + + it('should return a readonly store of the flags', () => { + ld.initialize(clientSideID, { key: 'user1' }); + + const { flags } = ld; + + expect(get(flags)).toEqual(rawFlags); + }); + + it('should update the flags store when the flags change', () => { + ld.initialize(clientSideID, { key: 'user1' }); + + const { flags } = ld; + + expect(get(flags)).toEqual(rawFlags); + + const newFlags = { 'test-flag': false, 'another-test-flag': true }; + mockAllFlags.mockReturnValue(newFlags); + + // dispatch a change event on ldClient + const changeCallback = mockLDClient.on.mock.calls[0][1]; + changeCallback(); + + expect(get(flags)).toEqual(newFlags); + }); + }); + }); +}); diff --git a/packages/sdk/svelte/src/lib/client/index.ts b/packages/sdk/svelte/src/lib/client/index.ts new file mode 100644 index 000000000..64d6e6118 --- /dev/null +++ b/packages/sdk/svelte/src/lib/client/index.ts @@ -0,0 +1 @@ +export * from './SvelteLDClient'; diff --git a/packages/sdk/svelte/src/lib/index.ts b/packages/sdk/svelte/src/lib/index.ts new file mode 100644 index 000000000..32c2c870b --- /dev/null +++ b/packages/sdk/svelte/src/lib/index.ts @@ -0,0 +1,6 @@ +// Reexport your entry components here +export * as LDClient from './client/SvelteLDClient.js'; + +// Export Components +export { default as LDProvider } from './provider/LDProvider.svelte'; +export { default as LDFlag } from './LDFlag.svelte'; diff --git a/packages/sdk/svelte/src/lib/provider/LDProvider.svelte b/packages/sdk/svelte/src/lib/provider/LDProvider.svelte new file mode 100644 index 000000000..d5a53be7f --- /dev/null +++ b/packages/sdk/svelte/src/lib/provider/LDProvider.svelte @@ -0,0 +1,19 @@ + + +{#if $$slots.initializing && $initializing} + Loading flags (default loading slot value)... +{:else} + +{/if} diff --git a/packages/sdk/svelte/svelte.config.js b/packages/sdk/svelte/svelte.config.js new file mode 100644 index 000000000..734094d0b --- /dev/null +++ b/packages/sdk/svelte/svelte.config.js @@ -0,0 +1,18 @@ +import adapter from '@sveltejs/adapter-auto'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + // Consult https://kit.svelte.dev/docs/integrations#preprocessors + // for more information about preprocessors + preprocess: vitePreprocess(), + + kit: { + // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. + // If your environment is not supported or you settled on a specific environment, switch out the adapter. + // See https://kit.svelte.dev/docs/adapters for more information about adapters. + adapter: adapter() + } +}; + +export default config; diff --git a/packages/sdk/svelte/tsconfig.eslint.json b/packages/sdk/svelte/tsconfig.eslint.json new file mode 100644 index 000000000..8241f86c3 --- /dev/null +++ b/packages/sdk/svelte/tsconfig.eslint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["/**/*.ts", "/**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/packages/sdk/svelte/tsconfig.json b/packages/sdk/svelte/tsconfig.json new file mode 100644 index 000000000..8ed3dd7f2 --- /dev/null +++ b/packages/sdk/svelte/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "module": "NodeNext", + "moduleResolution": "NodeNext" + } +} diff --git a/packages/sdk/svelte/tsconfig.ref.json b/packages/sdk/svelte/tsconfig.ref.json new file mode 100644 index 000000000..34a1cb607 --- /dev/null +++ b/packages/sdk/svelte/tsconfig.ref.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*", "package.json"], + "compilerOptions": { + "composite": true + } +} diff --git a/packages/sdk/svelte/tsconfig.test.json b/packages/sdk/svelte/tsconfig.test.json new file mode 100644 index 000000000..8d49b842c --- /dev/null +++ b/packages/sdk/svelte/tsconfig.test.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "lib": ["es6", "dom"], + "module": "ES6", + "moduleResolution": "node", + "resolveJsonModule": true, + "rootDir": ".", + "strict": true, + "types": ["jest", "node"] + }, + "exclude": ["dist", "node_modules", "__tests__", "example"] +} diff --git a/packages/sdk/svelte/typedoc.json b/packages/sdk/svelte/typedoc.json new file mode 100644 index 000000000..7ac616b54 --- /dev/null +++ b/packages/sdk/svelte/typedoc.json @@ -0,0 +1,5 @@ +{ + "extends": ["../../../typedoc.base.json"], + "entryPoints": ["src/index.ts"], + "out": "docs" +} diff --git a/packages/sdk/svelte/vite.config.ts b/packages/sdk/svelte/vite.config.ts new file mode 100644 index 000000000..0bc6e5ea6 --- /dev/null +++ b/packages/sdk/svelte/vite.config.ts @@ -0,0 +1,17 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import path from 'path'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [sveltekit()], + resolve: { + alias: { + lib: path.resolve(__dirname, 'src/lib'), + }, + }, + test: { + include: ['src/**/*.{test,spec}.{js,ts,svelte}'], + globals: true, + environment: 'jsdom', + }, +}); diff --git a/tsconfig.json b/tsconfig.json index e7ffa9fe3..6ce908909 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -40,6 +40,9 @@ { "path": "./packages/sdk/akamai-base/tsconfig.ref.json" }, + { + "path": "./packages/sdk/svelte/tsconfig.ref.json" + }, { "path": "./packages/store/node-server-sdk-redis/tsconfig.ref.json" }, From 22a3378cbd95cdb4490bf47b40b52e93f5da6f8e Mon Sep 17 00:00:00 2001 From: Robinson Marquez Date: Wed, 23 Oct 2024 17:37:26 -0300 Subject: [PATCH 2/7] Refactor test file paths and update Vite config --- .../__tests__ => __tests__/lib/client}/SvelteLDClient.test.ts | 2 +- packages/sdk/svelte/vite.config.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename packages/sdk/svelte/{src/lib/client/__tests__ => __tests__/lib/client}/SvelteLDClient.test.ts (99%) diff --git a/packages/sdk/svelte/src/lib/client/__tests__/SvelteLDClient.test.ts b/packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts similarity index 99% rename from packages/sdk/svelte/src/lib/client/__tests__/SvelteLDClient.test.ts rename to packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts index 7624bb5fe..ac821617a 100644 --- a/packages/sdk/svelte/src/lib/client/__tests__/SvelteLDClient.test.ts +++ b/packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts @@ -2,7 +2,7 @@ import * as LDClient from 'launchdarkly-js-client-sdk'; import { get } from 'svelte/store'; import { afterAll, afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; -import { LD } from '../SvelteLDClient.js'; +import { LD } from '../../../src/lib/client/SvelteLDClient'; vi.mock('launchdarkly-js-client-sdk', async (importActual) => { const actual = (await importActual()) as typeof LDClient; diff --git a/packages/sdk/svelte/vite.config.ts b/packages/sdk/svelte/vite.config.ts index 0bc6e5ea6..eef9c1da2 100644 --- a/packages/sdk/svelte/vite.config.ts +++ b/packages/sdk/svelte/vite.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ }, }, test: { - include: ['src/**/*.{test,spec}.{js,ts,svelte}'], + include: ['__tests__/**/*.{test,spec}.{js,ts,svelte}'], globals: true, environment: 'jsdom', }, From 147f67b04a4203d35f2ee3c15ef9b9994b877c84 Mon Sep 17 00:00:00 2001 From: Robinson Marquez Date: Wed, 6 Nov 2024 18:43:53 -0300 Subject: [PATCH 3/7] intial refactor to use new "@launchdarkly/js-client-sdk" --- .../lib/client/SvelteLDClient.test.ts | 269 +++++++++--------- packages/sdk/svelte/package.json | 9 +- .../svelte/src/lib/client/SvelteLDClient.ts | 38 ++- 3 files changed, 159 insertions(+), 157 deletions(-) diff --git a/packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts b/packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts index ac821617a..b0f927954 100644 --- a/packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts +++ b/packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts @@ -1,32 +1,26 @@ -import * as LDClient from 'launchdarkly-js-client-sdk'; +import { EventEmitter } from 'node:events'; import { get } from 'svelte/store'; -import { afterAll, afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; +import { afterAll, afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; + +import { initialize, LDClient } from '@launchdarkly/js-client-sdk'; import { LD } from '../../../src/lib/client/SvelteLDClient'; -vi.mock('launchdarkly-js-client-sdk', async (importActual) => { - const actual = (await importActual()) as typeof LDClient; - return { - ...actual, - initialize: vi.fn(), - }; -}); +vi.mock('@launchdarkly/js-client-sdk', { spy: true }); const clientSideID = 'test-client-side-id'; -const rawFlags = { 'test-flag': true, 'another-test-flag': true }; +const rawFlags = { 'test-flag': true, 'another-test-flag': 'flag-value' }; + +// used to mock ready and change events on the LDClient +const mockLDEventEmitter = new EventEmitter(); + const mockLDClient = { - on: vi.fn((e: string, cb: () => void) => { - cb(); - }), + on: (e: string, cb: () => void) => mockLDEventEmitter.on(e, cb), off: vi.fn(), - allFlags: vi.fn().mockReturnValue({}), + allFlags: vi.fn().mockReturnValue(rawFlags), variation: vi.fn(), - waitForInitialization: vi.fn(), - waitUntilReady: vi.fn().mockResolvedValue(undefined), identify: vi.fn(), }; -const mockInitialize = LDClient.initialize as Mock; -const mockAllFlags = mockLDClient.allFlags as Mock; describe('launchDarkly', () => { describe('createLD', () => { @@ -42,87 +36,87 @@ describe('launchDarkly', () => { }); describe('initialize', async () => { - let ld = LD; + const ld = LD; + beforeEach(() => { - mockInitialize.mockImplementation(() => mockLDClient); - mockAllFlags.mockImplementation(() => rawFlags); + // mocks the initialize function to return the mockLDClient + (initialize as Mock).mockReturnValue( + mockLDClient as unknown as LDClient, + ); }); afterEach(() => { - mockInitialize.mockClear(); - mockAllFlags.mockClear(); - }); - - afterAll(() => { vi.clearAllMocks(); + mockLDEventEmitter.removeAllListeners(); }); it('should throw an error if the client is not initialized', async () => { - ld = LD; - expect(() => ld.isOn('test-flag')).toThrow('LaunchDarkly client not initialized'); - await expect(() => ld.identify({ key: 'user1' })).rejects.toThrow( + const flagKey = 'test-flag'; + const user = { key: 'user1' }; + + expect(() => ld.isOn(flagKey)).toThrow('LaunchDarkly client not initialized'); + await expect(() => ld.identify(user)).rejects.toThrow( 'LaunchDarkly client not initialized', ); }); it('should set the loading status to false when the client is ready', async () => { const { initializing } = ld; - ld.initialize('clientId', { key: 'user1' }); + ld.initialize('clientId'); - // wait for next tick - await new Promise((r) => { - setTimeout(r); - }); + expect(get(initializing)).toBe(true); // should be true before the ready event is emitted + mockLDEventEmitter.emit('ready'); - const initializingValue = get(initializing); - expect(initializingValue).toBe(false); + expect(get(initializing)).toBe(false); }); - it('should initialize the LaunchDarkly SDK instance', () => { - const initializeSpy = vi.spyOn(LDClient, 'initialize'); - ld.initialize('clientId', { key: 'user1' }); - expect(initializeSpy).toHaveBeenCalledWith('clientId', { key: 'user1' }); - }); - - it('should call waitUntilReady when initializing', () => { - const waitUntilReadySpy = vi.spyOn(mockLDClient, 'waitUntilReady'); - - ld.initialize('clientId', { key: 'user1' }); + it('should initialize the LaunchDarkly SDK instance', () => { + ld.initialize('clientId'); - expect(waitUntilReadySpy).toHaveBeenCalled(); + expect(initialize).toHaveBeenCalledWith('clientId'); }); - it('should register an event listener for the "change" event', () => { - const onSpy = vi.spyOn(mockLDClient, 'on'); + it('should register function that gets flag values when client is ready', () => { + const newFlags = { ...rawFlags, 'new-flag': true }; + const allFlagsSpy = vi.spyOn(mockLDClient, 'allFlags').mockReturnValue(newFlags); - ld.initialize('clientId ', { key: 'user1' }); + ld.initialize('clientId'); + mockLDEventEmitter.emit('ready'); - expect(onSpy).toHaveBeenCalled(); - expect(onSpy).toHaveBeenCalledWith('change', expect.any(Function)); + expect(allFlagsSpy).toHaveBeenCalledOnce(); + expect(allFlagsSpy).toHaveReturnedWith(newFlags); }); - it('should set flags when the client is ready', () => { - const flagSubscriber = vi.fn(); - ld.initialize('clientId', { key: 'user1' }); + it('should register function that gets flag values when flags changed', () => { + const changedFlags = { ...rawFlags, 'changed-flag': true }; + const allFlagsSpy = vi.spyOn(mockLDClient, 'allFlags').mockReturnValue(changedFlags); - const subscribeSpy = vi.spyOn(ld.flags, 'subscribe'); - ld.flags.subscribe(flagSubscriber); + ld.initialize('clientId'); + mockLDEventEmitter.emit('change'); - expect(subscribeSpy).toBeDefined(); - expect(flagSubscriber).toHaveBeenCalledTimes(1); - expect(flagSubscriber).toHaveBeenCalledWith(rawFlags); + expect(allFlagsSpy).toHaveBeenCalledOnce(); + expect(allFlagsSpy).toHaveReturnedWith(changedFlags); }); }); + describe('watch function', () => { const ld = LD; + beforeEach(() => { - mockInitialize.mockImplementation(() => mockLDClient); - mockAllFlags.mockImplementation(() => rawFlags); + // mocks the initialize function to return the mockLDClient + (initialize as Mock).mockReturnValue( + mockLDClient as unknown as LDClient, + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + mockLDEventEmitter.removeAllListeners(); }); it('should return a derived store that reflects the value of the specified flag', () => { const flagKey = 'test-flag'; - ld.initialize(clientSideID, { key: 'user1' }); + ld.initialize(clientSideID); const flagStore = ld.watch(flagKey); @@ -130,25 +124,33 @@ describe('launchDarkly', () => { }); it('should update the flag store when the flag value changes', () => { - const flagKey = 'test-flag'; - ld.initialize(clientSideID, { key: 'user1' }); - - const flagStore = ld.watch(flagKey); + const booleanFlagKey = 'test-flag'; + const stringFlagKey = 'another-test-flag'; + ld.initialize(clientSideID); + const flagStore = ld.watch(booleanFlagKey); + const flagStore2 = ld.watch(stringFlagKey); + // 'test-flag' initial value is true according to `rawFlags` expect(get(flagStore)).toBe(true); + // 'another-test-flag' intial value is 'flag-value' according to `rawFlags` + expect(get(flagStore2)).toBe('flag-value'); - mockAllFlags.mockReturnValue({ ...rawFlags, 'test-flag': false }); + mockLDClient.allFlags.mockReturnValue({ + ...rawFlags, + 'test-flag': false, + 'another-test-flag': 'new-flag-value', + }); // dispatch a change event on ldClient - const changeCallback = mockLDClient.on.mock.calls[0][1]; - changeCallback(); + mockLDEventEmitter.emit('change'); expect(get(flagStore)).toBe(false); + expect(get(flagStore2)).toBe('new-flag-value'); }); it('should return undefined if the flag is not found', () => { const flagKey = 'non-existent-flag'; - ld.initialize(clientSideID, { key: 'user1' }); + ld.initialize(clientSideID); const flagStore = ld.watch(flagKey); @@ -156,89 +158,90 @@ describe('launchDarkly', () => { }); }); - describe('isOn function', () => { - const ld = LD; - beforeEach(() => { - mockInitialize.mockImplementation(() => mockLDClient); - mockAllFlags.mockImplementation(() => rawFlags); - }); + // TODO: fix these tests + // describe('isOn function', () => { + // const ld = LD; + // // beforeEach(() => { + // // mockInitialize.mockImplementation(() => mockLDClient); + // // mockAllFlags.mockImplementation(() => rawFlags); + // // }); - it('should return true if the flag is on', () => { - const flagKey = 'test-flag'; - ld.initialize(clientSideID, { key: 'user1' }); + // it('should return true if the flag is on', () => { + // const flagKey = 'test-flag'; + // ld.initialize(clientSideID, { key: 'user1' }); - expect(ld.isOn(flagKey)).toBe(true); - }); + // expect(ld.isOn(flagKey)).toBe(true); + // }); - it('should return false if the flag is off', () => { - const flagKey = 'test-flag'; - ld.initialize(clientSideID, { key: 'user1' }); + // it('should return false if the flag is off', () => { + // const flagKey = 'test-flag'; + // ld.initialize(clientSideID, { key: 'user1' }); - mockAllFlags.mockReturnValue({ ...rawFlags, 'test-flag': false }); + // mockAllFlags.mockReturnValue({ ...rawFlags, 'test-flag': false }); - // dispatch a change event on ldClient - const changeCallback = mockLDClient.on.mock.calls[0][1]; - changeCallback(); + // // dispatch a change event on ldClient + // const changeCallback = mockLDClient.on.mock.calls[0][1]; + // changeCallback(); - expect(ld.isOn(flagKey)).toBe(false); - }); + // expect(ld.isOn(flagKey)).toBe(false); + // }); - it('should return false if the flag is not found', () => { - const flagKey = 'non-existent-flag'; - ld.initialize(clientSideID, { key: 'user1' }); + // it('should return false if the flag is not found', () => { + // const flagKey = 'non-existent-flag'; + // ld.initialize(clientSideID, { key: 'user1' }); - expect(ld.isOn(flagKey)).toBe(false); - }); - }); + // expect(ld.isOn(flagKey)).toBe(false); + // }); + // }); - describe('identify function', () => { - const ld = LD; - beforeEach(() => { - mockInitialize.mockImplementation(() => mockLDClient); - mockAllFlags.mockImplementation(() => rawFlags); - }); + // describe('identify function', () => { + // const ld = LD; + // beforeEach(() => { + // mockInitialize.mockImplementation(() => mockLDClient); + // mockAllFlags.mockImplementation(() => rawFlags); + // }); - it('should call the identify method on the LaunchDarkly client', () => { - const user = { key: 'user1' }; - ld.initialize(clientSideID, user); + // it('should call the identify method on the LaunchDarkly client', () => { + // const user = { key: 'user1' }; + // ld.initialize(clientSideID, user); - ld.identify(user); + // ld.identify(user); - expect(mockLDClient.identify).toHaveBeenCalledWith(user); - }); - }); + // expect(mockLDClient.identify).toHaveBeenCalledWith(user); + // }); + // }); - describe('flags store', () => { - const ld = LD; - beforeEach(() => { - mockInitialize.mockImplementation(() => mockLDClient); - mockAllFlags.mockImplementation(() => rawFlags); - }); + // describe('flags store', () => { + // const ld = LD; + // beforeEach(() => { + // mockInitialize.mockImplementation(() => mockLDClient); + // mockAllFlags.mockImplementation(() => rawFlags); + // }); - it('should return a readonly store of the flags', () => { - ld.initialize(clientSideID, { key: 'user1' }); + // it('should return a readonly store of the flags', () => { + // ld.initialize(clientSideID, { key: 'user1' }); - const { flags } = ld; + // const { flags } = ld; - expect(get(flags)).toEqual(rawFlags); - }); + // expect(get(flags)).toEqual(rawFlags); + // }); - it('should update the flags store when the flags change', () => { - ld.initialize(clientSideID, { key: 'user1' }); + // it('should update the flags store when the flags change', () => { + // ld.initialize(clientSideID, { key: 'user1' }); - const { flags } = ld; + // const { flags } = ld; - expect(get(flags)).toEqual(rawFlags); + // expect(get(flags)).toEqual(rawFlags); - const newFlags = { 'test-flag': false, 'another-test-flag': true }; - mockAllFlags.mockReturnValue(newFlags); + // const newFlags = { 'test-flag': false, 'another-test-flag': true }; + // mockAllFlags.mockReturnValue(newFlags); - // dispatch a change event on ldClient - const changeCallback = mockLDClient.on.mock.calls[0][1]; - changeCallback(); + // // dispatch a change event on ldClient + // const changeCallback = mockLDClient.on.mock.calls[0][1]; + // changeCallback(); - expect(get(flags)).toEqual(newFlags); - }); - }); + // expect(get(flags)).toEqual(newFlags); + // }); + // }); }); }); diff --git a/packages/sdk/svelte/package.json b/packages/sdk/svelte/package.json index 86bd18b28..cf3914e34 100644 --- a/packages/sdk/svelte/package.json +++ b/packages/sdk/svelte/package.json @@ -43,13 +43,14 @@ "test:unit-ui": "vitest --ui" }, "peerDependencies": { - "@launchdarkly/js-client-sdk-common": "^1.1.4", + "@launchdarkly/js-client-sdk": "workspace:^", + "@launchdarkly/js-client-sdk-common": "^1.10.0", "@launchdarkly/node-server-sdk": "^9.4.6", - "launchdarkly-js-client-sdk": "^3.4.0", "svelte": "^4.0.0" }, "dependencies": { - "@launchdarkly/js-client-sdk-common": "1.1.4", + "@launchdarkly/js-client-sdk": "workspace:^", + "@launchdarkly/js-client-sdk-common": "1.10.0", "esm-env": "^1.0.0" }, "devDependencies": { @@ -84,6 +85,6 @@ "typedoc": "0.25.0", "typescript": "5.1.6", "vite": "^5.2.6", - "vitest": "^1.6.0" + "vitest": "^2.1.4" } } diff --git a/packages/sdk/svelte/src/lib/client/SvelteLDClient.ts b/packages/sdk/svelte/src/lib/client/SvelteLDClient.ts index 3ec4515bd..a251a37d3 100644 --- a/packages/sdk/svelte/src/lib/client/SvelteLDClient.ts +++ b/packages/sdk/svelte/src/lib/client/SvelteLDClient.ts @@ -1,24 +1,21 @@ -import { initialize } from 'launchdarkly-js-client-sdk'; -import type { - LDClient, - LDFlagSet, - LDFlagValue, - LDContext as NodeLDContext, -} from 'launchdarkly-js-client-sdk'; import { derived, get, type Readable, readonly, writable, type Writable } from 'svelte/store'; +import { + initialize, + type LDClient, + type LDContext, + type LDFlagSet, +} from '@launchdarkly/js-client-sdk'; + /** Client ID for LaunchDarkly */ export type LDClientID = string; -/** Context for LaunchDarkly */ -export type LDContext = NodeLDContext; - -/** Value of LaunchDarkly flags */ -export type LDFlagsValue = LDFlagValue; - /** Flags for LaunchDarkly */ export type LDFlags = LDFlagSet; +/** Value of LaunchDarkly flags */ +export type LDFlagsValue = LDFlagSet[string]; + /** * Checks if the LaunchDarkly client is initialized. * @param {LDClient | undefined} client - The LaunchDarkly client. @@ -42,19 +39,20 @@ function createLD() { /** * Initializes the LaunchDarkly client. * @param {LDClientID} clientId - The client ID. - * @param {LDContext} context - The context. * @returns {Writable} An object with the initialization status store. */ - function LDInitialize(clientId: LDClientID, context: LDContext) { - jsSdk = initialize(clientId, context); - jsSdk.waitUntilReady().then(() => { + function LDInitialize(clientId: LDClientID) { + jsSdk = initialize(clientId); + jsSdk!.on('ready', () => { loading.set(false); - flagsWritable.set(jsSdk!.allFlags()); + const allFlags = jsSdk!.allFlags(); + flagsWritable.set(allFlags); }); - jsSdk.on('change', () => { - flagsWritable.set(jsSdk!.allFlags()); + jsSdk!.on('change', () => { + const allFlags = jsSdk!.allFlags(); + flagsWritable.set(allFlags); }); return { From 938314c847fe3c2458aabb70ef0fa9f2fd9b1998 Mon Sep 17 00:00:00 2001 From: Robinson Marquez Date: Wed, 6 Nov 2024 18:53:10 -0300 Subject: [PATCH 4/7] refactor: update SvelteLDClient tests to use clientSideID --- .../lib/client/SvelteLDClient.test.ts | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts b/packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts index b0f927954..551d82852 100644 --- a/packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts +++ b/packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events'; import { get } from 'svelte/store'; -import { afterAll, afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import { initialize, LDClient } from '@launchdarkly/js-client-sdk'; @@ -62,7 +62,7 @@ describe('launchDarkly', () => { it('should set the loading status to false when the client is ready', async () => { const { initializing } = ld; - ld.initialize('clientId'); + ld.initialize(clientSideID); expect(get(initializing)).toBe(true); // should be true before the ready event is emitted mockLDEventEmitter.emit('ready'); @@ -71,16 +71,16 @@ describe('launchDarkly', () => { }); it('should initialize the LaunchDarkly SDK instance', () => { - ld.initialize('clientId'); + ld.initialize(clientSideID); - expect(initialize).toHaveBeenCalledWith('clientId'); + expect(initialize).toHaveBeenCalledWith('test-client-side-id'); }); it('should register function that gets flag values when client is ready', () => { const newFlags = { ...rawFlags, 'new-flag': true }; const allFlagsSpy = vi.spyOn(mockLDClient, 'allFlags').mockReturnValue(newFlags); - ld.initialize('clientId'); + ld.initialize(clientSideID); mockLDEventEmitter.emit('ready'); expect(allFlagsSpy).toHaveBeenCalledOnce(); @@ -91,7 +91,7 @@ describe('launchDarkly', () => { const changedFlags = { ...rawFlags, 'changed-flag': true }; const allFlagsSpy = vi.spyOn(mockLDClient, 'allFlags').mockReturnValue(changedFlags); - ld.initialize('clientId'); + ld.initialize(clientSideID); mockLDEventEmitter.emit('change'); expect(allFlagsSpy).toHaveBeenCalledOnce(); @@ -161,21 +161,29 @@ describe('launchDarkly', () => { // TODO: fix these tests // describe('isOn function', () => { // const ld = LD; - // // beforeEach(() => { - // // mockInitialize.mockImplementation(() => mockLDClient); - // // mockAllFlags.mockImplementation(() => rawFlags); - // // }); + + // beforeEach(() => { + // // mocks the initialize function to return the mockLDClient + // (initialize as Mock).mockReturnValue( + // mockLDClient as unknown as LDClient, + // ); + // }); + + // afterEach(() => { + // vi.clearAllMocks(); + // mockLDEventEmitter.removeAllListeners(); + // }); // it('should return true if the flag is on', () => { // const flagKey = 'test-flag'; - // ld.initialize(clientSideID, { key: 'user1' }); + // ld.initialize(clientSideID); // expect(ld.isOn(flagKey)).toBe(true); // }); // it('should return false if the flag is off', () => { // const flagKey = 'test-flag'; - // ld.initialize(clientSideID, { key: 'user1' }); + // ld.initialize(clientSideID); // mockAllFlags.mockReturnValue({ ...rawFlags, 'test-flag': false }); From 36903397a465b09964b0119004e9adc9dbf7868d Mon Sep 17 00:00:00 2001 From: Robinson Marquez Date: Fri, 29 Nov 2024 14:41:36 -0300 Subject: [PATCH 5/7] refactor: update SvelteLDClient to use proxy for flag variations and improve initialization logic --- .../lib/client/SvelteLDClient.test.ts | 5 +- packages/sdk/svelte/package.json | 2 - .../svelte/src/lib/client/SvelteLDClient.ts | 51 +++++++++++++++---- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts b/packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts index 551d82852..cf8fdd3af 100644 --- a/packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts +++ b/packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts @@ -18,7 +18,7 @@ const mockLDClient = { on: (e: string, cb: () => void) => mockLDEventEmitter.on(e, cb), off: vi.fn(), allFlags: vi.fn().mockReturnValue(rawFlags), - variation: vi.fn(), + variation: vi.fn((_, defaultValue) => defaultValue), identify: vi.fn(), }; @@ -130,6 +130,9 @@ describe('launchDarkly', () => { const flagStore = ld.watch(booleanFlagKey); const flagStore2 = ld.watch(stringFlagKey); + // emit ready event to set initial flag values + mockLDEventEmitter.emit('ready'); + // 'test-flag' initial value is true according to `rawFlags` expect(get(flagStore)).toBe(true); // 'another-test-flag' intial value is 'flag-value' according to `rawFlags` diff --git a/packages/sdk/svelte/package.json b/packages/sdk/svelte/package.json index cf3914e34..bb972b19c 100644 --- a/packages/sdk/svelte/package.json +++ b/packages/sdk/svelte/package.json @@ -44,13 +44,11 @@ }, "peerDependencies": { "@launchdarkly/js-client-sdk": "workspace:^", - "@launchdarkly/js-client-sdk-common": "^1.10.0", "@launchdarkly/node-server-sdk": "^9.4.6", "svelte": "^4.0.0" }, "dependencies": { "@launchdarkly/js-client-sdk": "workspace:^", - "@launchdarkly/js-client-sdk-common": "1.10.0", "esm-env": "^1.0.0" }, "devDependencies": { diff --git a/packages/sdk/svelte/src/lib/client/SvelteLDClient.ts b/packages/sdk/svelte/src/lib/client/SvelteLDClient.ts index a251a37d3..0b3103056 100644 --- a/packages/sdk/svelte/src/lib/client/SvelteLDClient.ts +++ b/packages/sdk/svelte/src/lib/client/SvelteLDClient.ts @@ -27,12 +27,43 @@ function isClientInitialized(client: LDClient | undefined): asserts client is LD } } +/** + * Creates a proxy for the given flags object that intercepts access to flag values. + * When a flag value is accessed, it checks if the flag key exists in the target object. + * If the flag key exists, it returns the variation of the flag from the client. + * Otherwise, it returns the current value of the flag. + * + * @param client - The LaunchDarkly client instance used to get flag variations. + * @param flags - The initial flags object to be proxied. + * @returns A proxy object that intercepts access to flag values and returns the appropriate variation. + */ +function toFlagsProxy(client: LDClient, flags: LDFlags): LDFlags { + return new Proxy(flags, { + get(target, prop, receiver) { + const currentValue = Reflect.get(target, prop, receiver); + // only process flag keys and ignore symbols and native Object functions + if (typeof prop === 'symbol') { + return currentValue; + } + + // check if flag key exists + const validFlagKey = Object.hasOwn(target, prop); + + if (!validFlagKey) { + return currentValue; + } + + return client.variation(prop, currentValue); + }, + }); +} + /** * Creates a LaunchDarkly instance. * @returns {Object} The LaunchDarkly instance object. */ function createLD() { - let jsSdk: LDClient | undefined; + let coreLdClient: LDClient | undefined; const loading = writable(true); const flagsWritable = writable({}); @@ -43,15 +74,17 @@ function createLD() { */ function LDInitialize(clientId: LDClientID) { - jsSdk = initialize(clientId); - jsSdk!.on('ready', () => { + coreLdClient = initialize(clientId); + coreLdClient!.on('ready', () => { loading.set(false); - const allFlags = jsSdk!.allFlags(); + const rawFlags = coreLdClient!.allFlags(); + const allFlags = toFlagsProxy(coreLdClient, rawFlags); flagsWritable.set(allFlags); }); - jsSdk!.on('change', () => { - const allFlags = jsSdk!.allFlags(); + coreLdClient!.on('change', () => { + const rawFlags = coreLdClient!.allFlags(); + const allFlags = toFlagsProxy(coreLdClient, rawFlags); flagsWritable.set(allFlags); }); @@ -66,8 +99,8 @@ function createLD() { * @returns {Promise} A promise that resolves when the user is identified. */ async function identify(context: LDContext) { - isClientInitialized(jsSdk); - return jsSdk.identify(context); + isClientInitialized(coreLdClient); + return coreLdClient.identify(context); } /** @@ -84,7 +117,7 @@ function createLD() { * @returns {boolean} True if the flag is on, false otherwise. */ const isOn = (flagKey: string): boolean => { - isClientInitialized(jsSdk); + isClientInitialized(coreLdClient); const currentFlags = get(flagsWritable); return !!currentFlags[flagKey]; }; From cf495712d12377b79fe86478f3d4214a68da800a Mon Sep 17 00:00:00 2001 From: Robinson Marquez Date: Tue, 3 Dec 2024 18:27:45 -0300 Subject: [PATCH 6/7] refactor: update SvelteLDClient to use compat SDK and improve initialization with user context --- .../lib/client/SvelteLDClient.test.ts | 21 ++++++++------- packages/sdk/svelte/package.json | 11 ++++---- .../svelte/src/lib/client/SvelteLDClient.ts | 26 +++++++++---------- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts b/packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts index cf8fdd3af..490bbbdd8 100644 --- a/packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts +++ b/packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts @@ -2,14 +2,15 @@ import { EventEmitter } from 'node:events'; import { get } from 'svelte/store'; import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { initialize, LDClient } from '@launchdarkly/js-client-sdk'; +import { initialize, LDClient } from '@launchdarkly/js-client-sdk/compat'; import { LD } from '../../../src/lib/client/SvelteLDClient'; -vi.mock('@launchdarkly/js-client-sdk', { spy: true }); +vi.mock('@launchdarkly/js-client-sdk/compat', { spy: true }); const clientSideID = 'test-client-side-id'; const rawFlags = { 'test-flag': true, 'another-test-flag': 'flag-value' }; +const mockContext = { key: 'user1' }; // used to mock ready and change events on the LDClient const mockLDEventEmitter = new EventEmitter(); @@ -62,7 +63,7 @@ describe('launchDarkly', () => { it('should set the loading status to false when the client is ready', async () => { const { initializing } = ld; - ld.initialize(clientSideID); + ld.initialize(clientSideID, mockContext); expect(get(initializing)).toBe(true); // should be true before the ready event is emitted mockLDEventEmitter.emit('ready'); @@ -71,16 +72,16 @@ describe('launchDarkly', () => { }); it('should initialize the LaunchDarkly SDK instance', () => { - ld.initialize(clientSideID); + ld.initialize(clientSideID, mockContext); - expect(initialize).toHaveBeenCalledWith('test-client-side-id'); + expect(initialize).toHaveBeenCalledWith('test-client-side-id', mockContext); }); it('should register function that gets flag values when client is ready', () => { const newFlags = { ...rawFlags, 'new-flag': true }; const allFlagsSpy = vi.spyOn(mockLDClient, 'allFlags').mockReturnValue(newFlags); - ld.initialize(clientSideID); + ld.initialize(clientSideID, mockContext); mockLDEventEmitter.emit('ready'); expect(allFlagsSpy).toHaveBeenCalledOnce(); @@ -91,7 +92,7 @@ describe('launchDarkly', () => { const changedFlags = { ...rawFlags, 'changed-flag': true }; const allFlagsSpy = vi.spyOn(mockLDClient, 'allFlags').mockReturnValue(changedFlags); - ld.initialize(clientSideID); + ld.initialize(clientSideID, mockContext); mockLDEventEmitter.emit('change'); expect(allFlagsSpy).toHaveBeenCalledOnce(); @@ -116,7 +117,7 @@ describe('launchDarkly', () => { it('should return a derived store that reflects the value of the specified flag', () => { const flagKey = 'test-flag'; - ld.initialize(clientSideID); + ld.initialize(clientSideID, mockContext); const flagStore = ld.watch(flagKey); @@ -126,7 +127,7 @@ describe('launchDarkly', () => { it('should update the flag store when the flag value changes', () => { const booleanFlagKey = 'test-flag'; const stringFlagKey = 'another-test-flag'; - ld.initialize(clientSideID); + ld.initialize(clientSideID, mockContext); const flagStore = ld.watch(booleanFlagKey); const flagStore2 = ld.watch(stringFlagKey); @@ -153,7 +154,7 @@ describe('launchDarkly', () => { it('should return undefined if the flag is not found', () => { const flagKey = 'non-existent-flag'; - ld.initialize(clientSideID); + ld.initialize(clientSideID, mockContext); const flagStore = ld.watch(flagKey); diff --git a/packages/sdk/svelte/package.json b/packages/sdk/svelte/package.json index bb972b19c..8061f94be 100644 --- a/packages/sdk/svelte/package.json +++ b/packages/sdk/svelte/package.json @@ -44,7 +44,6 @@ }, "peerDependencies": { "@launchdarkly/js-client-sdk": "workspace:^", - "@launchdarkly/node-server-sdk": "^9.4.6", "svelte": "^4.0.0" }, "dependencies": { @@ -56,12 +55,12 @@ "@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/kit": "^2.0.0", "@sveltejs/package": "^2.0.0", - "@sveltejs/vite-plugin-svelte": "^3.0.0", + "@sveltejs/vite-plugin-svelte": "^5.0.1", "@testing-library/svelte": "^5.2.0", "@types/jest": "^29.5.11", "@typescript-eslint/eslint-plugin": "^6.20.0", "@typescript-eslint/parser": "^6.20.0", - "@vitest/ui": "^1.6.0", + "@vitest/ui": "^2.1.8", "eslint": "^8.45.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-typescript": "^17.1.0", @@ -76,13 +75,13 @@ "prettier-plugin-svelte": "^3.1.2", "publint": "^0.1.9", "rimraf": "^5.0.5", - "svelte": "^4.2.7", + "svelte": "^5.4.0", "svelte-check": "^3.6.0", "ts-jest": "^29.1.1", "ts-node": "^10.9.2", "typedoc": "0.25.0", "typescript": "5.1.6", - "vite": "^5.2.6", - "vitest": "^2.1.4" + "vite": "^6.0.2", + "vitest": "^2.1.8" } } diff --git a/packages/sdk/svelte/src/lib/client/SvelteLDClient.ts b/packages/sdk/svelte/src/lib/client/SvelteLDClient.ts index 0b3103056..2dbdc84ca 100644 --- a/packages/sdk/svelte/src/lib/client/SvelteLDClient.ts +++ b/packages/sdk/svelte/src/lib/client/SvelteLDClient.ts @@ -1,11 +1,14 @@ import { derived, get, type Readable, readonly, writable, type Writable } from 'svelte/store'; +import type { LDFlagSet } from '@launchdarkly/js-client-sdk'; import { initialize, type LDClient, type LDContext, - type LDFlagSet, -} from '@launchdarkly/js-client-sdk'; + type LDFlagValue, +} from '@launchdarkly/js-client-sdk/compat'; + +export type { LDContext }; /** Client ID for LaunchDarkly */ export type LDClientID = string; @@ -13,9 +16,6 @@ export type LDClientID = string; /** Flags for LaunchDarkly */ export type LDFlags = LDFlagSet; -/** Value of LaunchDarkly flags */ -export type LDFlagsValue = LDFlagSet[string]; - /** * Checks if the LaunchDarkly client is initialized. * @param {LDClient | undefined} client - The LaunchDarkly client. @@ -70,21 +70,21 @@ function createLD() { /** * Initializes the LaunchDarkly client. * @param {LDClientID} clientId - The client ID. - * @returns {Writable} An object with the initialization status store. + * @param {LDContext} context - The user context. + * @returns {Object} An object with the initialization status store. */ - - function LDInitialize(clientId: LDClientID) { - coreLdClient = initialize(clientId); + function LDInitialize(clientId: LDClientID, context: LDContext) { + coreLdClient = initialize(clientId, context); coreLdClient!.on('ready', () => { loading.set(false); const rawFlags = coreLdClient!.allFlags(); - const allFlags = toFlagsProxy(coreLdClient, rawFlags); + const allFlags = toFlagsProxy(coreLdClient!, rawFlags); flagsWritable.set(allFlags); }); coreLdClient!.on('change', () => { const rawFlags = coreLdClient!.allFlags(); - const allFlags = toFlagsProxy(coreLdClient, rawFlags); + const allFlags = toFlagsProxy(coreLdClient!, rawFlags); flagsWritable.set(allFlags); }); @@ -108,8 +108,8 @@ function createLD() { * @param {string} flagKey - The key of the flag to watch. * @returns {Readable} A readable store of the flag value. */ - const watch = (flagKey: string): Readable => - derived, LDFlagsValue>(flagsWritable, ($flags) => $flags[flagKey]); + const watch = (flagKey: string): Readable => + derived, LDFlagValue>(flagsWritable, ($flags) => $flags[flagKey]); /** * Checks if a flag is on. From ff0b51bc58acf4b80d4deb7f62759c22fbef575f Mon Sep 17 00:00:00 2001 From: Robinson Marquez Date: Mon, 9 Dec 2024 21:01:44 -0300 Subject: [PATCH 7/7] refactor: replace isOn with useFlag in SvelteLDClient and update tests --- .../lib/client/SvelteLDClient.test.ts | 126 ++++++------------ packages/sdk/svelte/package.json | 4 +- packages/sdk/svelte/src/lib/LDFlag.svelte | 4 +- .../svelte/src/lib/client/SvelteLDClient.ts | 20 +-- 4 files changed, 56 insertions(+), 98 deletions(-) diff --git a/packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts b/packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts index 490bbbdd8..ea689d6ed 100644 --- a/packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts +++ b/packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts @@ -33,7 +33,7 @@ describe('launchDarkly', () => { expect(ld).toHaveProperty('initialize'); expect(ld).toHaveProperty('initializing'); expect(ld).toHaveProperty('watch'); - expect(ld).toHaveProperty('isOn'); + expect(ld).toHaveProperty('useFlag'); }); describe('initialize', async () => { @@ -55,7 +55,7 @@ describe('launchDarkly', () => { const flagKey = 'test-flag'; const user = { key: 'user1' }; - expect(() => ld.isOn(flagKey)).toThrow('LaunchDarkly client not initialized'); + expect(() => ld.useFlag(flagKey, true)).toThrow('LaunchDarkly client not initialized'); await expect(() => ld.identify(user)).rejects.toThrow( 'LaunchDarkly client not initialized', ); @@ -162,98 +162,54 @@ describe('launchDarkly', () => { }); }); - // TODO: fix these tests - // describe('isOn function', () => { - // const ld = LD; - - // beforeEach(() => { - // // mocks the initialize function to return the mockLDClient - // (initialize as Mock).mockReturnValue( - // mockLDClient as unknown as LDClient, - // ); - // }); - - // afterEach(() => { - // vi.clearAllMocks(); - // mockLDEventEmitter.removeAllListeners(); - // }); - - // it('should return true if the flag is on', () => { - // const flagKey = 'test-flag'; - // ld.initialize(clientSideID); - - // expect(ld.isOn(flagKey)).toBe(true); - // }); - - // it('should return false if the flag is off', () => { - // const flagKey = 'test-flag'; - // ld.initialize(clientSideID); - - // mockAllFlags.mockReturnValue({ ...rawFlags, 'test-flag': false }); - - // // dispatch a change event on ldClient - // const changeCallback = mockLDClient.on.mock.calls[0][1]; - // changeCallback(); - - // expect(ld.isOn(flagKey)).toBe(false); - // }); - - // it('should return false if the flag is not found', () => { - // const flagKey = 'non-existent-flag'; - // ld.initialize(clientSideID, { key: 'user1' }); - - // expect(ld.isOn(flagKey)).toBe(false); - // }); - // }); - - // describe('identify function', () => { - // const ld = LD; - // beforeEach(() => { - // mockInitialize.mockImplementation(() => mockLDClient); - // mockAllFlags.mockImplementation(() => rawFlags); - // }); - - // it('should call the identify method on the LaunchDarkly client', () => { - // const user = { key: 'user1' }; - // ld.initialize(clientSideID, user); - - // ld.identify(user); - - // expect(mockLDClient.identify).toHaveBeenCalledWith(user); - // }); - // }); + describe('useFlag function', () => { + const ld = LD; - // describe('flags store', () => { - // const ld = LD; - // beforeEach(() => { - // mockInitialize.mockImplementation(() => mockLDClient); - // mockAllFlags.mockImplementation(() => rawFlags); - // }); + beforeEach(() => { + // mocks the initialize function to return the mockLDClient + (initialize as Mock).mockReturnValue( + mockLDClient as unknown as LDClient, + ); + }); - // it('should return a readonly store of the flags', () => { - // ld.initialize(clientSideID, { key: 'user1' }); + afterEach(() => { + vi.clearAllMocks(); + mockLDEventEmitter.removeAllListeners(); + }); - // const { flags } = ld; + it('should return flag value', () => { + mockLDClient.variation.mockReturnValue(true); + const flagKey = 'test-flag'; + ld.initialize(clientSideID, mockContext); - // expect(get(flags)).toEqual(rawFlags); - // }); + expect(ld.useFlag(flagKey, false)).toBe(true); + expect(mockLDClient.variation).toHaveBeenCalledWith(flagKey, false); + }); + }); - // it('should update the flags store when the flags change', () => { - // ld.initialize(clientSideID, { key: 'user1' }); + describe('identify function', () => { + const ld = LD; - // const { flags } = ld; + beforeEach(() => { + // mocks the initialize function to return the mockLDClient + (initialize as Mock).mockReturnValue( + mockLDClient as unknown as LDClient, + ); + }); - // expect(get(flags)).toEqual(rawFlags); + afterEach(() => { + vi.clearAllMocks(); + mockLDEventEmitter.removeAllListeners(); + }); - // const newFlags = { 'test-flag': false, 'another-test-flag': true }; - // mockAllFlags.mockReturnValue(newFlags); + it('should call the identify method on the LaunchDarkly client', () => { + const user = { key: 'user1' }; + ld.initialize(clientSideID, user); - // // dispatch a change event on ldClient - // const changeCallback = mockLDClient.on.mock.calls[0][1]; - // changeCallback(); + ld.identify(user); - // expect(get(flags)).toEqual(newFlags); - // }); - // }); + expect(mockLDClient.identify).toHaveBeenCalledWith(user); + }); + }); }); }); diff --git a/packages/sdk/svelte/package.json b/packages/sdk/svelte/package.json index 8061f94be..10be95075 100644 --- a/packages/sdk/svelte/package.json +++ b/packages/sdk/svelte/package.json @@ -40,7 +40,8 @@ "check": "yarn prettier && yarn lint && yarn build && yarn test", "test": "playwright test", "test:unit": "vitest", - "test:unit-ui": "vitest --ui" + "test:unit-ui": "vitest --ui", + "test:unit-coverage": "vitest --coverage" }, "peerDependencies": { "@launchdarkly/js-client-sdk": "workspace:^", @@ -60,6 +61,7 @@ "@types/jest": "^29.5.11", "@typescript-eslint/eslint-plugin": "^6.20.0", "@typescript-eslint/parser": "^6.20.0", + "@vitest/coverage-v8": "^2.1.8", "@vitest/ui": "^2.1.8", "eslint": "^8.45.0", "eslint-config-airbnb-base": "^15.0.0", diff --git a/packages/sdk/svelte/src/lib/LDFlag.svelte b/packages/sdk/svelte/src/lib/LDFlag.svelte index 230e78908..f0d4bfe20 100644 --- a/packages/sdk/svelte/src/lib/LDFlag.svelte +++ b/packages/sdk/svelte/src/lib/LDFlag.svelte @@ -1,8 +1,8 @@ diff --git a/packages/sdk/svelte/src/lib/client/SvelteLDClient.ts b/packages/sdk/svelte/src/lib/client/SvelteLDClient.ts index 2dbdc84ca..21fc32ffb 100644 --- a/packages/sdk/svelte/src/lib/client/SvelteLDClient.ts +++ b/packages/sdk/svelte/src/lib/client/SvelteLDClient.ts @@ -1,4 +1,4 @@ -import { derived, get, type Readable, readonly, writable, type Writable } from 'svelte/store'; +import { derived, type Readable, readonly, writable, type Writable } from 'svelte/store'; import type { LDFlagSet } from '@launchdarkly/js-client-sdk'; import { @@ -8,7 +8,7 @@ import { type LDFlagValue, } from '@launchdarkly/js-client-sdk/compat'; -export type { LDContext }; +export type { LDContext, LDFlagValue }; /** Client ID for LaunchDarkly */ export type LDClientID = string; @@ -112,15 +112,15 @@ function createLD() { derived, LDFlagValue>(flagsWritable, ($flags) => $flags[flagKey]); /** - * Checks if a flag is on. - * @param {string} flagKey - The key of the flag to check. - * @returns {boolean} True if the flag is on, false otherwise. + * Gets the current value of a flag. + * @param {string} flagKey - The key of the flag to get. + * @param {TFlag} defaultValue - The default value of the flag. + * @returns {TFlag} The current value of the flag. */ - const isOn = (flagKey: string): boolean => { + function useFlag(flagKey: string, defaultValue: TFlag): TFlag { isClientInitialized(coreLdClient); - const currentFlags = get(flagsWritable); - return !!currentFlags[flagKey]; - }; + return coreLdClient.variation(flagKey, defaultValue); + } return { identify, @@ -128,7 +128,7 @@ function createLD() { initialize: LDInitialize, initializing: readonly(loading), watch, - isOn, + useFlag, }; }