diff --git a/packages/sdk/browser/__tests__/platform/LocalStorage.test.ts b/packages/sdk/browser/__tests__/platform/LocalStorage.test.ts new file mode 100644 index 0000000000..477b348f09 --- /dev/null +++ b/packages/sdk/browser/__tests__/platform/LocalStorage.test.ts @@ -0,0 +1,139 @@ +import LocalStorage from '../../src/platform/LocalStorage'; + +it('can set values', async () => { + const logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + // Storage here needs to be the global browser 'Storage' not the interface + // for our platform. + const spy = jest.spyOn(Storage.prototype, 'setItem'); + + const storage = new LocalStorage(logger); + storage.set('test-key', 'test-value'); + expect(spy).toHaveBeenCalledWith('test-key', 'test-value'); + + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); +}); + +it('can handle an error setting a value', async () => { + const logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + // Storage here needs to be the global browser 'Storage' not the interface + // for our platform. + const spy = jest.spyOn(Storage.prototype, 'setItem'); + spy.mockImplementation(() => { + throw new Error('bad'); + }); + + const storage = new LocalStorage(logger); + storage.set('test-key', 'test-value'); + + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + 'Error setting key in localStorage: test-key, reason: Error: bad', + ); +}); + +it('can get values', async () => { + const logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + // Storage here needs to be the global browser 'Storage' not the interface + // for our platform. + const spy = jest.spyOn(Storage.prototype, 'getItem'); + + const storage = new LocalStorage(logger); + storage.get('test-key'); + expect(spy).toHaveBeenCalledWith('test-key'); + + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); +}); + +it('can handle an error getting a value', async () => { + const logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + // Storage here needs to be the global browser 'Storage' not the interface + // for our platform. + const spy = jest.spyOn(Storage.prototype, 'getItem'); + spy.mockImplementation(() => { + throw new Error('bad'); + }); + + const storage = new LocalStorage(logger); + storage.get('test-key'); + + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + 'Error getting key from localStorage: test-key, reason: Error: bad', + ); +}); + +it('can clear values', async () => { + const logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + // Storage here needs to be the global browser 'Storage' not the interface + // for our platform. + const spy = jest.spyOn(Storage.prototype, 'removeItem'); + + const storage = new LocalStorage(logger); + storage.clear('test-key'); + expect(spy).toHaveBeenCalledWith('test-key'); + + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); +}); + +it('can handle an error clearing a value', async () => { + const logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + // Storage here needs to be the global browser 'Storage' not the interface + // for our platform. + const spy = jest.spyOn(Storage.prototype, 'removeItem'); + spy.mockImplementation(() => { + throw new Error('bad'); + }); + + const storage = new LocalStorage(logger); + storage.clear('test-key'); + + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + 'Error clearing key from localStorage: test-key, reason: Error: bad', + ); +}); diff --git a/packages/sdk/browser/jest.config.js b/packages/sdk/browser/jest.config.js new file mode 100644 index 0000000000..364918be3a --- /dev/null +++ b/packages/sdk/browser/jest.config.js @@ -0,0 +1,12 @@ + +export default { + preset: 'ts-jest', + testEnvironment: 'jest-environment-jsdom', + transform: { + "^.+\\.tsx?$": "ts-jest" + // process `*.tsx` files with `ts-jest` + }, + moduleNameMapper: { + '\\.(gif|ttf|eot|svg|png)$': '/test/__ mocks __/fileMock.js', + }, +} diff --git a/packages/sdk/browser/package.json b/packages/sdk/browser/package.json index 066309d7bf..8723526a35 100644 --- a/packages/sdk/browser/package.json +++ b/packages/sdk/browser/package.json @@ -39,7 +39,6 @@ }, "devDependencies": { "@launchdarkly/private-js-mocks": "0.0.1", - "@testing-library/react": "^14.1.2", "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@types/jest": "^29.5.11", "@typescript-eslint/eslint-plugin": "^6.20.0", @@ -52,9 +51,11 @@ "eslint-plugin-jest": "^27.6.3", "eslint-plugin-prettier": "^5.0.0", "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "prettier": "^3.0.0", "rimraf": "^5.0.5", "ts-jest": "^29.1.1", + "ts-node": "^10.9.2", "typedoc": "0.25.0", "typescript": "^5.5.3", "vite": "^5.4.1", diff --git a/packages/sdk/browser/src/platform/BrowserPlatform.ts b/packages/sdk/browser/src/platform/BrowserPlatform.ts new file mode 100644 index 0000000000..419840d5d3 --- /dev/null +++ b/packages/sdk/browser/src/platform/BrowserPlatform.ts @@ -0,0 +1,22 @@ +import { + LDOptions, + Storage, + /* platform */ +} from '@launchdarkly/js-client-sdk-common'; + +import LocalStorage, { isLocalStorageSupported } from './LocalStorage'; + +export default class BrowserPlatform /* implements platform.Platform */ { + // encoding?: Encoding; + // info: Info; + // fileSystem?: Filesystem; + // crypto: Crypto; + // requests: Requests; + storage?: Storage; + + constructor(options: LDOptions) { + if (isLocalStorageSupported()) { + this.storage = new LocalStorage(options.logger); + } + } +} diff --git a/packages/sdk/browser/src/platform/LocalStorage.ts b/packages/sdk/browser/src/platform/LocalStorage.ts new file mode 100644 index 0000000000..75e8be6de4 --- /dev/null +++ b/packages/sdk/browser/src/platform/LocalStorage.ts @@ -0,0 +1,42 @@ +import type { LDLogger, Storage } from '@launchdarkly/js-client-sdk-common'; + +export function isLocalStorageSupported() { + // Checking a symbol using typeof is safe, but directly accessing a symbol + // which is not defined would be an error. + return typeof localStorage !== 'undefined'; +} + +/** + * Implementation of Storage using localStorage for the browser. + * + * The Storage API is async, and localStorage is synchronous. This is fine, + * and none of the methods need to internally await their operations. + */ +export default class PlatformStorage implements Storage { + constructor(private readonly logger?: LDLogger) {} + async clear(key: string): Promise { + try { + localStorage.removeItem(key); + } catch (error) { + this.logger?.error(`Error clearing key from localStorage: ${key}, reason: ${error}`); + } + } + + async get(key: string): Promise { + try { + const value = localStorage.getItem(key); + return value ?? null; + } catch (error) { + this.logger?.error(`Error getting key from localStorage: ${key}, reason: ${error}`); + return null; + } + } + + async set(key: string, value: string): Promise { + try { + localStorage.setItem(key, value); + } catch (error) { + this.logger?.error(`Error setting key in localStorage: ${key}, reason: ${error}`); + } + } +}