Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions .github/workflows/react-native-contract-tests.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions packages/sdk/react-native/contract-tests/adapter/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
112 changes: 112 additions & 0 deletions packages/sdk/react-native/contract-tests/adapter/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, (data: unknown) => 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<any> => {
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();
15 changes: 15 additions & 0 deletions packages/sdk/react-native/contract-tests/adapter/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
4 changes: 4 additions & 0 deletions packages/sdk/react-native/contract-tests/entity/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
android/
ios/
node_modules/
.expo/
38 changes: 38 additions & 0 deletions packages/sdk/react-native/contract-tests/entity/App.tsx
Original file line number Diff line number Diff line change
@@ -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(() => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is slightly amusing.

const ws = new TestHarnessWebSocket('ws://localhost:8001', setConnected);
ws.connect();
return () => ws.disconnect();
}, []);

return (
<View style={styles.container}>
<Text style={styles.text}>RN Contract Test Entity</Text>
<Text style={styles.status}>WebSocket: {connected ? 'Connected' : 'Disconnected'}</Text>
</View>
);
}
14 changes: 14 additions & 0 deletions packages/sdk/react-native/contract-tests/entity/app.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};
10 changes: 10 additions & 0 deletions packages/sdk/react-native/contract-tests/entity/index.js
Original file line number Diff line number Diff line change
@@ -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);
26 changes: 26 additions & 0 deletions packages/sdk/react-native/contract-tests/entity/metro.config.js
Original file line number Diff line number Diff line change
@@ -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;
Loading