Skip to content

Commit af793b5

Browse files
authored
chore: Add react-native contract tets. (#1149)
SDK-1762 <!-- CURSOR_SUMMARY --> --- > [!NOTE] > <sup>[Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) is generating a summary for commit 968a3f5. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent e5427d6 commit af793b5

24 files changed

Lines changed: 1334 additions & 0 deletions
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
name: sdk/react-native/contract-tests
2+
3+
on:
4+
push:
5+
branches: [main, 'feat/**']
6+
paths-ignore:
7+
- '**.md'
8+
pull_request:
9+
branches: [main, 'feat/**']
10+
paths:
11+
- 'packages/shared/common/**'
12+
- 'packages/shared/sdk-client/**'
13+
- 'packages/sdk/react-native/**'
14+
- '.github/workflows/react-native-contract-tests.yml'
15+
16+
jobs:
17+
contract-tests-android:
18+
runs-on: ubuntu-22.04
19+
timeout-minutes: 30
20+
steps:
21+
# https://github.com/actions/checkout/releases/tag/v6.0.2
22+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
23+
24+
- name: Setup Node.js
25+
# https://github.com/actions/setup-node/releases/tag/v6.2.0
26+
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
27+
with:
28+
node-version: 18
29+
30+
- name: Install dependencies
31+
run: yarn workspaces focus react-native-contract-test-adapter react-native-contract-test-entity
32+
33+
- name: Build SDK and dependencies
34+
run: yarn workspaces foreach -pR --topological-dev --from '@launchdarkly/react-native-client-sdk' run build
35+
36+
- name: Build contract test adapter
37+
run: yarn workspace react-native-contract-test-adapter run build
38+
39+
- name: Enable KVM group perms (for emulator performance)
40+
run: |
41+
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
42+
sudo udevadm control --reload-rules
43+
sudo udevadm trigger --name-match=kvm
44+
45+
- name: Expo Prebuild
46+
working-directory: packages/sdk/react-native/contract-tests/entity
47+
run: npx expo prebuild --platform android
48+
49+
# Java setup is after expo prebuild so that it can locate the gradle configuration.
50+
- name: Setup Java
51+
# https://github.com/actions/setup-java/releases/tag/v5.2.0
52+
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
53+
with:
54+
distribution: temurin
55+
java-version: 17
56+
cache: 'gradle'
57+
58+
- name: Build APK (x86_64 only for emulator)
59+
working-directory: packages/sdk/react-native/contract-tests/entity/android
60+
run: ./gradlew assembleRelease -PreactNativeArchitectures=x86_64 -x lintVitalRelease -x lintVitalAnalyzeRelease -x lintVitalReportRelease
61+
62+
- name: Make space for the emulator
63+
# https://github.com/jlumbroso/free-disk-space/releases/tag/main
64+
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
65+
with:
66+
android: false
67+
large-packages: false
68+
69+
- name: Start adapter in background
70+
run: |
71+
yarn workspace react-native-contract-test-adapter run start > /tmp/adapter.log 2>&1 &
72+
echo $! > /tmp/adapter.pid
73+
74+
- name: Wait for adapter to be ready
75+
run: |
76+
echo "Waiting for adapter on port 8001..."
77+
for i in $(seq 1 30); do
78+
if nc -z localhost 8001; then
79+
echo "Adapter WebSocket ready"
80+
break
81+
fi
82+
if [ "$i" -eq 30 ]; then
83+
echo "Timeout waiting for adapter"
84+
cat /tmp/adapter.log
85+
exit 1
86+
fi
87+
sleep 1
88+
done
89+
90+
- name: Download contract test harness
91+
run: |
92+
# https://github.com/launchdarkly/sdk-test-harness/releases/tag/v2.34.0
93+
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"
94+
tar -xzf sdk-test-harness.tar.gz sdk-test-harness
95+
chmod +x sdk-test-harness
96+
97+
- name: Run contract tests on Android emulator
98+
# https://github.com/ReactiveCircus/android-emulator-runner/releases/tag/v2.34.0
99+
uses: reactivecircus/android-emulator-runner@f0d1ed2dcad93c7479e8b2f2226c83af54494915 # v2
100+
with:
101+
api-level: 31
102+
arch: x86_64
103+
avd-name: contract_test_emulator
104+
script: packages/sdk/react-native/contract-tests/run-ci-contract-tests.sh
105+
106+
- name: Cleanup
107+
if: always()
108+
run: |
109+
[ -f /tmp/adapter.pid ] && kill $(cat /tmp/adapter.pid) || true

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
"packages/sdk/react/examples/client-only",
2323
"packages/sdk/react-native",
2424
"packages/sdk/react-native/example",
25+
"packages/sdk/react-native/contract-tests/adapter",
26+
"packages/sdk/react-native/contract-tests/entity",
2527
"packages/sdk/react-universal",
2628
"packages/sdk/react-universal/example",
2729
"packages/sdk/vercel",
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "react-native-contract-test-adapter",
3+
"version": "1.0.0",
4+
"description": "Adapts REST interface to a websocket for use in React Native.",
5+
"main": "dist/index.js",
6+
"scripts": {
7+
"build": "tsc",
8+
"start": "yarn build && node dist/index.js"
9+
},
10+
"author": "",
11+
"license": "UNLICENSED",
12+
"dependencies": {
13+
"body-parser": "^1.20.3",
14+
"cors": "^2.8.5",
15+
"express": "^4.21.0",
16+
"ws": "^8.18.0"
17+
},
18+
"devDependencies": {
19+
"@types/cors": "^2.8.17",
20+
"@types/express": "^4.17.21",
21+
"@types/ws": "^8.5.12",
22+
"typescript": "^5.6.2"
23+
}
24+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/* eslint-disable no-console */
2+
3+
/* eslint-disable @typescript-eslint/no-explicit-any */
4+
import bodyParser from 'body-parser';
5+
import cors from 'cors';
6+
import { randomUUID } from 'crypto';
7+
import express from 'express';
8+
import http from 'node:http';
9+
import util from 'node:util';
10+
import { WebSocketServer } from 'ws';
11+
12+
let server: http.Server | undefined;
13+
14+
async function main() {
15+
const wss = new WebSocketServer({ port: 8001 });
16+
const waiters: Record<string, (data: unknown) => void> = {};
17+
18+
console.log('Running contract test harness adapter.');
19+
wss.on('connection', async (ws) => {
20+
ws.on('error', console.error);
21+
22+
ws.on('message', (stringData: string) => {
23+
const data = JSON.parse(stringData);
24+
if (Object.prototype.hasOwnProperty.call(waiters, data.reqId)) {
25+
waiters[data.reqId](data);
26+
delete waiters[data.reqId];
27+
} else {
28+
console.error('Did not find outstanding request', data.reqId);
29+
}
30+
});
31+
32+
const send = (data: { [key: string]: unknown; reqId: string }): Promise<any> => {
33+
let resolver: (data: unknown) => void;
34+
const waiter = new Promise((resolve) => {
35+
resolver = resolve;
36+
});
37+
// @ts-expect-error The body of the above assignment runs sequentially.
38+
waiters[data.reqId] = resolver;
39+
ws.send(JSON.stringify(data));
40+
return waiter;
41+
};
42+
43+
if (server) {
44+
await util.promisify(server.close).call(server);
45+
server = undefined;
46+
}
47+
48+
const app = express();
49+
50+
const port = 8000;
51+
52+
app.use(
53+
cors({
54+
origin: '*',
55+
allowedHeaders: '*',
56+
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
57+
}),
58+
);
59+
app.use(bodyParser.json());
60+
61+
app.get('/', async (_req, res) => {
62+
const commandResult = await send({ command: 'getCapabilities', reqId: randomUUID() });
63+
res.header('Content-Type', 'application/json');
64+
res.json(commandResult);
65+
});
66+
67+
app.delete('/', () => {
68+
process.exit();
69+
});
70+
71+
app.post('/', async (req, res) => {
72+
const commandResult = await send({
73+
command: 'createClient',
74+
body: req.body,
75+
reqId: randomUUID(),
76+
});
77+
if (commandResult.resourceUrl) {
78+
res.set('Location', commandResult.resourceUrl);
79+
}
80+
if (commandResult.status) {
81+
res.status(commandResult.status);
82+
}
83+
res.send();
84+
});
85+
86+
app.post('/clients/:id', async (req, res) => {
87+
const commandResult = await send({
88+
command: 'runCommand',
89+
id: req.params.id,
90+
body: req.body,
91+
reqId: randomUUID(),
92+
});
93+
if (commandResult.status) {
94+
res.status(commandResult.status);
95+
}
96+
if (commandResult.body) {
97+
res.write(JSON.stringify(commandResult.body));
98+
}
99+
res.send();
100+
});
101+
102+
app.delete('/clients/:id', async (req, res) => {
103+
await send({ command: 'deleteClient', id: req.params.id, reqId: randomUUID() });
104+
res.send();
105+
});
106+
107+
server = app.listen(port, () => {
108+
console.log('Listening on port %d', port);
109+
});
110+
});
111+
}
112+
main();
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES6",
4+
"lib": ["ES6"],
5+
"module": "commonjs",
6+
"esModuleInterop": true,
7+
"forceConsistentCasingInFileNames": true,
8+
"strict": true,
9+
"moduleResolution": "node",
10+
"outDir": "dist",
11+
"sourceMap": true,
12+
"skipLibCheck": true
13+
},
14+
"exclude": ["**/*.test.ts", "dist", "node_modules"]
15+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
android/
2+
ios/
3+
node_modules/
4+
.expo/
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import React, { useEffect, useState } from 'react';
2+
import { StyleSheet, Text, View } from 'react-native';
3+
4+
import TestHarnessWebSocket from './src/TestHarnessWebSocket';
5+
6+
const styles = StyleSheet.create({
7+
container: {
8+
flex: 1,
9+
justifyContent: 'center',
10+
alignItems: 'center',
11+
backgroundColor: '#fff',
12+
},
13+
text: {
14+
fontSize: 20,
15+
fontWeight: 'bold',
16+
},
17+
status: {
18+
fontSize: 16,
19+
marginTop: 10,
20+
},
21+
});
22+
23+
export default function App() {
24+
const [connected, setConnected] = useState(false);
25+
26+
useEffect(() => {
27+
const ws = new TestHarnessWebSocket('ws://localhost:8001', setConnected);
28+
ws.connect();
29+
return () => ws.disconnect();
30+
}, []);
31+
32+
return (
33+
<View style={styles.container}>
34+
<Text style={styles.text}>RN Contract Test Entity</Text>
35+
<Text style={styles.status}>WebSocket: {connected ? 'Connected' : 'Disconnected'}</Text>
36+
</View>
37+
);
38+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"expo": {
3+
"name": "rn-contract-test-entity",
4+
"slug": "rn-contract-test-entity",
5+
"version": "1.0.0",
6+
"orientation": "portrait",
7+
"android": {
8+
"package": "com.launchdarkly.rncontracttestentity"
9+
},
10+
"plugins": [
11+
"./plugins/withCleartextTraffic"
12+
]
13+
}
14+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module.exports = function (api) {
2+
api.cache(true);
3+
return {
4+
presets: ['babel-preset-expo'],
5+
};
6+
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// We have to use a custom entrypoint for monorepo workspaces to work.
2+
// https://docs.expo.dev/guides/monorepos/#change-default-entrypoint
3+
import { registerRootComponent } from 'expo';
4+
5+
import App from './App';
6+
7+
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
8+
// It also ensures that whether you load the app in Expo Go or in a native build,
9+
// the environment is set up appropriately
10+
registerRootComponent(App);

0 commit comments

Comments
 (0)