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"] }