From cc0e50a429c5b5fff341e9aae980bed31527667b Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:17:08 -0800 Subject: [PATCH 01/11] chore: Add react-native contract tets. --- .../workflows/react-native-contract-tests.yml | 146 +++++++++++ package.json | 2 + .../contract-tests/adapter/package.json | 24 ++ .../contract-tests/adapter/src/index.ts | 112 ++++++++ .../contract-tests/adapter/tsconfig.json | 15 ++ .../contract-tests/entity/.gitignore | 4 + .../contract-tests/entity/App.tsx | 38 +++ .../contract-tests/entity/app.json | 14 + .../contract-tests/entity/babel.config.js | 6 + .../contract-tests/entity/index.js | 10 + .../contract-tests/entity/metro.config.js | 26 ++ .../contract-tests/entity/package.json | 30 +++ .../entity/plugins/withCleartextTraffic.js | 9 + .../contract-tests/entity/src/ClientEntity.ts | 246 ++++++++++++++++++ .../entity/src/CommandParams.ts | 157 +++++++++++ .../contract-tests/entity/src/ConfigParams.ts | 90 +++++++ .../entity/src/TestHarnessWebSocket.ts | 120 +++++++++ .../contract-tests/entity/src/TestHook.ts | 94 +++++++ .../contract-tests/entity/src/makeLogger.ts | 22 ++ .../contract-tests/entity/tsconfig.json | 6 + .../contract-tests/run-contract-tests.sh | 127 +++++++++ .../contract-tests/suppressions.txt | 0 packages/sdk/react-native/tsconfig.json | 1 + 23 files changed, 1299 insertions(+) create mode 100644 .github/workflows/react-native-contract-tests.yml create mode 100644 packages/sdk/react-native/contract-tests/adapter/package.json create mode 100644 packages/sdk/react-native/contract-tests/adapter/src/index.ts create mode 100644 packages/sdk/react-native/contract-tests/adapter/tsconfig.json create mode 100644 packages/sdk/react-native/contract-tests/entity/.gitignore create mode 100644 packages/sdk/react-native/contract-tests/entity/App.tsx create mode 100644 packages/sdk/react-native/contract-tests/entity/app.json create mode 100644 packages/sdk/react-native/contract-tests/entity/babel.config.js create mode 100644 packages/sdk/react-native/contract-tests/entity/index.js create mode 100644 packages/sdk/react-native/contract-tests/entity/metro.config.js create mode 100644 packages/sdk/react-native/contract-tests/entity/package.json create mode 100644 packages/sdk/react-native/contract-tests/entity/plugins/withCleartextTraffic.js create mode 100644 packages/sdk/react-native/contract-tests/entity/src/ClientEntity.ts create mode 100644 packages/sdk/react-native/contract-tests/entity/src/CommandParams.ts create mode 100644 packages/sdk/react-native/contract-tests/entity/src/ConfigParams.ts create mode 100644 packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts create mode 100644 packages/sdk/react-native/contract-tests/entity/src/TestHook.ts create mode 100644 packages/sdk/react-native/contract-tests/entity/src/makeLogger.ts create mode 100644 packages/sdk/react-native/contract-tests/entity/tsconfig.json create mode 100755 packages/sdk/react-native/contract-tests/run-contract-tests.sh create mode 100644 packages/sdk/react-native/contract-tests/suppressions.txt diff --git a/.github/workflows/react-native-contract-tests.yml b/.github/workflows/react-native-contract-tests.yml new file mode 100644 index 0000000000..1754d0c43c --- /dev/null +++ b/.github/workflows/react-native-contract-tests.yml @@ -0,0 +1,146 @@ +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 + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + 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 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + cache: 'gradle' + + - name: Build release APK + working-directory: packages/sdk/react-native/contract-tests/entity/android + run: ./gradlew assembleRelease + + - name: Make space for the emulator + uses: jlumbroso/free-disk-space@v1.3.1 + 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 {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: | + curl -sL -o sdk-test-harness.tar.gz "https://github.com/launchdarkly/sdk-test-harness/releases/latest/download/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 + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 31 + arch: x86_64 + avd-name: contract_test_emulator + script: | + # 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 + adb install packages/sdk/react-native/contract-tests/entity/android/app/build/outputs/apk/release/app-release.apk + adb shell am start -n com.launchdarkly.rncontracttestentity/.MainActivity + + # Wait for the app to connect to WebSocket + echo "Waiting for app to connect..." + for i in {1..30}; do + 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" + cat /tmp/adapter.log + adb logcat -d -s ReactNativeJS:* | tail -100 + exit 1 + fi + sleep 2 + done + + # Run the contract test harness + SUPPRESSIONS_FILE="${{ github.workspace }}/packages/sdk/react-native/contract-tests/suppressions.txt" + EXTRA_ARGS="" + if [ -s "$SUPPRESSIONS_FILE" ]; then + EXTRA_ARGS="--skip-from=$SUPPRESSIONS_FILE" + fi + + ./sdk-test-harness \ + -url http://localhost:8000 \ + -debug \ + $EXTRA_ARGS + + - name: Print logs on failure + if: failure() + run: | + echo "=== Adapter Log ===" + cat /tmp/adapter.log || echo "No adapter log" + echo "=== Logcat ===" + adb logcat -d -s ReactNativeJS:* 2>/dev/null | tail -200 || echo "No logcat available" + + - 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..f6ad77fb4c --- /dev/null +++ b/packages/sdk/react-native/contract-tests/adapter/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES6", + "module": "commonjs", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "moduleResolution": "node", + "outDir": "dist", + "sourceMap": true, + "skipLibCheck": true + }, + "lib": ["ES6"], + "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..ab951179a8 --- /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'; + +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'} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#fff', + }, + text: { + fontSize: 20, + fontWeight: 'bold', + }, + status: { + fontSize: 16, + marginTop: 10, + }, +}); 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..e3b8070755 --- /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: + 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..2dcfd2e9dc --- /dev/null +++ b/packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts @@ -0,0 +1,120 @@ +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-contract-tests.sh b/packages/sdk/react-native/contract-tests/run-contract-tests.sh new file mode 100755 index 0000000000..822b7acd65 --- /dev/null +++ b/packages/sdk/react-native/contract-tests/run-contract-tests.sh @@ -0,0 +1,127 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +TEST_HARNESS_PATH="${SDK_TEST_HARNESS_PATH:-/home/rlamb/code/launchdarkly/sdk-test-harness}" + +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", From 79a7790aaffa0a045ec59d619da0a425eecc57f9 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:28:05 -0800 Subject: [PATCH 02/11] fix: Resolve lint errors in react-native contract tests entity --- .../contract-tests/entity/App.tsx | 34 +++++++++---------- .../entity/src/TestHarnessWebSocket.ts | 20 +++++------ 2 files changed, 26 insertions(+), 28 deletions(-) diff --git a/packages/sdk/react-native/contract-tests/entity/App.tsx b/packages/sdk/react-native/contract-tests/entity/App.tsx index ab951179a8..0293facee3 100644 --- a/packages/sdk/react-native/contract-tests/entity/App.tsx +++ b/packages/sdk/react-native/contract-tests/entity/App.tsx @@ -3,23 +3,6 @@ import { StyleSheet, Text, View } from 'react-native'; import TestHarnessWebSocket from './src/TestHarnessWebSocket'; -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'} - - ); -} - const styles = StyleSheet.create({ container: { flex: 1, @@ -36,3 +19,20 @@ const styles = StyleSheet.create({ 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/src/TestHarnessWebSocket.ts b/packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts index 2dcfd2e9dc..4bc88606a4 100644 --- a/packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts +++ b/packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts @@ -61,17 +61,15 @@ export default class TestHarnessWebSocket { 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; - } + 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': From 2bbcd8678126fa8ec8e57eb80b2027089cb19e5e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:35:23 -0800 Subject: [PATCH 03/11] chore: Pin GitHub Actions to SHAs in contract tests workflow --- .github/workflows/react-native-contract-tests.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/react-native-contract-tests.yml b/.github/workflows/react-native-contract-tests.yml index 1754d0c43c..d6e35ce081 100644 --- a/.github/workflows/react-native-contract-tests.yml +++ b/.github/workflows/react-native-contract-tests.yml @@ -17,10 +17,12 @@ jobs: contract-tests-android: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + # https://github.com/actions/checkout/releases/tag/v6.0.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Node.js - uses: actions/setup-node@v4 + # https://github.com/actions/setup-node/releases/tag/v6.2.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: 18 @@ -45,7 +47,8 @@ jobs: # Java setup is after expo prebuild so that it can locate the gradle configuration. - name: Setup Java - uses: actions/setup-java@v4 + # 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 @@ -56,7 +59,8 @@ jobs: run: ./gradlew assembleRelease - name: Make space for the emulator - uses: jlumbroso/free-disk-space@v1.3.1 + # https://github.com/jlumbroso/free-disk-space/releases/tag/main + uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be with: android: false large-packages: false @@ -89,7 +93,8 @@ jobs: chmod +x sdk-test-harness - name: Run contract tests on Android emulator - uses: reactivecircus/android-emulator-runner@v2 + # 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 From 528852f475d5c568d550a3e27794c88f4e51231e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 4 Mar 2026 09:38:18 -0800 Subject: [PATCH 04/11] fix: Use POSIX-compatible shell syntax in emulator runner script The android-emulator-runner action executes scripts with /usr/bin/sh, not bash. The {1..30} brace expansion is a bashism that causes a syntax error in POSIX sh, which made the script fail immediately. Also adds a 30-minute job timeout to prevent runaway CI jobs. --- .github/workflows/react-native-contract-tests.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/react-native-contract-tests.yml b/.github/workflows/react-native-contract-tests.yml index d6e35ce081..70e4deecd5 100644 --- a/.github/workflows/react-native-contract-tests.yml +++ b/.github/workflows/react-native-contract-tests.yml @@ -16,6 +16,7 @@ on: 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 @@ -73,12 +74,12 @@ jobs: - name: Wait for adapter to be ready run: | echo "Waiting for adapter on port 8001..." - for i in {1..30}; do + for i in $(seq 1 30); do if nc -z localhost 8001; then echo "Adapter WebSocket ready" break fi - if [ $i -eq 30 ]; then + if [ "$i" -eq 30 ]; then echo "Timeout waiting for adapter" cat /tmp/adapter.log exit 1 @@ -111,12 +112,12 @@ jobs: # Wait for the app to connect to WebSocket echo "Waiting for app to connect..." - for i in {1..30}; do + for i in $(seq 1 30); do if curl -s http://localhost:8000 > /dev/null 2>&1; then echo "Test service ready" break fi - if [ $i -eq 30 ]; then + if [ "$i" -eq 30 ]; then echo "Timeout waiting for test service" cat /tmp/adapter.log adb logcat -d -s ReactNativeJS:* | tail -100 From 847ad594ef368eb13c36b62bc279b244cb388d0a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 4 Mar 2026 09:55:50 -0800 Subject: [PATCH 05/11] fix: Move emulator runner script to external file The android-emulator-runner action runs each line of the script parameter as a separate /usr/bin/sh -c invocation. Multi-line constructs like for loops, if/fi blocks, and line continuations cannot work inline. Moved the entire script to run-ci-contract-tests.sh which is invoked as a single command. --- .../workflows/react-native-contract-tests.yml | 38 +--------------- .../contract-tests/run-ci-contract-tests.sh | 44 +++++++++++++++++++ 2 files changed, 45 insertions(+), 37 deletions(-) create mode 100755 packages/sdk/react-native/contract-tests/run-ci-contract-tests.sh diff --git a/.github/workflows/react-native-contract-tests.yml b/.github/workflows/react-native-contract-tests.yml index 70e4deecd5..88fa834798 100644 --- a/.github/workflows/react-native-contract-tests.yml +++ b/.github/workflows/react-native-contract-tests.yml @@ -100,43 +100,7 @@ jobs: api-level: 31 arch: x86_64 avd-name: contract_test_emulator - script: | - # 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 - adb install packages/sdk/react-native/contract-tests/entity/android/app/build/outputs/apk/release/app-release.apk - adb shell am start -n com.launchdarkly.rncontracttestentity/.MainActivity - - # Wait for the app to connect to WebSocket - echo "Waiting for app to connect..." - for i in $(seq 1 30); do - 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" - cat /tmp/adapter.log - adb logcat -d -s ReactNativeJS:* | tail -100 - exit 1 - fi - sleep 2 - done - - # Run the contract test harness - SUPPRESSIONS_FILE="${{ github.workspace }}/packages/sdk/react-native/contract-tests/suppressions.txt" - EXTRA_ARGS="" - if [ -s "$SUPPRESSIONS_FILE" ]; then - EXTRA_ARGS="--skip-from=$SUPPRESSIONS_FILE" - fi - - ./sdk-test-harness \ - -url http://localhost:8000 \ - -debug \ - $EXTRA_ARGS + script: packages/sdk/react-native/contract-tests/run-ci-contract-tests.sh - name: Print logs on failure if: failure() 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..8460fa5bb6 --- /dev/null +++ b/packages/sdk/react-native/contract-tests/run-ci-contract-tests.sh @@ -0,0 +1,44 @@ +#!/bin/sh +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" + +# 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 +adb install "$REPO_ROOT/packages/sdk/react-native/contract-tests/entity/android/app/build/outputs/apk/release/app-release.apk" +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" + cat /tmp/adapter.log || true + adb logcat -d -s ReactNativeJS:* | tail -100 || true + 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 From 87e2b88475a8fa1dd6009e821146de94eb1f097d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:13:39 -0800 Subject: [PATCH 06/11] perf: Switch to debug build with x86_64-only ABI for contract tests The release build was spending ~7.5 min in CI building native code for 4 ABIs (arm64-v8a, armeabi-v7a, x86, x86_64) plus running lintVital. Since the emulator is x86_64, we only need that ABI. Switching to debug build also skips lintVital and R8/ProGuard (which weren't meaningful anyway as release was signed with debug keys). Expected savings: ~3-4 minutes (native builds for 3 unused ABIs + lint). --- .github/workflows/react-native-contract-tests.yml | 4 ++-- .../contract-tests/run-ci-contract-tests.sh | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/react-native-contract-tests.yml b/.github/workflows/react-native-contract-tests.yml index 88fa834798..7ddf54f676 100644 --- a/.github/workflows/react-native-contract-tests.yml +++ b/.github/workflows/react-native-contract-tests.yml @@ -55,9 +55,9 @@ jobs: java-version: 17 cache: 'gradle' - - name: Build release APK + - name: Build debug APK (x86_64 only for emulator) working-directory: packages/sdk/react-native/contract-tests/entity/android - run: ./gradlew assembleRelease + run: ./gradlew assembleDebug -PreactNativeArchitectures=x86_64 - name: Make space for the emulator # https://github.com/jlumbroso/free-disk-space/releases/tag/main 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 index 8460fa5bb6..aab9c1c8a7 100755 --- a/packages/sdk/react-native/contract-tests/run-ci-contract-tests.sh +++ b/packages/sdk/react-native/contract-tests/run-ci-contract-tests.sh @@ -10,7 +10,15 @@ adb reverse tcp:8111 tcp:8111 adb reverse tcp:8112 tcp:8112 # Install and launch the APK -adb install "$REPO_ROOT/packages/sdk/react-native/contract-tests/entity/android/app/build/outputs/apk/release/app-release.apk" +APK_DIR="$REPO_ROOT/packages/sdk/react-native/contract-tests/entity/android/app/build/outputs/apk" +if [ -f "$APK_DIR/debug/app-debug.apk" ]; then + adb install "$APK_DIR/debug/app-debug.apk" +elif [ -f "$APK_DIR/release/app-release.apk" ]; then + adb install "$APK_DIR/release/app-release.apk" +else + echo "ERROR: No APK found in $APK_DIR" + exit 1 +fi adb shell am start -n com.launchdarkly.rncontracttestentity/.MainActivity # Wait for the app to connect to WebSocket From b2ea02a61280200fe01f0f9a1db3d8a9ba37c08e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:29:30 -0800 Subject: [PATCH 07/11] fix: Use release build (debug has no JS bundle) and capture logs inside emulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Debug builds skip JS bundling and expect Metro dev server — the APK launched with no JavaScript code so it never connected to the WebSocket. Switched back to assembleRelease with x86_64-only ABI and skipping lintVital to keep the speed improvement. Moved log capture into the emulator runner script via a trap on EXIT. The previous "Print logs" step ran after the emulator was terminated, causing adb logcat to hang indefinitely. --- .../workflows/react-native-contract-tests.yml | 12 ++------ .../contract-tests/run-ci-contract-tests.sh | 29 +++++++++++++++---- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/.github/workflows/react-native-contract-tests.yml b/.github/workflows/react-native-contract-tests.yml index 7ddf54f676..01356d4495 100644 --- a/.github/workflows/react-native-contract-tests.yml +++ b/.github/workflows/react-native-contract-tests.yml @@ -55,9 +55,9 @@ jobs: java-version: 17 cache: 'gradle' - - name: Build debug APK (x86_64 only for emulator) + - name: Build APK (x86_64 only for emulator) working-directory: packages/sdk/react-native/contract-tests/entity/android - run: ./gradlew assembleDebug -PreactNativeArchitectures=x86_64 + run: ./gradlew assembleRelease -PreactNativeArchitectures=x86_64 -x lintVitalAnalyzeRelease -x lintVitalReportRelease - name: Make space for the emulator # https://github.com/jlumbroso/free-disk-space/releases/tag/main @@ -102,14 +102,6 @@ jobs: avd-name: contract_test_emulator script: packages/sdk/react-native/contract-tests/run-ci-contract-tests.sh - - name: Print logs on failure - if: failure() - run: | - echo "=== Adapter Log ===" - cat /tmp/adapter.log || echo "No adapter log" - echo "=== Logcat ===" - adb logcat -d -s ReactNativeJS:* 2>/dev/null | tail -200 || echo "No logcat available" - - name: Cleanup if: always() run: | 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 index aab9c1c8a7..12d520b10f 100755 --- a/packages/sdk/react-native/contract-tests/run-ci-contract-tests.sh +++ b/packages/sdk/react-native/contract-tests/run-ci-contract-tests.sh @@ -1,9 +1,27 @@ #!/bin/sh -set -e +# 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 @@ -11,12 +29,13 @@ 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/debug/app-debug.apk" ]; then - adb install "$APK_DIR/debug/app-debug.apk" -elif [ -f "$APK_DIR/release/app-release.apk" ]; then +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 @@ -32,8 +51,6 @@ while [ "$i" -lt 30 ]; do fi if [ "$i" -eq 30 ]; then echo "Timeout waiting for test service" - cat /tmp/adapter.log || true - adb logcat -d -s ReactNativeJS:* | tail -100 || true exit 1 fi sleep 2 From 968a3f5e0627009998361ca29a641abd64ac77d0 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:35:43 -0800 Subject: [PATCH 08/11] fix: Also skip lintVitalRelease task (not just its subtasks) --- .github/workflows/react-native-contract-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/react-native-contract-tests.yml b/.github/workflows/react-native-contract-tests.yml index 01356d4495..1899cc35a0 100644 --- a/.github/workflows/react-native-contract-tests.yml +++ b/.github/workflows/react-native-contract-tests.yml @@ -57,7 +57,7 @@ jobs: - 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 lintVitalAnalyzeRelease -x lintVitalReportRelease + 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 From 496963fa27ac7eb225618e35acfd2b4f28252315 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:57:00 -0800 Subject: [PATCH 09/11] fix: Require SDK_TEST_HARNESS_PATH instead of hardcoding personal path --- .../sdk/react-native/contract-tests/run-contract-tests.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/sdk/react-native/contract-tests/run-contract-tests.sh b/packages/sdk/react-native/contract-tests/run-contract-tests.sh index 822b7acd65..113a84d187 100755 --- a/packages/sdk/react-native/contract-tests/run-contract-tests.sh +++ b/packages/sdk/react-native/contract-tests/run-contract-tests.sh @@ -3,7 +3,12 @@ set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" -TEST_HARNESS_PATH="${SDK_TEST_HARNESS_PATH:-/home/rlamb/code/launchdarkly/sdk-test-harness}" +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" From 5ef11f3838bd7cbbfac95f47601c2b14403629cd Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:03:13 -0800 Subject: [PATCH 10/11] fix: Move lib into compilerOptions in adapter tsconfig --- packages/sdk/react-native/contract-tests/adapter/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/react-native/contract-tests/adapter/tsconfig.json b/packages/sdk/react-native/contract-tests/adapter/tsconfig.json index f6ad77fb4c..693bf1f0c2 100644 --- a/packages/sdk/react-native/contract-tests/adapter/tsconfig.json +++ b/packages/sdk/react-native/contract-tests/adapter/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "target": "ES6", + "lib": ["ES6"], "module": "commonjs", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, @@ -10,6 +11,5 @@ "sourceMap": true, "skipLibCheck": true }, - "lib": ["ES6"], "exclude": ["**/*.test.ts", "dist", "node_modules"] } From 3ded7cfe3bc43aedd4db3c8691db12303552630a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:35:32 -0800 Subject: [PATCH 11/11] fix: Address PR review comments - Await flush() call in ClientEntity (joker23) - Pin sdk-test-harness download to v2.34.0 instead of latest (joker23) --- .github/workflows/react-native-contract-tests.yml | 3 ++- .../sdk/react-native/contract-tests/entity/src/ClientEntity.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/react-native-contract-tests.yml b/.github/workflows/react-native-contract-tests.yml index 1899cc35a0..aa5e3b68d9 100644 --- a/.github/workflows/react-native-contract-tests.yml +++ b/.github/workflows/react-native-contract-tests.yml @@ -89,7 +89,8 @@ jobs: - name: Download contract test harness run: | - curl -sL -o sdk-test-harness.tar.gz "https://github.com/launchdarkly/sdk-test-harness/releases/latest/download/sdk-test-harness_Linux_x86_64.tar.gz" + # 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 diff --git a/packages/sdk/react-native/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/react-native/contract-tests/entity/src/ClientEntity.ts index e3b8070755..7f2b9ba6ad 100644 --- a/packages/sdk/react-native/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/react-native/contract-tests/entity/src/ClientEntity.ts @@ -188,7 +188,7 @@ export class ClientEntity { } case CommandType.FlushEvents: - this._client.flush(); + await this._client.flush(); return undefined; default: