Skip to content
Open
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
19 changes: 17 additions & 2 deletions .github/workflows/node-client.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
strategy:
matrix:
# Node versions to run on.
version: [18, 22]
version: [20, 22]

steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
Expand All @@ -31,4 +31,19 @@ jobs:
with:
workspace_name: '@launchdarkly/node-client-sdk'
workspace_path: packages/sdk/node-client
# TODO: Add contract tests
- name: Install contract test service dependencies
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: '1'
run: yarn workspace @launchdarkly/node-client-sdk-contract-tests install --no-immutable
- name: Build shared contract test utils
run: yarn workspace @launchdarkly/js-contract-test-utils build:client
- name: Build the test service
run: yarn workspace @launchdarkly/node-client-sdk-contract-tests build
- name: Launch the test service in the background
run: yarn workspace @launchdarkly/node-client-sdk-contract-tests start 2>&1 &
- name: Run contract tests (FDv1)
uses: launchdarkly/gh-actions/actions/contract-tests@5adb11fd6953e1bc35d9cf1fc1b4374c464e3a8b # contract-tests-v1.3.0
with:
test_service_port: 8000
token: ${{ secrets.GITHUB_TOKEN }}
extra_params: '--skip-from=${{ github.workspace }}/packages/sdk/node-client/contract-tests/testharness-suppressions.txt'
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"packages/sdk/cloudflare": "2.7.25",
"packages/sdk/combined-browser": "0.1.27",
"packages/sdk/fastly": "0.2.15",
"packages/sdk/node-client": "0.0.4",
"packages/sdk/node-client": "0.0.1",
"packages/sdk/react-native": "10.19.0",
"packages/sdk/server-ai": "1.1.0",
"packages/sdk/server-node": "9.11.2",
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
"packages/sdk/electron/example",
"packages/sdk/electron/contract-tests/entity",
"packages/sdk/node-client",
"packages/sdk/node-client/examples/hello-node-client",
"packages/sdk/node-client/contract-tests",
"packages/sdk/fastly",
"packages/sdk/fastly/example",
"packages/sdk/react",
Expand Down
41 changes: 41 additions & 0 deletions packages/sdk/node-client/contract-tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Node Client SDK Contract Tests

This directory contains the contract test implementation for the LaunchDarkly Client-Side SDK for Node.js using the [SDK Test Harness](https://github.com/launchdarkly/sdk-test-harness).

The contract test service is an Express server that exposes a REST API on port 8000. The test harness sends commands to this service, which creates and manages SDK client instances and executes flag evaluations, events, and other operations.

## Running locally

From the SDK package directory (`packages/sdk/node-client`):

```bash
yarn contract-tests
```

This builds the SDK and the contract-test service, starts the service in the background on port 8000, downloads the matching `sdk-test-harness` binary, and runs the harness against the service. The harness shuts the service down when it finishes via `-stop-service-at-end`.

To run the service on its own (e.g. when iterating against a local checkout of `sdk-test-harness`):

```bash
yarn contract-test-service
```

Then run the harness from your local clone in another terminal.

## Suppressions

Two suppression files cover tests that are not yet supported or are known to differ:

- `testharness-suppressions.txt` -- default
- `testharness-suppressions-fdv2.txt` -- when running the harness from the `feat/fdv2` branch

Override the suppressions file by setting the `SUPPRESSIONS` environment variable:

```bash
SUPPRESSIONS=./contract-tests/testharness-suppressions-fdv2.txt yarn contract-tests
```

## Other environment variables

- `TEST_HARNESS_PARAMS` -- extra params appended to the harness command line (e.g. `-run TestName`).
- `VERSION` -- the major version of `sdk-test-harness` to download. Defaults to `v2`.
26 changes: 26 additions & 0 deletions packages/sdk/node-client/contract-tests/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "@launchdarkly/node-client-sdk-contract-tests",
"version": "0.0.0",
"main": "dist/src/index.js",
"scripts": {
"start": "node --inspect dist/src/index.js",
"build": "tsc",
"dev": "tsc --watch"
},
"type": "module",
"author": "",
"license": "Apache-2.0",
"private": true,
"dependencies": {
"@launchdarkly/js-contract-test-utils": "workspace:^",
"@launchdarkly/node-client-sdk": "workspace:^",
"body-parser": "^1.19.0",
"express": "^4.17.1"
},
"devDependencies": {
"@types/body-parser": "^1.19.2",
"@types/express": "^4.17.13",
"@types/node": "^18.11.9",
"typescript": "^5.5.3"
}
}
33 changes: 33 additions & 0 deletions packages/sdk/node-client/contract-tests/run-contract-tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/bin/sh
# Runs the SDK contract tests locally against a fresh build of @launchdarkly/node-client-sdk.
#
# Mirrors the GitHub Actions workflow at .github/workflows/node-client.yml: builds the SDK
# and its contract-test service, starts the service in the background, downloads the matching
# sdk-test-harness binary, and runs the harness against the service.
#
# Environment variables:
# SUPPRESSIONS Path to the suppressions file to pass via --skip-from. Defaults to
# ./testharness-suppressions.txt (next to this script). Use
# ./testharness-suppressions-fdv2.txt when running the harness from the
# feat/fdv2 branch.
# TEST_HARNESS_PARAMS Extra params appended to the harness command line.
# VERSION sdk-test-harness major version to download. Defaults to v2.

