diff --git a/jest.config.js b/jest.config.js index c9b229c3d7..58df77ebc6 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,7 @@ module.exports = { transform: { '^.+\\.ts?$': 'ts-jest' }, testMatch: ['**/__tests__/**/*test.ts?(x)'], - testEnvironment: 'node', + testEnvironment: 'jsdom', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], collectCoverageFrom: [ 'packages/sdk/server-node/src/**/*.ts', diff --git a/package.json b/package.json index 2778c9e9d2..eb764fee94 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "packages/store/node-server-sdk-dynamodb", "packages/telemetry/node-server-sdk-otel", "packages/tooling/jest", + "packages/tooling/jest/example/react-native-example", "packages/sdk/browser", "packages/sdk/browser/contract-tests/entity", "packages/sdk/browser/contract-tests/adapter" diff --git a/packages/tooling/jest/example/react-native-example/.gitignore b/packages/tooling/jest/example/react-native-example/.gitignore new file mode 100644 index 0000000000..78c70b32ea --- /dev/null +++ b/packages/tooling/jest/example/react-native-example/.gitignore @@ -0,0 +1,38 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ + +# Native +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +# vscode +.vscode \ No newline at end of file diff --git a/packages/tooling/jest/example/react-native-example/App.tsx b/packages/tooling/jest/example/react-native-example/App.tsx new file mode 100644 index 0000000000..8521ffd2c6 --- /dev/null +++ b/packages/tooling/jest/example/react-native-example/App.tsx @@ -0,0 +1,36 @@ +import { StyleSheet } from 'react-native'; +import { + AutoEnvAttributes, + LDProvider, + ReactNativeLDClient, + LDOptions, +} from '@launchdarkly/react-native-client-sdk'; +import Welcome from './src/welcome'; + +const options: LDOptions = { + debug: true, +} +//TODO Set MOBILE_KEY in .env file to a mobile key in your project/environment. +const MOBILE_KEY = 'YOUR_MOBILE_KEY'; +const featureClient = new ReactNativeLDClient(MOBILE_KEY, AutoEnvAttributes.Enabled, options); + +const userContext = { kind: 'user', key: '', anonymous: true }; + +export default function App() { + featureClient.identify(userContext).catch((e: any) => console.log(e)); + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/packages/tooling/jest/example/react-native-example/app.json b/packages/tooling/jest/example/react-native-example/app.json new file mode 100644 index 0000000000..c33b70a741 --- /dev/null +++ b/packages/tooling/jest/example/react-native-example/app.json @@ -0,0 +1,29 @@ +{ + "expo": { + "name": "react-native-jest-example", + "slug": "react-native-jest-example", + "version": "0.0.1", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "light", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.anonymous.reactnativejestexample" + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#ffffff" + }, + "package": "com.anonymous.reactnativejestexample" + }, + "web": { + "favicon": "./assets/favicon.png" + } + } +} diff --git a/packages/tooling/jest/example/react-native-example/assets/adaptive-icon.png b/packages/tooling/jest/example/react-native-example/assets/adaptive-icon.png new file mode 100644 index 0000000000..03d6f6b6c6 Binary files /dev/null and b/packages/tooling/jest/example/react-native-example/assets/adaptive-icon.png differ diff --git a/packages/tooling/jest/example/react-native-example/assets/favicon.png b/packages/tooling/jest/example/react-native-example/assets/favicon.png new file mode 100644 index 0000000000..e75f697b18 Binary files /dev/null and b/packages/tooling/jest/example/react-native-example/assets/favicon.png differ diff --git a/packages/tooling/jest/example/react-native-example/assets/icon.png b/packages/tooling/jest/example/react-native-example/assets/icon.png new file mode 100644 index 0000000000..a0b1526fc7 Binary files /dev/null and b/packages/tooling/jest/example/react-native-example/assets/icon.png differ diff --git a/packages/tooling/jest/example/react-native-example/assets/splash.png b/packages/tooling/jest/example/react-native-example/assets/splash.png new file mode 100644 index 0000000000..0e89705a94 Binary files /dev/null and b/packages/tooling/jest/example/react-native-example/assets/splash.png differ diff --git a/packages/tooling/jest/example/react-native-example/babel.config.js b/packages/tooling/jest/example/react-native-example/babel.config.js new file mode 100644 index 0000000000..28dcb83baa --- /dev/null +++ b/packages/tooling/jest/example/react-native-example/babel.config.js @@ -0,0 +1,7 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo', '@babel/preset-typescript'], + + }; +}; diff --git a/packages/tooling/jest/example/react-native-example/index.js b/packages/tooling/jest/example/react-native-example/index.js new file mode 100644 index 0000000000..202e3f47d8 --- /dev/null +++ b/packages/tooling/jest/example/react-native-example/index.js @@ -0,0 +1,10 @@ +// We have to use a custom entrypoint for monorepo workspaces to work. +// https://docs.expo.dev/guides/monorepos/#change-default-entrypoint +import { registerRootComponent } from 'expo'; + +import App from './App'; + +// registerRootComponent calls AppRegistry.registerComponent('main', () => App); +// It also ensures that whether you load the app in Expo Go or in a native build, +// the environment is set up appropriately +registerRootComponent(App); diff --git a/packages/tooling/jest/example/react-native-example/jest.config.js b/packages/tooling/jest/example/react-native-example/jest.config.js new file mode 100644 index 0000000000..5fcf170e8f --- /dev/null +++ b/packages/tooling/jest/example/react-native-example/jest.config.js @@ -0,0 +1,4 @@ +module.exports = { + preset: 'jest-expo', + setupFiles: ['@launchdarkly/jest/react-native'], +}; diff --git a/packages/tooling/jest/example/react-native-example/metro.config.js b/packages/tooling/jest/example/react-native-example/metro.config.js new file mode 100644 index 0000000000..8dd286e022 --- /dev/null +++ b/packages/tooling/jest/example/react-native-example/metro.config.js @@ -0,0 +1,28 @@ +// We need to use a custom metro config for monorepo workspaces to work. +// https://docs.expo.dev/guides/monorepos/#modify-the-metro-config +/** + * @type {import('expo/metro-config')} + */ +const { getDefaultConfig } = require('expo/metro-config'); +const path = require('path'); + +// Find the project and workspace directories +const projectRoot = __dirname; + +const findWorkspaceRoot = require('find-yarn-workspace-root'); + +const workspaceRoot = findWorkspaceRoot(__dirname); // Absolute path or null + +const config = getDefaultConfig(projectRoot); + +// 1. Watch all files within the monorepo +config.watchFolders = [workspaceRoot]; +// 2. Let Metro know where to resolve packages and in what order +config.resolver.nodeModulesPaths = [ + path.resolve(projectRoot, 'node_modules'), + path.resolve(workspaceRoot, 'node_modules'), +]; +// 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths` +config.resolver.disableHierarchicalLookup = true; + +module.exports = config; diff --git a/packages/tooling/jest/example/react-native-example/package.json b/packages/tooling/jest/example/react-native-example/package.json new file mode 100644 index 0000000000..a2898a7d11 --- /dev/null +++ b/packages/tooling/jest/example/react-native-example/package.json @@ -0,0 +1,40 @@ +{ + "name": "react-native-jest-example", + "version": "0.0.1", + "main": "index.js", + "scripts": { + "start": "expo start", + "android": "expo run:android", + "ios": "expo run:ios", + "test": "jest", + "clean": "rm -rf node_modules && rm -rf package-lock.json && rm -rf yarn.lock" + }, + "dependencies": { + "@launchdarkly/react-native-client-sdk": "^10.9.0", + "expo": "^51.0.26", + "expo-status-bar": "~1.12.1", + "find-yarn-workspace-root": "^2.0.0", + "react": "^18.2.0", + "react-native": "0.74.5" + }, + "devDependencies": { + "@babel/core": "^7.20.0", + "@babel/preset-env": "^7.25.4", + "@babel/preset-react": "^7.24.7", + "@babel/runtime": "^7.25.4", + "@launchdarkly/jest": "workspace:^", + "@react-native/babel-preset": "^0.75.2", + "@testing-library/react-native": "^12.6.1", + "@types/jest": "^29.5.12", + "@types/react": "~18.2.45", + "jest": "^29.7.0", + "jest-expo": "^51.0.4", + "react-test-renderer": "^18.2.0", + "typescript": "^5.1.3" + }, + "packageManager": "yarn@3.4.1", + "installConfig": { + "hoistingLimits": "workspaces" + }, + "private": true +} diff --git a/packages/tooling/jest/example/react-native-example/src/welcome.test.tsx b/packages/tooling/jest/example/react-native-example/src/welcome.test.tsx new file mode 100644 index 0000000000..8bd9f4e21c --- /dev/null +++ b/packages/tooling/jest/example/react-native-example/src/welcome.test.tsx @@ -0,0 +1,29 @@ +/** + * @jest-environment jsdom + */ + +import { mockFlags, resetLDMocks } from '@launchdarkly/jest/react-native'; +import { screen, render } from '@testing-library/react-native'; +import { useLDClient } from '@launchdarkly/react-native-client-sdk'; +import Welcome from './welcome'; + +describe('Welcome component test', () => { + + afterEach(() => { + resetLDMocks(); + }); + + test('mock boolean flag correctly', () => { + mockFlags({ 'my-boolean-flag': true }); + render(); + expect(screen.getByText('Flag value is true')).toBeTruthy(); + }); + + test('mock ldClient correctly', () => { + const current = useLDClient(); + + current?.track('event'); + expect(current.track).toHaveBeenCalledTimes(1); + }); + +}); diff --git a/packages/tooling/jest/example/react-native-example/src/welcome.tsx b/packages/tooling/jest/example/react-native-example/src/welcome.tsx new file mode 100644 index 0000000000..f167b11fcd --- /dev/null +++ b/packages/tooling/jest/example/react-native-example/src/welcome.tsx @@ -0,0 +1,25 @@ +import { StyleSheet, Text, View } from 'react-native'; +import { useLDClient } from '@launchdarkly/react-native-client-sdk'; + +export default function Welcome() { + + const ldClient = useLDClient(); + + const flagValue = ldClient.boolVariation('my-boolean-flag', false); + + return ( + + Welcome to LaunchDarkly + Flag value is {`${flagValue}`} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/packages/tooling/jest/example/react-native-example/tsconfig.eslint.json b/packages/tooling/jest/example/react-native-example/tsconfig.eslint.json new file mode 100644 index 0000000000..9101efe40f --- /dev/null +++ b/packages/tooling/jest/example/react-native-example/tsconfig.eslint.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "/**/*.ts", + "/**/*.tsx", + "/*.js", + "/*.tsx" + ], + "exclude": ["node_modules"] +} diff --git a/packages/tooling/jest/example/react-native-example/tsconfig.json b/packages/tooling/jest/example/react-native-example/tsconfig.json new file mode 100644 index 0000000000..bb0ef71b76 --- /dev/null +++ b/packages/tooling/jest/example/react-native-example/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "moduleResolution": "bundler", + "jsx": "react-jsx" + } +} diff --git a/packages/tooling/jest/jest.config.json b/packages/tooling/jest/jest.config.json index 6174807746..ddf54bb471 100644 --- a/packages/tooling/jest/jest.config.json +++ b/packages/tooling/jest/jest.config.json @@ -3,7 +3,7 @@ "testMatch": ["**/*.test.ts?(x)"], "testPathIgnorePatterns": ["node_modules", "example", "dist"], "modulePathIgnorePatterns": ["dist"], - "testEnvironment": "node", + "testEnvironment": "jsdom", "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"], "collectCoverageFrom": ["src/**/*.ts"] } diff --git a/packages/tooling/jest/package.json b/packages/tooling/jest/package.json index 0885269428..1cb51dd224 100644 --- a/packages/tooling/jest/package.json +++ b/packages/tooling/jest/package.json @@ -55,9 +55,18 @@ "eslint-plugin-prettier": "^5.0.0", "jest": "^29.5.0", "prettier": "^3.0.0", + "react-test-renderer": "^18.3.1", "rimraf": "^5.0.1", "ts-jest": "^29.1.0", "typedoc": "0.25.0", "typescript": "5.1.6" + }, + "dependencies": { + "@launchdarkly/react-native-client-sdk": "~10.9.0", + "@testing-library/react-hooks": "^8.0.1", + "@testing-library/react-native": "^12.7.2", + "@types/lodash": "^4.17.7", + "launchdarkly-react-client-sdk": "^3.4.0", + "react": "^18.3.1" } } diff --git a/packages/tooling/jest/src/react-native/index.test.ts b/packages/tooling/jest/src/react-native/index.test.ts index 66cbbd8323..bb37ecd1ca 100644 --- a/packages/tooling/jest/src/react-native/index.test.ts +++ b/packages/tooling/jest/src/react-native/index.test.ts @@ -1,3 +1,72 @@ +import { + ldClientMock, + mockFlags, + mockLDProvider, + mockReactNativeLDClient, + mockUseLDClient, + resetLDMocks, +} from '.'; + describe('react-native', () => { - test.todo('Add react-native tests'); + afterEach(() => { + resetLDMocks(); + }); + + test('reset LD Mocks', () => { + const current = mockUseLDClient(); + + current?.track('event'); + expect(ldClientMock.track).toHaveBeenCalledTimes(1); + + resetLDMocks(); + expect(ldClientMock.track).toHaveBeenCalledTimes(0); + }); + + test('mock boolean flag correctly', () => { + mockFlags({ 'bool-flag': true }); + expect(ldClientMock.boolVariation).toBeDefined(); + }); + + test('mock number flag correctly', () => { + mockFlags({ 'number-flag': 42 }); + expect(ldClientMock.numberVariation).toBeDefined(); + }); + + test('mock string flag correctly', () => { + mockFlags({ 'string-flag': 'hello' }); + expect(ldClientMock.stringVariation).toBeDefined(); + }); + + test('mock json flag correctly', () => { + mockFlags({ 'json-flag': { key: 'value' } }); + expect(ldClientMock.jsonVariation).toBeDefined(); + }); + + test('mock LDProvider correctly', () => { + expect(mockLDProvider).toBeDefined(); + }); + + test('mock ReactNativeLDClient correctly', () => { + expect(mockReactNativeLDClient).toBeDefined(); + }); + + test('mock ldClient correctly', () => { + const current = mockUseLDClient(); + + current?.track('event'); + expect(ldClientMock.track).toHaveBeenCalledTimes(1); + }); + + test('mock ldClient complete set of methods correctly', () => { + expect(ldClientMock.identify).toBeDefined(); + expect(ldClientMock.allFlags.mock).toBeDefined(); + expect(ldClientMock.close.mock).toBeDefined(); + expect(ldClientMock.flush).toBeDefined(); + expect(ldClientMock.getContext.mock).toBeDefined(); + expect(ldClientMock.off.mock).toBeDefined(); + expect(ldClientMock.on.mock).toBeDefined(); + expect(ldClientMock.track.mock).toBeDefined(); + expect(ldClientMock.variation.mock).toBeDefined(); + expect(ldClientMock.variationDetail.mock).toBeDefined(); + }); }); diff --git a/packages/tooling/jest/src/react-native/index.ts b/packages/tooling/jest/src/react-native/index.ts index 07dd57aa0c..f735550808 100644 --- a/packages/tooling/jest/src/react-native/index.ts +++ b/packages/tooling/jest/src/react-native/index.ts @@ -1,3 +1,109 @@ -jest.mock('@launchdarkly/react-client-sdk', () => { - // TODO: -}); +import { + LDClient, + LDFlagSet, + LDProvider, + ReactNativeLDClient, + useBoolVariation, + useJsonVariation, + useLDClient, + useNumberVariation, + useStringVariation, +} from '@launchdarkly/react-native-client-sdk'; + +jest.mock('@launchdarkly/react-native-client-sdk', () => ({ + LDFlagSet: jest.fn(() => ({})), + LDProvider: jest.fn().mockImplementation(({ children }) => children), + ReactNativeLDClient: jest.fn().mockImplementation(), + useLDClient: jest.fn().mockImplementation(), + useBoolVariation: jest.fn(), + useBoolVariationDetail: jest.fn(), + useNumberVariation: jest.fn(), + useNumberVariationDetail: jest.fn(), + useStringVariation: jest.fn(), + useStringVariationDetail: jest.fn(), + useJsonVariation: jest.fn(), + useJsonVariationDetail: jest.fn(), + useTypedVariation: jest.fn(), + useTypedVariationDetail: jest.fn(), +})); + +export const ldClientMock: jest.Mocked = { + allFlags: jest.fn(), + boolVariation: jest.fn(), + boolVariationDetail: jest.fn(), + close: jest.fn(), + flush: jest.fn(() => Promise.resolve({ result: true })), + // getConnectionMode: jest.fn(), + getContext: jest.fn(), + identify: jest.fn().mockResolvedValue(undefined), + jsonVariation: jest.fn(), + jsonVariationDetail: jest.fn(), + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, + numberVariation: jest.fn(), + numberVariationDetail: jest.fn(), + off: jest.fn(), + on: jest.fn(), + // setConnectionMode: jest.fn(), + stringVariation: jest.fn(), + stringVariationDetail: jest.fn(), + track: jest.fn(), + variation: jest.fn(), + variationDetail: jest.fn(), + addHook: jest.fn(), +}; + +export const mockLDProvider = LDProvider as jest.Mock; +export const mockReactNativeLDClient = ReactNativeLDClient as jest.Mock; +export const mockUseLDClient = useLDClient as jest.Mock; + +const mockUseBoolVariation = useBoolVariation as jest.Mock; +const mockUseNumberVariation = useNumberVariation as jest.Mock; +const mockUseStringVariation = useStringVariation as jest.Mock; +const mockUseJsonVariation = useJsonVariation as jest.Mock; + +mockLDProvider.mockImplementation(({ children }) => children); +mockReactNativeLDClient.mockImplementation(() => ldClientMock); +mockUseLDClient.mockImplementation(() => ldClientMock); + +export const mockFlags = (flags: LDFlagSet): any => { + Object.keys(flags).forEach((key) => { + const defaultValue = flags[key]; + switch (typeof defaultValue) { + case 'boolean': + mockUseBoolVariation.mockImplementation((flagKey: string) => flags[flagKey] as boolean); + ldClientMock.boolVariation.mockImplementation( + (flagKey: string) => flags[flagKey] as boolean, + ); + break; + case 'number': + mockUseNumberVariation.mockImplementation((flagKey: string) => flags[flagKey] as number); + ldClientMock.numberVariation.mockImplementation( + (flagKey: string) => flags[flagKey] as number, + ); + break; + case 'string': + mockUseStringVariation.mockImplementation((flagKey: string) => flags[flagKey] as string); + ldClientMock.stringVariation.mockImplementation( + (flagKey: string) => flags[flagKey] as string, + ); + break; + case 'object': + mockUseJsonVariation.mockImplementation((flagKey: string) => flags[flagKey] as object); + ldClientMock.jsonVariation.mockImplementation( + (flagKey: string) => flags[flagKey] as object, + ); + break; + default: + break; + } + }); +}; + +export const resetLDMocks = () => { + jest.clearAllMocks(); +}; diff --git a/packages/tooling/jest/src/react/index.ts b/packages/tooling/jest/src/react/index.ts deleted file mode 100644 index 728a12ae45..0000000000 --- a/packages/tooling/jest/src/react/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -jest.mock('@launchdarkly/react-native-client-sdk', () => { - // TODO: -}); diff --git a/packages/tooling/jest/tsconfig.eslint.json b/packages/tooling/jest/tsconfig.eslint.json index 56c9b38305..c46cca0b54 100644 --- a/packages/tooling/jest/tsconfig.eslint.json +++ b/packages/tooling/jest/tsconfig.eslint.json @@ -1,5 +1,5 @@ { "extends": "./tsconfig.json", - "include": ["/**/*.ts"], + "include": ["/**/*.ts", "/**/*.tsx", "/**/*.js"], "exclude": ["node_modules"] }