Skip to content

Commit

Permalink
feat: add initial SDK library boilerplate and basic svelte LD SDK (#632)
Browse files Browse the repository at this point in the history
**Requirements**

- [x] I have added test coverage for new or changed functionality
- [x] I have followed the repository's [pull request submission
guidelines](../blob/main/CONTRIBUTING.md#submitting-pull-requests) (TBH
`yarn run contract-tests` failed for me even in `main`)
- [x] I have validated my changes against all supported platform
versions

**Related issues**

No issue

**Describe the solution you've provided**

Introducing the new `@launchdarkly/svelte-client-sdk` package. Some of
the details included in this PR are

1.  Svelte Library Boilerplate 
2. Basic Svelte SDK functionality:
2.1 `LDProvider` component
2.2 `LDFlag` component
2.3 Svelte-compatible LD instance (exposes API to work with feature
flags)

**Describe alternatives you've considered**

I don't know what to write here.

**Additional context**

This is the first of a series of PRs. Some of the following PR should be
about
1. Adding Documentation for @launchdarkly/svelte-client-sdk
2. Adding Example project that uses `@launchdarkly/svelte-client-sdk`

---------

Co-authored-by: Robinson Marquez <[email protected]>
Co-authored-by: Ryan Lamb <[email protected]>
  • Loading branch information
3 people authored Dec 19, 2024
1 parent d723e62 commit 897905b
Show file tree
Hide file tree
Showing 20 changed files with 613 additions and 0 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions packages/sdk/svelte/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/dist
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*

# Playwright
/test-results
215 changes: 215 additions & 0 deletions packages/sdk/svelte/__tests__/lib/client/SvelteLDClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
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/compat';

import { LD } from '../../../src/lib/client/SvelteLDClient';

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();

const mockLDClient = {
on: (e: string, cb: () => void) => mockLDEventEmitter.on(e, cb),
off: vi.fn(),
allFlags: vi.fn().mockReturnValue(rawFlags),
variation: vi.fn((_, defaultValue) => defaultValue),
identify: vi.fn(),
};

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('useFlag');
});

describe('initialize', async () => {
const ld = LD;

beforeEach(() => {
// mocks the initialize function to return the mockLDClient
(initialize as Mock<typeof initialize>).mockReturnValue(
mockLDClient as unknown as LDClient,
);
});

afterEach(() => {
vi.clearAllMocks();
mockLDEventEmitter.removeAllListeners();
});

it('should throw an error if the client is not initialized', async () => {
const flagKey = 'test-flag';
const user = { key: 'user1' };

expect(() => ld.useFlag(flagKey, true)).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(clientSideID, mockContext);

expect(get(initializing)).toBe(true); // should be true before the ready event is emitted
mockLDEventEmitter.emit('ready');

expect(get(initializing)).toBe(false);
});

it('should initialize the LaunchDarkly SDK instance', () => {
ld.initialize(clientSideID, mockContext);

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, mockContext);
mockLDEventEmitter.emit('ready');

expect(allFlagsSpy).toHaveBeenCalledOnce();
expect(allFlagsSpy).toHaveReturnedWith(newFlags);
});

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);

ld.initialize(clientSideID, mockContext);
mockLDEventEmitter.emit('change');

expect(allFlagsSpy).toHaveBeenCalledOnce();
expect(allFlagsSpy).toHaveReturnedWith(changedFlags);
});
});

describe('watch function', () => {
const ld = LD;

beforeEach(() => {
// mocks the initialize function to return the mockLDClient
(initialize as Mock<typeof initialize>).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, mockContext);

const flagStore = ld.watch(flagKey);

expect(get(flagStore)).toBe(true);
});

it('should update the flag store when the flag value changes', () => {
const booleanFlagKey = 'test-flag';
const stringFlagKey = 'another-test-flag';
ld.initialize(clientSideID, mockContext);
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`
expect(get(flagStore2)).toBe('flag-value');

mockLDClient.allFlags.mockReturnValue({
...rawFlags,
'test-flag': false,
'another-test-flag': 'new-flag-value',
});

// dispatch a change event on ldClient
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, mockContext);

const flagStore = ld.watch(flagKey);

expect(get(flagStore)).toBeUndefined();
});
});

describe('useFlag function', () => {
const ld = LD;

beforeEach(() => {
// mocks the initialize function to return the mockLDClient
(initialize as Mock<typeof initialize>).mockReturnValue(
mockLDClient as unknown as LDClient,
);
});

afterEach(() => {
vi.clearAllMocks();
mockLDEventEmitter.removeAllListeners();
});

it('should return flag value', () => {
mockLDClient.variation.mockReturnValue(true);
const flagKey = 'test-flag';
ld.initialize(clientSideID, mockContext);

expect(ld.useFlag(flagKey, false)).toBe(true);
expect(mockLDClient.variation).toHaveBeenCalledWith(flagKey, false);
});
});

describe('identify function', () => {
const ld = LD;

beforeEach(() => {
// mocks the initialize function to return the mockLDClient
(initialize as Mock<typeof initialize>).mockReturnValue(
mockLDClient as unknown as LDClient,
);
});

afterEach(() => {
vi.clearAllMocks();
mockLDEventEmitter.removeAllListeners();
});

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);
});
});
});
});
89 changes: 89 additions & 0 deletions packages/sdk/svelte/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
{
"name": "@launchdarkly/svelte-client-sdk",
"version": "0.1.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": "[email protected]",
"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",
"test:unit-coverage": "vitest --coverage"
},
"peerDependencies": {
"@launchdarkly/js-client-sdk": "workspace:^",
"svelte": "^4.0.0"
},
"dependencies": {
"@launchdarkly/js-client-sdk": "workspace:^",
"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": "^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/coverage-v8": "^2.1.8",
"@vitest/ui": "^2.1.8",
"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": "^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": "^6.0.2",
"vitest": "^2.1.8"
}
}
12 changes: 12 additions & 0 deletions packages/sdk/svelte/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 13 additions & 0 deletions packages/sdk/svelte/src/app.d.ts
Original file line number Diff line number Diff line change
@@ -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 {};
11 changes: 11 additions & 0 deletions packages/sdk/svelte/src/app.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div>%sveltekit.body%</div>
</body>
</html>
14 changes: 14 additions & 0 deletions packages/sdk/svelte/src/lib/LDFlag.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<script lang="ts">
import { LD, type LDFlagValue } from './client/SvelteLDClient.js';
export let flag: string;
export let matches: LDFlagValue = true;
$: flagValue = LD.watch(flag);
</script>

{#if $flagValue === matches}
<slot name="true" />
{:else}
<slot name="false" />
{/if}
Loading

0 comments on commit 897905b

Please sign in to comment.