diff --git a/.github/workflows/react-native-contract-tests.yml b/.github/workflows/react-native-contract-tests.yml new file mode 100644 index 0000000000..aa5e3b68d9 --- /dev/null +++ b/.github/workflows/react-native-contract-tests.yml @@ -0,0 +1,109 @@ +name: sdk/react-native/contract-tests + +on: + push: + branches: [main, 'feat/**'] + paths-ignore: + - '**.md' + pull_request: + branches: [main, 'feat/**'] + paths: + - 'packages/shared/common/**' + - 'packages/shared/sdk-client/**' + - 'packages/sdk/react-native/**' + - '.github/workflows/react-native-contract-tests.yml' + +jobs: + contract-tests-android: + runs-on: ubuntu-22.04 + timeout-minutes: 30 + steps: + # https://github.com/actions/checkout/releases/tag/v6.0.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node.js + # https://github.com/actions/setup-node/releases/tag/v6.2.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: 18 + + - name: Install dependencies + run: yarn workspaces focus react-native-contract-test-adapter react-native-contract-test-entity + + - name: Build SDK and dependencies + run: yarn workspaces foreach -pR --topological-dev --from '@launchdarkly/react-native-client-sdk' run build + + - name: Build contract test adapter + run: yarn workspace react-native-contract-test-adapter run build + + - name: Enable KVM group perms (for emulator performance) + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Expo Prebuild + working-directory: packages/sdk/react-native/contract-tests/entity + run: npx expo prebuild --platform android + + # Java setup is after expo prebuild so that it can locate the gradle configuration. + - name: Setup Java + # https://github.com/actions/setup-java/releases/tag/v5.2.0 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: temurin + java-version: 17 + cache: 'gradle' + + - name: Build APK (x86_64 only for emulator) + working-directory: packages/sdk/react-native/contract-tests/entity/android + run: ./gradlew assembleRelease -PreactNativeArchitectures=x86_64 -x lintVitalRelease -x lintVitalAnalyzeRelease -x lintVitalReportRelease + + - name: Make space for the emulator + # https://github.com/jlumbroso/free-disk-space/releases/tag/main + uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be + with: + android: false + large-packages: false + + - name: Start adapter in background + run: | + yarn workspace react-native-contract-test-adapter run start > /tmp/adapter.log 2>&1 & + echo $! > /tmp/adapter.pid + + - name: Wait for adapter to be ready + run: | + echo "Waiting for adapter on port 8001..." + for i in $(seq 1 30); do + if nc -z localhost 8001; then + echo "Adapter WebSocket ready" + break + fi + if [ "$i" -eq 30 ]; then + echo "Timeout waiting for adapter" + cat /tmp/adapter.log + exit 1 + fi + sleep 1 + done + + - name: Download contract test harness + run: | + # https://github.com/launchdarkly/sdk-test-harness/releases/tag/v2.34.0 + curl -sL -o sdk-test-harness.tar.gz "https://github.com/launchdarkly/sdk-test-harness/releases/download/v2.34.0/sdk-test-harness_Linux_x86_64.tar.gz" + tar -xzf sdk-test-harness.tar.gz sdk-test-harness + chmod +x sdk-test-harness + + - name: Run contract tests on Android emulator + # https://github.com/ReactiveCircus/android-emulator-runner/releases/tag/v2.34.0 + uses: reactivecircus/android-emulator-runner@f0d1ed2dcad93c7479e8b2f2226c83af54494915 # v2 + with: + api-level: 31 + arch: x86_64 + avd-name: contract_test_emulator + script: packages/sdk/react-native/contract-tests/run-ci-contract-tests.sh + + - name: Cleanup + if: always() + run: | + [ -f /tmp/adapter.pid ] && kill $(cat /tmp/adapter.pid) || true diff --git a/package.json b/package.json index e9d729393a..02e1f864b9 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "packages/sdk/react/examples/client-only", "packages/sdk/react-native", "packages/sdk/react-native/example", + "packages/sdk/react-native/contract-tests/adapter", + "packages/sdk/react-native/contract-tests/entity", "packages/sdk/react-universal", "packages/sdk/react-universal/example", "packages/sdk/vercel", diff --git a/packages/sdk/react-native/contract-tests/adapter/package.json b/packages/sdk/react-native/contract-tests/adapter/package.json new file mode 100644 index 0000000000..88c409a3c7 --- /dev/null +++ b/packages/sdk/react-native/contract-tests/adapter/package.json @@ -0,0 +1,24 @@ +{ + "name": "react-native-contract-test-adapter", + "version": "1.0.0", + "description": "Adapts REST interface to a websocket for use in React Native.", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "yarn build && node dist/index.js" + }, + "author": "", + "license": "UNLICENSED", + "dependencies": { + "body-parser": "^1.20.3", + "cors": "^2.8.5", + "express": "^4.21.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/ws": "^8.5.12", + "typescript": "^5.6.2" + } +} diff --git a/packages/sdk/react-native/contract-tests/adapter/src/index.ts b/packages/sdk/react-native/contract-tests/adapter/src/index.ts new file mode 100644 index 0000000000..5dfffa3bd8 --- /dev/null +++ b/packages/sdk/react-native/contract-tests/adapter/src/index.ts @@ -0,0 +1,112 @@ +/* eslint-disable no-console */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import bodyParser from 'body-parser'; +import cors from 'cors'; +import { randomUUID } from 'crypto'; +import express from 'express'; +import http from 'node:http'; +import util from 'node:util'; +import { WebSocketServer } from 'ws'; + +let server: http.Server | undefined; + +async function main() { + const wss = new WebSocketServer({ port: 8001 }); + const waiters: Record void> = {}; + + console.log('Running contract test harness adapter.'); + wss.on('connection', async (ws) => { + ws.on('error', console.error); + + ws.on('message', (stringData: string) => { + const data = JSON.parse(stringData); + if (Object.prototype.hasOwnProperty.call(waiters, data.reqId)) { + waiters[data.reqId](data); + delete waiters[data.reqId]; + } else { + console.error('Did not find outstanding request', data.reqId); + } + }); + + const send = (data: { [key: string]: unknown; reqId: string }): Promise => { + let resolver: (data: unknown) => void; + const waiter = new Promise((resolve) => { + resolver = resolve; + }); + // @ts-expect-error The body of the above assignment runs sequentially. + waiters[data.reqId] = resolver; + ws.send(JSON.stringify(data)); + return waiter; + }; + + if (server) { + await util.promisify(server.close).call(server); + server = undefined; + } + + const app = express(); + + const port = 8000; + + app.use( + cors({ + origin: '*', + allowedHeaders: '*', + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + }), + ); + app.use(bodyParser.json()); + + app.get('/', async (_req, res) => { + const commandResult = await send({ command: 'getCapabilities', reqId: randomUUID() }); + res.header('Content-Type', 'application/json'); + res.json(commandResult); + }); + + app.delete('/', () => { + process.exit(); + }); + + app.post('/', async (req, res) => { + const commandResult = await send({ + command: 'createClient', + body: req.body, + reqId: randomUUID(), + }); + if (commandResult.resourceUrl) { + res.set('Location', commandResult.resourceUrl); + } + if (commandResult.status) { + res.status(commandResult.status); + } + res.send(); + }); + + app.post('/clients/:id', async (req, res) => { + const commandResult = await send({ + command: 'runCommand', + id: req.params.id, + body: req.body, + reqId: randomUUID(), + }); + if (commandResult.status) { + res.status(commandResult.status); + } + if (commandResult.body) { + res.write(JSON.stringify(commandResult.body)); + } + res.send(); + }); + + app.delete('/clients/:id', async (req, res) => { + await send({ command: 'deleteClient', id: req.params.id, reqId: randomUUID() }); + res.send(); + }); + + server = app.listen(port, () => { + console.log('Listening on port %d', port); + }); + }); +} +main(); diff --git a/packages/sdk/react-native/contract-tests/adapter/tsconfig.json b/packages/sdk/react-native/contract-tests/adapter/tsconfig.json new file mode 100644 index 0000000000..693bf1f0c2 --- /dev/null +++ b/packages/sdk/react-native/contract-tests/adapter/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES6", + "lib": ["ES6"], + "module": "commonjs", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "moduleResolution": "node", + "outDir": "dist", + "sourceMap": true, + "skipLibCheck": true + }, + "exclude": ["**/*.test.ts", "dist", "node_modules"] +} diff --git a/packages/sdk/react-native/contract-tests/entity/.gitignore b/packages/sdk/react-native/contract-tests/entity/.gitignore new file mode 100644 index 0000000000..927d484343 --- /dev/null +++ b/packages/sdk/react-native/contract-tests/entity/.gitignore @@ -0,0 +1,4 @@ +android/ +ios/ +node_modules/ +.expo/ diff --git a/packages/sdk/react-native/contract-tests/entity/App.tsx b/packages/sdk/react-native/contract-tests/entity/App.tsx new file mode 100644 index 0000000000..0293facee3 --- /dev/null +++ b/packages/sdk/react-native/contract-tests/entity/App.tsx @@ -0,0 +1,38 @@ +import React, { useEffect, useState } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import TestHarnessWebSocket from './src/TestHarnessWebSocket'; + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#fff', + }, + text: { + fontSize: 20, + fontWeight: 'bold', + }, + status: { + fontSize: 16, + marginTop: 10, + }, +}); + +export default function App() { + const [connected, setConnected] = useState(false); + + useEffect(() => { + const ws = new TestHarnessWebSocket('ws://localhost:8001', setConnected); + ws.connect(); + return () => ws.disconnect(); + }, []); + + return ( + + RN Contract Test Entity + WebSocket: {connected ? 'Connected' : 'Disconnected'} + + ); +} diff --git a/packages/sdk/react-native/contract-tests/entity/app.json b/packages/sdk/react-native/contract-tests/entity/app.json new file mode 100644 index 0000000000..7a25fe085e --- /dev/null +++ b/packages/sdk/react-native/contract-tests/entity/app.json @@ -0,0 +1,14 @@ +{ + "expo": { + "name": "rn-contract-test-entity", + "slug": "rn-contract-test-entity", + "version": "1.0.0", + "orientation": "portrait", + "android": { + "package": "com.launchdarkly.rncontracttestentity" + }, + "plugins": [ + "./plugins/withCleartextTraffic" + ] + } +} diff --git a/packages/sdk/react-native/contract-tests/entity/babel.config.js b/packages/sdk/react-native/contract-tests/entity/babel.config.js new file mode 100644 index 0000000000..9d89e13119 --- /dev/null +++ b/packages/sdk/react-native/contract-tests/entity/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + }; +}; diff --git a/packages/sdk/react-native/contract-tests/entity/index.js b/packages/sdk/react-native/contract-tests/entity/index.js new file mode 100644 index 0000000000..202e3f47d8 --- /dev/null +++ b/packages/sdk/react-native/contract-tests/entity/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/sdk/react-native/contract-tests/entity/metro.config.js b/packages/sdk/react-native/contract-tests/entity/metro.config.js new file mode 100644 index 0000000000..6cb167be47 --- /dev/null +++ b/packages/sdk/react-native/contract-tests/entity/metro.config.js @@ -0,0 +1,26 @@ +// 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; +// This can be replaced with `find-yarn-workspace-root` +const workspaceRoot = path.resolve(projectRoot, '../../../../..'); + +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/sdk/react-native/contract-tests/entity/package.json b/packages/sdk/react-native/contract-tests/entity/package.json new file mode 100644 index 0000000000..38401d25da --- /dev/null +++ b/packages/sdk/react-native/contract-tests/entity/package.json @@ -0,0 +1,30 @@ +{ + "name": "react-native-contract-test-entity", + "private": true, + "version": "0.0.1", + "main": "index.js", + "scripts": { + "start": "expo start --reset-cache", + "expo-prebuild": "CI=1 expo prebuild --clean", + "android": "expo run:android", + "android-release": "expo run:android --variant release", + "ios": "expo run:ios" + }, + "dependencies": { + "@launchdarkly/react-native-client-sdk": "workspace:^", + "@react-native-async-storage/async-storage": "^2.0.0", + "expo": "52.0.14", + "expo-status-bar": "~1.11.1", + "react": "18.3.1", + "react-native": "0.76.3" + }, + "devDependencies": { + "@babel/core": "^7.20.0", + "@types/react": "~18.2.55", + "typescript": "^5.2.2" + }, + "packageManager": "yarn@3.4.1", + "installConfig": { + "hoistingLimits": "workspaces" + } +} diff --git a/packages/sdk/react-native/contract-tests/entity/plugins/withCleartextTraffic.js b/packages/sdk/react-native/contract-tests/entity/plugins/withCleartextTraffic.js new file mode 100644 index 0000000000..b9a3dc2eef --- /dev/null +++ b/packages/sdk/react-native/contract-tests/entity/plugins/withCleartextTraffic.js @@ -0,0 +1,9 @@ +const { withAndroidManifest } = require('expo/config-plugins'); + +module.exports = function withCleartextTraffic(config) { + return withAndroidManifest(config, (config) => { + const mainApplication = config.modResults.manifest.application[0]; + mainApplication.$['android:usesCleartextTraffic'] = 'true'; + return config; + }); +}; diff --git a/packages/sdk/react-native/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/react-native/contract-tests/entity/src/ClientEntity.ts new file mode 100644 index 0000000000..7f2b9ba6ad --- /dev/null +++ b/packages/sdk/react-native/contract-tests/entity/src/ClientEntity.ts @@ -0,0 +1,246 @@ +import { + AutoEnvAttributes, + LDOptions, + ReactNativeLDClient, +} from '@launchdarkly/react-native-client-sdk'; + +import { CommandParams, CommandType, ValueType } from './CommandParams'; +import { CreateInstanceParams, SDKConfigParams } from './ConfigParams'; +import { makeLogger } from './makeLogger'; +import TestHook from './TestHook'; + +export const badCommandError = new Error('unsupported command'); +export const malformedCommand = new Error('command was malformed'); + +function makeSdkConfig(options: SDKConfigParams, tag: string) { + if (!options.clientSide) { + throw new Error('configuration did not include clientSide options'); + } + + const isSet = (x?: unknown) => x !== null && x !== undefined; + const maybeTime = (seconds?: number) => (isSet(seconds) ? seconds / 1000 : undefined); + + const cf: LDOptions = { + withReasons: options.clientSide.evaluationReasons, + logger: makeLogger(`${tag}.sdk`), + useReport: options.clientSide.useReport, + // Disable automatic lifecycle handling for contract tests + automaticNetworkHandling: false, + automaticBackgroundHandling: false, + }; + + if (options.serviceEndpoints) { + cf.streamUri = options.serviceEndpoints.streaming; + cf.baseUri = options.serviceEndpoints.polling; + cf.eventsUri = options.serviceEndpoints.events; + } + + if (options.polling) { + if (options.polling.baseUri) { + cf.baseUri = options.polling.baseUri; + } + cf.initialConnectionMode = 'polling'; + } + + // Can contain streaming and polling, if streaming is set override the initial connection + // mode. + if (options.streaming) { + if (options.streaming.baseUri) { + cf.streamUri = options.streaming.baseUri; + } + cf.initialConnectionMode = 'streaming'; + cf.streamInitialReconnectDelay = maybeTime(options.streaming.initialRetryDelayMs); + } + + if (options.events) { + if (options.events.baseUri) { + cf.eventsUri = options.events.baseUri; + } + cf.allAttributesPrivate = options.events.allAttributesPrivate; + cf.capacity = options.events.capacity; + cf.diagnosticOptOut = !options.events.enableDiagnostics; + cf.flushInterval = maybeTime(options.events.flushIntervalMs); + cf.privateAttributes = options.events.globalPrivateAttributes; + } else { + cf.sendEvents = false; + } + + if (options.tags) { + cf.applicationInfo = { + id: options.tags.applicationId, + version: options.tags.applicationVersion, + }; + } + + if (options.hooks) { + cf.hooks = options.hooks.hooks.map( + (hook) => new TestHook(hook.name, hook.callbackUri, hook.data, hook.errors), + ); + } + + return cf; +} + +function makeDefaultInitialContext() { + return { kind: 'user', key: 'key-not-specified' }; +} + +export class ClientEntity { + constructor( + private readonly _client: ReactNativeLDClient, + private readonly _logger: ReturnType, + ) {} + + close() { + this._client.close(); + this._logger.info('Test ended'); + } + + async doCommand(params: CommandParams) { + this._logger.info(`Received command: ${params.command}`); + switch (params.command) { + case CommandType.EvaluateFlag: { + const evaluationParams = params.evaluate; + if (!evaluationParams) { + throw malformedCommand; + } + if (evaluationParams.detail) { + switch (evaluationParams.valueType) { + case ValueType.Bool: + return this._client.boolVariationDetail( + evaluationParams.flagKey, + evaluationParams.defaultValue as boolean, + ); + case ValueType.Int: // Intentional fallthrough. + case ValueType.Double: + return this._client.numberVariationDetail( + evaluationParams.flagKey, + evaluationParams.defaultValue as number, + ); + case ValueType.String: + return this._client.stringVariationDetail( + evaluationParams.flagKey, + evaluationParams.defaultValue as string, + ); + default: + return this._client.variationDetail( + evaluationParams.flagKey, + evaluationParams.defaultValue, + ); + } + } + switch (evaluationParams.valueType) { + case ValueType.Bool: + return { + value: this._client.boolVariation( + evaluationParams.flagKey, + evaluationParams.defaultValue as boolean, + ), + }; + case ValueType.Int: // Intentional fallthrough. + case ValueType.Double: + return { + value: this._client.numberVariation( + evaluationParams.flagKey, + evaluationParams.defaultValue as number, + ), + }; + case ValueType.String: + return { + value: this._client.stringVariation( + evaluationParams.flagKey, + evaluationParams.defaultValue as string, + ), + }; + default: + return { + value: this._client.variation( + evaluationParams.flagKey, + evaluationParams.defaultValue, + ), + }; + } + } + + case CommandType.EvaluateAllFlags: + return { state: this._client.allFlags() }; + + case CommandType.IdentifyEvent: { + const identifyParams = params.identifyEvent; + if (!identifyParams) { + throw malformedCommand; + } + await this._client.identify(identifyParams.user || identifyParams.context); + return undefined; + } + + case CommandType.CustomEvent: { + const customEventParams = params.customEvent; + if (!customEventParams) { + throw malformedCommand; + } + this._client.track( + customEventParams.eventKey, + customEventParams.data, + customEventParams.metricValue, + ); + return undefined; + } + + case CommandType.FlushEvents: + await this._client.flush(); + return undefined; + + default: + throw badCommandError; + } + } +} + +export async function newSdkClientEntity(options: CreateInstanceParams) { + const logger = makeLogger(options.tag); + + logger.info(`Creating client with configuration: ${JSON.stringify(options.configuration)}`); + + const timeout = + options.configuration.startWaitTimeMs !== null && + options.configuration.startWaitTimeMs !== undefined + ? options.configuration.startWaitTimeMs + : 5000; + + const sdkConfig = makeSdkConfig(options.configuration, options.tag); + + const autoEnvAttributes = options.configuration.clientSide?.includeEnvironmentAttributes + ? AutoEnvAttributes.Enabled + : AutoEnvAttributes.Disabled; + + const initialContext = + options.configuration.clientSide?.initialUser || + options.configuration.clientSide?.initialContext || + makeDefaultInitialContext(); + + const client = new ReactNativeLDClient( + options.configuration.credential || 'unknown-mobile-key', + autoEnvAttributes, + sdkConfig, + ); + + let failed = false; + try { + await Promise.race([ + client.identify(initialContext, { timeout: timeout / 1000, waitForNetworkResults: true }), + new Promise((_resolve, reject) => { + setTimeout(reject, timeout); + }), + ]); + } catch (_) { + // we get here if identify() rejects or if we timed out + failed = true; + } + if (failed && !options.configuration.initCanFail) { + client.close(); + throw new Error('client initialization failed'); + } + + return new ClientEntity(client, logger); +} diff --git a/packages/sdk/react-native/contract-tests/entity/src/CommandParams.ts b/packages/sdk/react-native/contract-tests/entity/src/CommandParams.ts new file mode 100644 index 0000000000..55174970f2 --- /dev/null +++ b/packages/sdk/react-native/contract-tests/entity/src/CommandParams.ts @@ -0,0 +1,157 @@ +import { LDContext, LDEvaluationReason } from '@launchdarkly/react-native-client-sdk'; + +export enum CommandType { + EvaluateFlag = 'evaluate', + EvaluateAllFlags = 'evaluateAll', + IdentifyEvent = 'identifyEvent', + CustomEvent = 'customEvent', + AliasEvent = 'aliasEvent', + FlushEvents = 'flushEvents', + ContextBuild = 'contextBuild', + ContextConvert = 'contextConvert', + ContextComparison = 'contextComparison', + SecureModeHash = 'secureModeHash', +} + +export enum ValueType { + Bool = 'bool', + Int = 'int', + Double = 'double', + String = 'string', + Any = 'any', +} + +export interface CommandParams { + command: CommandType; + evaluate?: EvaluateFlagParams; + evaluateAll?: EvaluateAllFlagsParams; + customEvent?: CustomEventParams; + identifyEvent?: IdentifyEventParams; + contextBuild?: ContextBuildParams; + contextConvert?: ContextConvertParams; + contextComparison?: ContextComparisonPairParams; + secureModeHash?: SecureModeHashParams; +} + +export interface EvaluateFlagParams { + flagKey: string; + context?: LDContext; + user?: any; + valueType: ValueType; + defaultValue: unknown; + detail: boolean; +} + +export interface EvaluateFlagResponse { + value: unknown; + variationIndex?: number; + reason?: LDEvaluationReason; +} + +export interface EvaluateAllFlagsParams { + context?: LDContext; + user?: any; + withReasons: boolean; + clientSideOnly: boolean; + detailsOnlyForTrackedFlags: boolean; +} + +export interface EvaluateAllFlagsResponse { + state: Record; +} + +export interface CustomEventParams { + eventKey: string; + context?: LDContext; + user?: any; + data?: unknown; + omitNullData: boolean; + metricValue?: number; +} + +export interface IdentifyEventParams { + context?: LDContext; + user?: any; +} + +export interface ContextBuildParams { + single?: ContextBuildSingleParams; + multi?: ContextBuildSingleParams[]; +} + +export interface ContextBuildSingleParams { + kind?: string; + key: string; + name?: string; + anonymous?: boolean; + private?: string[]; + custom?: Record; +} + +export interface ContextBuildResponse { + output: string; + error: string; +} + +export interface ContextConvertParams { + input: string; +} + +export interface ContextComparisonPairParams { + context1: ContextComparisonParams; + context2: ContextComparisonParams; +} + +export interface ContextComparisonParams { + single?: ContextComparisonSingleParams; + multi?: ContextComparisonSingleParams[]; +} + +export interface ContextComparisonSingleParams { + kind: string; + key: string; + attributes?: AttributeDefinition[]; + privateAttributes?: PrivateAttribute[]; +} + +export interface AttributeDefinition { + name: string; + value?: unknown; +} + +export interface PrivateAttribute { + value: string; + literal: boolean; +} + +export interface ContextComparisonResponse { + equals: boolean; +} + +export interface SecureModeHashParams { + context?: LDContext; + user?: any; +} + +export interface SecureModeHashResponse { + result: string; +} + +export enum HookStage { + BeforeEvaluation = 'beforeEvaluation', + AfterEvaluation = 'afterEvaluation', +} + +export interface EvaluationSeriesContext { + flagKey: string; + context: LDContext; + defaultValue: unknown; + method: string; +} + +export interface HookExecutionPayload { + evaluationSeriesContext?: EvaluationSeriesContext; + evaluationSeriesData?: Record; + evaluationDetail?: EvaluateFlagResponse; + stage?: HookStage; +} diff --git a/packages/sdk/react-native/contract-tests/entity/src/ConfigParams.ts b/packages/sdk/react-native/contract-tests/entity/src/ConfigParams.ts new file mode 100644 index 0000000000..7b15ab053a --- /dev/null +++ b/packages/sdk/react-native/contract-tests/entity/src/ConfigParams.ts @@ -0,0 +1,90 @@ +import { LDContext } from '@launchdarkly/react-native-client-sdk'; + +export interface CreateInstanceParams { + configuration: SDKConfigParams; + tag: string; +} + +export interface SDKConfigParams { + credential: string; + startWaitTimeMs?: number; // UnixMillisecondTime + initCanFail?: boolean; + serviceEndpoints?: SDKConfigServiceEndpointsParams; + tls?: SDKConfigTLSParams; + streaming?: SDKConfigStreamingParams; + polling?: SDKConfigPollingParams; + events?: SDKConfigEventParams; + tags?: SDKConfigTagsParams; + clientSide?: SDKConfigClientSideParams; + hooks?: SDKConfigHooksParams; + wrapper?: SDKConfigWrapper; +} + +export interface SDKConfigTLSParams { + skipVerifyPeer?: boolean; + customCAFile?: string; +} + +export interface SDKConfigServiceEndpointsParams { + streaming?: string; + polling?: string; + events?: string; +} + +export interface SDKConfigStreamingParams { + baseUri?: string; + initialRetryDelayMs?: number; // UnixMillisecondTime + filter?: string; +} + +export interface SDKConfigPollingParams { + baseUri?: string; + pollIntervalMs?: number; // UnixMillisecondTime + filter?: string; +} + +export interface SDKConfigEventParams { + baseUri?: string; + capacity?: number; + enableDiagnostics: boolean; + allAttributesPrivate?: boolean; + globalPrivateAttributes?: string[]; + flushIntervalMs?: number; // UnixMillisecondTime + omitAnonymousContexts?: boolean; + enableGzip?: boolean; +} + +export interface SDKConfigTagsParams { + applicationId?: string; + applicationVersion?: string; +} + +export interface SDKConfigClientSideParams { + initialContext?: LDContext; + initialUser?: any; + evaluationReasons?: boolean; + useReport?: boolean; + includeEnvironmentAttributes?: boolean; +} + +export interface SDKConfigEvaluationHookData { + [key: string]: unknown; +} + +export interface SDKConfigHookInstance { + name: string; + callbackUri: string; + data?: Record; + errors?: Record; +} + +export interface SDKConfigHooksParams { + hooks: SDKConfigHookInstance[]; +} + +export interface SDKConfigWrapper { + name: string; + version: string; +} + +export type HookStage = 'beforeEvaluation' | 'afterEvaluation'; diff --git a/packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts b/packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts new file mode 100644 index 0000000000..4bc88606a4 --- /dev/null +++ b/packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts @@ -0,0 +1,118 @@ +import { LDLogger } from '@launchdarkly/react-native-client-sdk'; + +import { ClientEntity, newSdkClientEntity } from './ClientEntity'; +import { makeLogger } from './makeLogger'; + +export default class TestHarnessWebSocket { + private _ws?: WebSocket; + private readonly _entities: Record = {}; + private _clientCounter = 0; + private _logger: LDLogger = makeLogger('TestHarnessWebSocket'); + private _intentionalClose = false; + private _onConnectionChange?: (connected: boolean) => void; + + constructor( + private readonly _url: string, + onConnectionChange?: (connected: boolean) => void, + ) { + this._onConnectionChange = onConnectionChange; + } + + connect() { + this._intentionalClose = false; + this._logger.info(`Connecting to web socket.`); + this._ws = new WebSocket(this._url, 'v1'); + this._ws.onopen = () => { + this._logger.info('Connected to websocket.'); + this._onConnectionChange?.(true); + }; + this._ws.onclose = () => { + this._logger.info('Websocket closed. Attempting to reconnect in 1 second.'); + this._onConnectionChange?.(false); + if (!this._intentionalClose) { + setTimeout(() => { + this.connect(); + }, 1000); + } + }; + this._ws.onerror = (err) => { + this._logger.info(`error:`, err); + }; + + this._ws.onmessage = async (msg) => { + this._logger.info('Test harness message', msg); + const data = JSON.parse(msg.data as string); + const resData: any = { reqId: data.reqId }; + switch (data.command) { + case 'getCapabilities': + resData.capabilities = [ + 'client-side', + 'mobile', + 'service-endpoints', + 'tags', + 'user-type', + 'inline-context-all', + 'anonymous-redaction', + 'strongly-typed', + 'client-prereq-events', + 'client-per-context-summaries', + 'track-hooks', + ]; + + break; + case 'createClient': + try { + resData.resourceUrl = `/clients/${this._clientCounter}`; + resData.status = 201; + const entity = await newSdkClientEntity(data.body); + this._entities[this._clientCounter] = entity; + this._clientCounter += 1; + } catch (e: any) { + this._logger.error(`Failed to create client: ${e?.message ?? e}`); + resData.status = 500; + } + break; + case 'runCommand': + if (Object.prototype.hasOwnProperty.call(this._entities, data.id)) { + const entity = this._entities[data.id]; + try { + const body = await entity.doCommand(data.body); + resData.body = body; + resData.status = body ? 200 : 204; + } catch (e: any) { + this._logger.error(`Command failed: ${e?.message ?? e}`); + resData.status = 500; + } + } else { + resData.status = 404; + this._logger.warn(`Client did not exist: ${data.id}`); + } + + break; + case 'deleteClient': + if (Object.prototype.hasOwnProperty.call(this._entities, data.id)) { + const entity = this._entities[data.id]; + entity.close(); + delete this._entities[data.id]; + } else { + resData.status = 404; + this._logger.warn(`Could not delete client because it did not exist: ${data.id}`); + } + break; + default: + break; + } + + this.send(resData); + }; + } + + disconnect() { + this._intentionalClose = true; + this._ws?.close(); + } + + send(data: unknown) { + this._ws?.send(JSON.stringify(data)); + } +} diff --git a/packages/sdk/react-native/contract-tests/entity/src/TestHook.ts b/packages/sdk/react-native/contract-tests/entity/src/TestHook.ts new file mode 100644 index 0000000000..80db5add4e --- /dev/null +++ b/packages/sdk/react-native/contract-tests/entity/src/TestHook.ts @@ -0,0 +1,94 @@ +import { + EvaluationSeriesContext, + EvaluationSeriesData, + Hook, + HookMetadata, + LDEvaluationDetail, + TrackSeriesContext, +} from '@launchdarkly/react-native-client-sdk'; + +export interface HookData { + beforeEvaluation?: Record; + afterEvaluation?: Record; +} + +export interface HookErrors { + beforeEvaluation?: string; + afterEvaluation?: string; + afterTrack?: string; +} + +export default class TestHook implements Hook { + private _name: string; + private _endpoint: string; + private _data?: HookData; + private _errors?: HookErrors; + + constructor(name: string, endpoint: string, data?: HookData, errors?: HookErrors) { + this._name = name; + this._endpoint = endpoint; + this._data = data; + this._errors = errors; + } + + private async _safePost(body: unknown): Promise { + try { + await fetch(this._endpoint, { + method: 'POST', + body: JSON.stringify(body), + }); + } catch { + // The test could move on before the post, so we are ignoring + // failed posts. + } + } + + getMetadata(): HookMetadata { + return { + name: this._name, + }; + } + + beforeEvaluation( + hookContext: EvaluationSeriesContext, + data: EvaluationSeriesData, + ): EvaluationSeriesData { + if (this._errors?.beforeEvaluation) { + throw new Error(this._errors.beforeEvaluation); + } + this._safePost({ + evaluationSeriesContext: hookContext, + evaluationSeriesData: data, + stage: 'beforeEvaluation', + }); + return { ...data, ...(this._data?.beforeEvaluation || {}) }; + } + + afterEvaluation( + hookContext: EvaluationSeriesContext, + data: EvaluationSeriesData, + detail: LDEvaluationDetail, + ): EvaluationSeriesData { + if (this._errors?.afterEvaluation) { + throw new Error(this._errors.afterEvaluation); + } + this._safePost({ + evaluationSeriesContext: hookContext, + evaluationSeriesData: data, + stage: 'afterEvaluation', + evaluationDetail: detail, + }); + + return { ...data, ...(this._data?.afterEvaluation || {}) }; + } + + afterTrack(hookContext: TrackSeriesContext): void { + if (this._errors?.afterTrack) { + throw new Error(this._errors.afterTrack); + } + this._safePost({ + trackSeriesContext: hookContext, + stage: 'afterTrack', + }); + } +} diff --git a/packages/sdk/react-native/contract-tests/entity/src/makeLogger.ts b/packages/sdk/react-native/contract-tests/entity/src/makeLogger.ts new file mode 100644 index 0000000000..076710fec2 --- /dev/null +++ b/packages/sdk/react-native/contract-tests/entity/src/makeLogger.ts @@ -0,0 +1,22 @@ +import { LDLogger } from '@launchdarkly/react-native-client-sdk'; + +export function makeLogger(tag: string): LDLogger { + return { + debug(message: any, ...args: any[]) { + // eslint-disable-next-line no-console + console.debug(`${new Date().toISOString()} [${tag}]: ${message}`, ...args); + }, + info(message: any, ...args: any[]) { + // eslint-disable-next-line no-console + console.info(`${new Date().toISOString()} [${tag}]: ${message}`, ...args); + }, + warn(message: any, ...args: any[]) { + // eslint-disable-next-line no-console + console.warn(`${new Date().toISOString()} [${tag}]: ${message}`, ...args); + }, + error(message: any, ...args: any[]) { + // eslint-disable-next-line no-console + console.error(`${new Date().toISOString()} [${tag}]: ${message}`, ...args); + }, + }; +} diff --git a/packages/sdk/react-native/contract-tests/entity/tsconfig.json b/packages/sdk/react-native/contract-tests/entity/tsconfig.json new file mode 100644 index 0000000000..b9567f6052 --- /dev/null +++ b/packages/sdk/react-native/contract-tests/entity/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true + } +} diff --git a/packages/sdk/react-native/contract-tests/run-ci-contract-tests.sh b/packages/sdk/react-native/contract-tests/run-ci-contract-tests.sh new file mode 100755 index 0000000000..12d520b10f --- /dev/null +++ b/packages/sdk/react-native/contract-tests/run-ci-contract-tests.sh @@ -0,0 +1,69 @@ +#!/bin/sh +# This script runs inside the android-emulator-runner action, where the +# emulator is alive. All adb commands must happen here — once this script +# exits the emulator is terminated. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" + +# Capture logs on any failure (while emulator is still alive) +dump_logs() { + echo "" + echo "=== Adapter Log ===" + cat /tmp/adapter.log 2>/dev/null || echo "No adapter log" + echo "" + echo "=== Logcat (ReactNativeJS) ===" + adb logcat -d -s ReactNativeJS:* 2>/dev/null | tail -200 || echo "No logcat available" + echo "" + echo "=== Logcat (recent errors) ===" + adb logcat -d '*:E' 2>/dev/null | tail -50 || echo "No logcat available" +} +trap dump_logs EXIT + +set -e + +# Set up port forwarding +adb reverse tcp:8001 tcp:8001 +adb reverse tcp:8111 tcp:8111 +adb reverse tcp:8112 tcp:8112 + +# Install and launch the APK +APK_DIR="$REPO_ROOT/packages/sdk/react-native/contract-tests/entity/android/app/build/outputs/apk" +if [ -f "$APK_DIR/release/app-release.apk" ]; then + adb install "$APK_DIR/release/app-release.apk" +elif [ -f "$APK_DIR/debug/app-debug.apk" ]; then + adb install "$APK_DIR/debug/app-debug.apk" +else + echo "ERROR: No APK found in $APK_DIR" + ls -R "$APK_DIR" 2>/dev/null || true + exit 1 +fi +adb shell am start -n com.launchdarkly.rncontracttestentity/.MainActivity + +# Wait for the app to connect to WebSocket +echo "Waiting for app to connect..." +i=0 +while [ "$i" -lt 30 ]; do + i=$((i + 1)) + if curl -s http://localhost:8000 > /dev/null 2>&1; then + echo "Test service ready" + break + fi + if [ "$i" -eq 30 ]; then + echo "Timeout waiting for test service" + exit 1 + fi + sleep 2 +done + +# Run the contract test harness +SUPPRESSIONS_FILE="$SCRIPT_DIR/suppressions.txt" +EXTRA_ARGS="" +if [ -s "$SUPPRESSIONS_FILE" ]; then + EXTRA_ARGS="--skip-from=$SUPPRESSIONS_FILE" +fi + +"$REPO_ROOT/sdk-test-harness" \ + -url http://localhost:8000 \ + -debug \ + $EXTRA_ARGS diff --git a/packages/sdk/react-native/contract-tests/run-contract-tests.sh b/packages/sdk/react-native/contract-tests/run-contract-tests.sh new file mode 100755 index 0000000000..113a84d187 --- /dev/null +++ b/packages/sdk/react-native/contract-tests/run-contract-tests.sh @@ -0,0 +1,132 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +if [ -z "$SDK_TEST_HARNESS_PATH" ]; then + echo "ERROR: SDK_TEST_HARNESS_PATH is not set. Point it to your sdk-test-harness checkout." + echo " export SDK_TEST_HARNESS_PATH=/path/to/sdk-test-harness" + exit 1 +fi +TEST_HARNESS_PATH="$SDK_TEST_HARNESS_PATH" + +echo "=== React Native Contract Tests ===" +echo "Repo root: $REPO_ROOT" +echo "Test harness: $TEST_HARNESS_PATH" + +# Check prerequisites +if ! command -v adb &> /dev/null; then + echo "ERROR: adb not found. Set ANDROID_HOME or install Android SDK." + exit 1 +fi + +if ! adb devices | grep -q "device$"; then + echo "ERROR: No Android device/emulator connected. Start an emulator first." + exit 1 +fi + +# Set up adb reverse port forwarding +echo "" +echo "=== Setting up adb reverse ports ===" +adb reverse tcp:8001 tcp:8001 # WebSocket adapter +adb reverse tcp:8111 tcp:8111 # Test harness mock HTTP server +adb reverse tcp:8112 tcp:8112 # Test harness mock HTTPS server +echo "Port forwarding configured." + +# Build the adapter +echo "" +echo "=== Building adapter ===" +cd "$REPO_ROOT" +yarn workspace react-native-contract-test-adapter run build + +# Start the adapter in the background +echo "" +echo "=== Starting adapter ===" +yarn workspace react-native-contract-test-adapter run start > /tmp/rn-adapter.log 2>&1 & +ADAPTER_PID=$! +echo "Adapter started (PID: $ADAPTER_PID)" + +# Wait for adapter to be ready +echo "Waiting for adapter on port 8001..." +for i in {1..30}; do + if nc -z localhost 8001 2>/dev/null; then + echo "Adapter WebSocket ready." + break + fi + if [ "$i" -eq 30 ]; then + echo "Timeout waiting for adapter." + cat /tmp/rn-adapter.log + kill $ADAPTER_PID 2>/dev/null + exit 1 + fi + sleep 1 +done + +# Cleanup function +cleanup() { + echo "" + echo "=== Cleaning up ===" + kill $ADAPTER_PID 2>/dev/null || true + echo "Done." +} +trap cleanup EXIT + +# Build and install the RN entity app +echo "" +echo "=== Building and installing RN entity app ===" +cd "$SCRIPT_DIR/entity" + +if [ ! -d "android" ]; then + echo "Running expo prebuild..." + npx expo prebuild --platform android +fi + +echo "Building release APK..." +cd android +./gradlew assembleRelease -q +cd .. + +echo "Installing APK on device..." +adb install -r android/app/build/outputs/apk/release/app-release.apk + +echo "Launching app..." +adb shell am start -n com.launchdarkly.rncontracttestentity/.MainActivity + +# Wait for the app to connect +echo "Waiting for app to connect to WebSocket..." +sleep 10 + +# Verify the test service is responding +echo "Verifying test service..." +for i in {1..30}; do + if curl -s http://localhost:8000 > /dev/null 2>&1; then + echo "Test service is ready!" + break + fi + if [ "$i" -eq 30 ]; then + echo "Timeout waiting for test service." + echo "=== Adapter Log ===" + cat /tmp/rn-adapter.log + echo "=== Logcat ===" + adb logcat -d -s ReactNativeJS:* 2>/dev/null | tail -50 + exit 1 + fi + sleep 2 +done + +# Run the test harness +echo "" +echo "=== Running test harness ===" +cd "$TEST_HARNESS_PATH" + +SUPPRESSIONS="$SCRIPT_DIR/suppressions.txt" +EXTRA_ARGS="" +if [ -s "$SUPPRESSIONS" ]; then + EXTRA_ARGS="--skip-from=$SUPPRESSIONS" +fi + +go run . \ + -url http://localhost:8000 \ + -debug \ + $EXTRA_ARGS \ + "$@" diff --git a/packages/sdk/react-native/contract-tests/suppressions.txt b/packages/sdk/react-native/contract-tests/suppressions.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/sdk/react-native/tsconfig.json b/packages/sdk/react-native/tsconfig.json index cb011a869d..afbca6d198 100644 --- a/packages/sdk/react-native/tsconfig.json +++ b/packages/sdk/react-native/tsconfig.json @@ -23,6 +23,7 @@ }, "exclude": [ "__tests__", + "contract-tests", "dist", "docs", "example",