set -e

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SUPPRESSIONS="${SUPPRESSIONS:-$SCRIPT_DIR/testharness-suppressions.txt}"
VERSION="${VERSION:-v2}"

yarn workspaces foreach -pR --topological-dev --from '@launchdarkly/node-client-sdk' run build
yarn workspace @launchdarkly/js-contract-test-utils build:client
yarn workspace @launchdarkly/node-client-sdk-contract-tests build
Comment thread
cursor[bot] marked this conversation as resolved.

yarn workspace @launchdarkly/node-client-sdk-contract-tests start &
SERVICE_PID=$!
trap 'kill $SERVICE_PID 2>/dev/null || true' EXIT

curl -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/main/downloader/run.sh \
| VERSION="$VERSION" \
PARAMS="-url http://localhost:8000 -debug -stop-service-at-end --skip-from=$SUPPRESSIONS $TEST_HARNESS_PARAMS" \
sh
122 changes: 122 additions & 0 deletions packages/sdk/node-client/contract-tests/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import bodyParser from 'body-parser';
import express, { Request, Response } from 'express';
import { Server } from 'http';

import { ClientPool } from '@launchdarkly/js-contract-test-utils';

import { Log } from './log.js';
import { badCommandError, newSdkClientEntity, SdkClientEntity } from './sdkClientEntity.js';

const app = express();
let server: Server | null = null;

const port = 8000;

const clients = new ClientPool<SdkClientEntity>();

const mainLog = Log('service');

app.use(bodyParser.json());

app.get('/', (req: Request, res: Response) => {
res.header('Content-Type', 'application/json');
res.json({
capabilities: [
'client-side',
'service-endpoints',
'tags',
'user-type',
'inline-context',
'inline-context-all',
'client-prereq-events',
'client-per-context-summaries',
'evaluation-hooks',
'track-hooks',
'anonymous-redaction',
'strongly-typed',
'event-gzip',
'flag-change-listeners',
'tls:skip-verify-peer',
'tls:custom-ca',
'wrapper',
],
});
});

app.delete('/', (req: Request, res: Response) => {
mainLog.info('Test service has told us to exit');
res.status(204);
res.send();

// Defer the following actions till after the response has been sent
setTimeout(() => {
if (server) {
server.close(() => process.exit());
}
// We force-quit with process.exit because, even after closing the server, there could be some
// scheduled tasks lingering if an SDK instance didn't get cleaned up properly.
}, 1);
});

app.post('/', async (req: Request, res: Response) => {
const options = req.body;

try {
const client = await newSdkClientEntity(options);
const clientId = clients.add(client);

res.status(201);
res.set('Location', `/clients/${clientId}`);
} catch (e) {
res.status(500);
const message = e instanceof Error ? e.message : JSON.stringify(e);
mainLog.error(`Error creating client: ${message}`);
res.write(message);
}
res.send();
});

app.post('/clients/:id', async (req: Request, res: Response) => {
const client = clients.get(req.params.id);
if (!client) {
res.status(404);
} else {
try {
const respValue = await client.doCommand(req.body);
if (respValue) {
res.status(200);
res.write(JSON.stringify(respValue));
} else {
res.status(204);
}
} catch (e) {
const isBadRequest = e === badCommandError;
res.status(isBadRequest ? 400 : 500);
const message = e instanceof Error ? e.message : JSON.stringify(e);
res.write(message);
if (!isBadRequest && e instanceof Error && e.stack) {
// eslint-disable-next-line no-console
console.log(e.stack);
}
}
}
res.send();
});

app.delete('/clients/:id', async (req: Request, res: Response) => {
const client = clients.get(req.params.id);
if (!client) {
res.status(404);
res.send();
} else {
await client.close();
clients.remove(req.params.id);
res.status(204);
res.send();
}
});

server = app.listen(port, () => {
// eslint-disable-next-line no-console
console.log('Listening on port %d', port);
});
27 changes: 27 additions & 0 deletions packages/sdk/node-client/contract-tests/src/log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { basicLogger, LDLogger } from '@launchdarkly/node-client-sdk';

export interface Logger {
info: (message: string) => void;
error: (message: string) => void;
}

export function Log(tag: string): Logger {
function doLog(level: string, message: string): void {
// eslint-disable-next-line no-console
console.log(`${new Date().toISOString()} [${tag}] ${level}: ${message}`);
}
return {
info: (message: string) => doLog('info', message),
error: (message: string) => doLog('error', message),
};
}

export function sdkLogger(tag: string): LDLogger {
return basicLogger({
level: 'debug',
destination: (line: string) => {
// eslint-disable-next-line no-console
console.log(`${new Date().toISOString()} [${tag}.sdk] ${line}`);
},
});
}
Loading
Loading