diff --git a/.eslintrc.js b/.eslintrc.js
index f6659e123..f7dc58086 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -14,6 +14,7 @@ module.exports = {
'**/vercel/examples/**',
'**/react-native/example/**',
'**/react-universal/example/**',
+ '**/react-universal/contract-tests/**',
'**/fromExternal/**',
],
rules: {
diff --git a/package.json b/package.json
index 11e71f91d..7bc5d495d 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
"packages/sdk/react-native/example",
"packages/sdk/react-universal",
"packages/sdk/react-universal/example",
+ "packages/sdk/react-universal/contract-tests",
"packages/sdk/vercel",
"packages/sdk/svelte",
"packages/sdk/svelte/example",
diff --git a/packages/sdk/browser/contract-tests/README.md b/packages/sdk/browser/contract-tests/README.md
index 73397575c..51808132f 100644
--- a/packages/sdk/browser/contract-tests/README.md
+++ b/packages/sdk/browser/contract-tests/README.md
@@ -49,5 +49,5 @@ You then run the `sdk-test-harness`. More information is available here: https:/
Example with local clone of the test harness:
```bash
-go run . --url http://localhost:8123 -skip-from path-to-your-js-core-clone/packages/sdk/browser/contract-tests/suppressions.txt
+go run . --url http://localhost:8000 -skip-from path-to-your-js-core-clone/packages/sdk/browser/contract-tests/suppressions.txt
```
diff --git a/packages/sdk/react-universal/contract-tests/.gitignore b/packages/sdk/react-universal/contract-tests/.gitignore
new file mode 100644
index 000000000..4415ba5c6
--- /dev/null
+++ b/packages/sdk/react-universal/contract-tests/.gitignore
@@ -0,0 +1,5 @@
+# next.js
+/.next/
+/out/
+
+next-env.d.ts
diff --git a/packages/sdk/react-universal/contract-tests/README.md b/packages/sdk/react-universal/contract-tests/README.md
new file mode 100644
index 000000000..d9830d3a2
--- /dev/null
+++ b/packages/sdk/react-universal/contract-tests/README.md
@@ -0,0 +1,59 @@
+# React SDK contract-tests
+
+This directory contains the contract test implementation for the LaunchDarkly React SDK using the [SDK Test Harness](https://github.com/launchdarkly/sdk-test-harness).
+
+## Architecture
+> NOTE: much of the test architecture is based off of
+> [browser contract test](../../browser/contract-tests).
+
+This contract test consists of 3 components:
+
+1. [Adapter](../../browser/contract-tests/adapter/): A Node.js server that:
+ - Exposes a REST API on port 8000 for the test harness
+ - Runs a WebSocket server on port 8001 for browser communication
+ - Translates REST commands to WebSocket messages
+
+2. Entity: A browser application (NextJS app) that:
+ - Connects to the adapter via WebSocket
+ - Implements the actual SDK test logic
+ - Runs the React SDK in a real browser environment
+
+3. [Test harness](https://github.com/launchdarkly/sdk-test-harness): The SDK test harness that:
+ - Sends test commands via REST API to the adapter (port 8000)
+ - Validates SDK behavior across different scenarios
+
+## Running Locally
+
+### Prerequisites
+
+- Node.js 18 or later
+- Yarn
+- A modern browser (for manual testing)
+
+### Quick Start
+
+```bash
+# Install the workspace if you haven't already
+yarn install
+
+# Build contract tests and browser contract test (dependency)
+yarn workspaces foreach -pR --topological-dev --from 'browser-contract-test-adapter' run build
+yarn workspaces foreach -pR --topological-dev --from '@launchdarkly/react-sdk-contract-tests' run build
+```
+
+From the repository root
+```bash
+./packages/sdk/browser/contract-tests/run-test-service.sh
+```
+
+This script will:
+1. Start the adapter (WebSocket bridge)
+2. Start the app
+
+The services will be available at:
+- Adapter REST API: http://localhost:8000
+- Adapter WebSocket: ws://localhost:8001
+- Browser App: http://localhost:8002
+
+You then run the `sdk-test-harness`. More information is available here: https://github.com/launchdarkly/sdk-test-harness
+
diff --git a/packages/sdk/react-universal/contract-tests/app/layout.tsx b/packages/sdk/react-universal/contract-tests/app/layout.tsx
new file mode 100644
index 000000000..d0a0b200f
--- /dev/null
+++ b/packages/sdk/react-universal/contract-tests/app/layout.tsx
@@ -0,0 +1,24 @@
+'use client';
+
+import React, { useEffect } from 'react';
+
+import AdaptorWebSocket from './websocket';
+
+const ws = new AdaptorWebSocket('ws://localhost:8001');
+
+export default function App() {
+ useEffect(() => {
+ ws.connect();
+ return () => {
+ ws.disconnect();
+ };
+ }, []);
+
+ return (
+
+
+ Hello test harness
+
+
+ );
+}
diff --git a/packages/sdk/react-universal/contract-tests/app/websocket.ts b/packages/sdk/react-universal/contract-tests/app/websocket.ts
new file mode 100644
index 000000000..6a30281be
--- /dev/null
+++ b/packages/sdk/react-universal/contract-tests/app/websocket.ts
@@ -0,0 +1,64 @@
+// eslint-disable no-console
+
+export default class AdaptorWebSocket {
+ private _ws?: WebSocket;
+
+ constructor(private readonly _url: string) {}
+
+ connect() {
+ console.log(`Connecting to web socket.`);
+ this._ws = new WebSocket(this._url, ['v1']);
+ this._ws.onopen = () => {
+ console.log('Connected to websocket.');
+ };
+ this._ws.onclose = () => {
+ console.log('Websocket closed. Attempting to reconnect in 1 second.');
+ setTimeout(() => {
+ this.connect();
+ }, 1000);
+ };
+ this._ws.onerror = (err) => {
+ console.log(`error:`, err);
+ };
+
+ this._ws.onmessage = async (msg) => {
+ console.log('Test harness message', msg);
+ const data = JSON.parse(msg.data);
+ const resData: any = { reqId: data.reqId };
+ // TODO: currently copied from the browser contract tests
+ // will need to figure out what the actual capabilities are.
+ switch (data.command) {
+ case 'getCapabilities':
+ resData.capabilities = [
+ 'client-side',
+ 'service-endpoints',
+ 'tags',
+ 'user-type',
+ 'inline-context-all',
+ 'anonymous-redaction',
+ 'strongly-typed',
+ 'client-prereq-events',
+ 'client-per-context-summaries',
+ 'track-hooks',
+ ];
+
+ break;
+ case 'createClient':
+ case 'runCommand':
+ case 'deleteClient':
+ default:
+ break;
+ }
+
+ this.send(resData);
+ };
+ }
+
+ disconnect() {
+ this._ws?.close();
+ }
+
+ send(data: unknown) {
+ this._ws?.send(JSON.stringify(data));
+ }
+}
diff --git a/packages/sdk/react-universal/contract-tests/next.config.ts b/packages/sdk/react-universal/contract-tests/next.config.ts
new file mode 100644
index 000000000..5e891cf00
--- /dev/null
+++ b/packages/sdk/react-universal/contract-tests/next.config.ts
@@ -0,0 +1,7 @@
+import type { NextConfig } from 'next';
+
+const nextConfig: NextConfig = {
+ /* config options here */
+};
+
+export default nextConfig;
diff --git a/packages/sdk/react-universal/contract-tests/open-browser.mjs b/packages/sdk/react-universal/contract-tests/open-browser.mjs
new file mode 100644
index 000000000..8a3825576
--- /dev/null
+++ b/packages/sdk/react-universal/contract-tests/open-browser.mjs
@@ -0,0 +1,42 @@
+#!/usr/bin/env node
+
+/**
+ * Opens a headless browser and navigates to the contract test entity page.
+ * Keeps the browser open until the process is terminated.
+ *
+ * Usage: node open-browser.mjs [url]
+ * Default URL: http://localhost:8002
+ */
+
+import { chromium } from 'playwright';
+
+const url = process.argv[2] || 'http://localhost:8002';
+
+console.log(`Opening headless browser at ${url}...`);
+
+const browser = await chromium.launch({
+ headless: true,
+ args: ['--no-sandbox', '--disable-setuid-sandbox']
+});
+
+const context = await browser.newContext();
+const page = await context.newPage();
+
+// Log console messages from the browser
+page.on('console', (msg) => {
+ console.log(`[Browser Console] ${msg.type()}: ${msg.text()}`);
+});
+
+// Log page errors
+page.on('pageerror', (error) => {
+ console.error(`[Browser Error] ${error.message}`);
+});
+
+await page.goto(url);
+
+console.log('Browser is open and running. Press Ctrl+C to close.');
+
+// Keep the process alive
+await new Promise(() => {
+ // Intentionally never resolve - keeps browser open until process is killed
+});
diff --git a/packages/sdk/react-universal/contract-tests/package.json b/packages/sdk/react-universal/contract-tests/package.json
new file mode 100644
index 000000000..b4322c844
--- /dev/null
+++ b/packages/sdk/react-universal/contract-tests/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "@launchdarkly/react-sdk-contract-tests",
+ "version": "0.0.0",
+ "private": true,
+ "packageManager": "yarn@3.4.1",
+ "scripts": {
+ "install-playwright-browsers": "playwright install --with-deps chromium",
+ "start:adapter": "yarn workspace browser-contract-test-adapter run start",
+ "dev": "next dev -p 8002",
+ "build": "next build",
+ "start:entity": "next start -p 8002",
+ "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../../.prettierignore"
+ },
+ "dependencies": {
+ "next": "16.1.4",
+ "react": "19.2.3",
+ "react-dom": "19.2.3"
+ },
+ "devDependencies": {
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "eslint": "^8.45.0",
+ "eslint-config-airbnb-base": "^15.0.0",
+ "eslint-config-airbnb-typescript": "^17.1.0",
+ "eslint-config-prettier": "^8.8.0",
+ "eslint-plugin-import": "^2.27.5",
+ "eslint-plugin-jest": "^27.6.3",
+ "eslint-plugin-prettier": "^5.0.0",
+ "playwright": "^1.49.1",
+ "prettier": "^3.0.0",
+ "typescript": "^5"
+ }
+}
diff --git a/packages/sdk/react-universal/contract-tests/run-test-service.sh b/packages/sdk/react-universal/contract-tests/run-test-service.sh
new file mode 100755
index 000000000..9512f8aac
--- /dev/null
+++ b/packages/sdk/react-universal/contract-tests/run-test-service.sh
@@ -0,0 +1 @@
+yarn workspace @launchdarkly/react-sdk-contract-tests run start:adapter & yarn workspace @launchdarkly/react-sdk-contract-tests run start:entity && kill $!
diff --git a/packages/sdk/react-universal/contract-tests/tsconfig.json b/packages/sdk/react-universal/contract-tests/tsconfig.json
new file mode 100644
index 000000000..3a13f90a7
--- /dev/null
+++ b/packages/sdk/react-universal/contract-tests/tsconfig.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts",
+ "**/*.mts"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/sdk/react-universal/tsconfig.json b/packages/sdk/react-universal/tsconfig.json
index 402ebe28f..2cb55fe2d 100644
--- a/packages/sdk/react-universal/tsconfig.json
+++ b/packages/sdk/react-universal/tsconfig.json
@@ -20,5 +20,5 @@
"types": ["jest", "node", "react/canary"],
"jsx": "react"
},
- "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__", "example"]
+ "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__", "example", "contract-tests"]
}