From 62adbbd44e5669bd8287a51995aed623f1406b11 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:53:42 +0000 Subject: [PATCH 01/19] feat: create shared contract test utilities package (SDK-1866) Create @launchdarkly/js-contract-test-utils package under packages/shared/contract-test-utils/ to eliminate code duplication across JavaScript SDK contract test implementations. Changes: - Extract CommandParams, ConfigParams, makeLogger into shared types - Create client-side TestHook (fetch-based) and TestHarnessWebSocket - Create server-side TestHook (got-based) and generic ClientPool - Migrate browser contract tests to use shared package - Migrate server-node contract tests to use shared package - Migrate shopify-oxygen contract tests to use shared package - Register workspace in root package.json Co-Authored-By: Steven Zhang --- package.json | 3 +- .../contract-tests/entity/package.json | 3 +- .../contract-tests/entity/src/ClientEntity.ts | 14 ++- .../entity/src/TestHarnessWebSocket.ts | 114 +++--------------- .../server-node/contract-tests/package.json | 1 + .../contract-tests/src/sdkClientEntity.ts | 2 +- .../contract-tests/package.json | 1 + .../contract-tests/src/utils/clientPool.ts | 39 ++---- .../shared/contract-test-utils/package.json | 22 ++++ .../src/client-side/TestHarnessWebSocket.ts | 100 +++++++++++++++ .../src/client-side}/TestHook.ts | 0 .../shared/contract-test-utils/src/index.ts | 36 ++++++ .../src/logging}/makeLogger.ts | 2 +- .../src/server-side/ClientPool.ts | 60 +++++++++ .../src/server-side}/TestHook.ts | 0 .../src/types}/CommandParams.ts | 2 +- .../src/types}/ConfigParams.ts | 2 +- .../shared/contract-test-utils/tsconfig.json | 25 ++++ 18 files changed, 292 insertions(+), 134 deletions(-) create mode 100644 packages/shared/contract-test-utils/package.json create mode 100644 packages/shared/contract-test-utils/src/client-side/TestHarnessWebSocket.ts rename packages/{sdk/browser/contract-tests/entity/src => shared/contract-test-utils/src/client-side}/TestHook.ts (100%) create mode 100644 packages/shared/contract-test-utils/src/index.ts rename packages/{sdk/browser/contract-tests/entity/src => shared/contract-test-utils/src/logging}/makeLogger.ts (93%) create mode 100644 packages/shared/contract-test-utils/src/server-side/ClientPool.ts rename packages/{sdk/server-node/contract-tests/src => shared/contract-test-utils/src/server-side}/TestHook.ts (100%) rename packages/{sdk/browser/contract-tests/entity/src => shared/contract-test-utils/src/types}/CommandParams.ts (99%) rename packages/{sdk/browser/contract-tests/entity/src => shared/contract-test-utils/src/types}/ConfigParams.ts (97%) create mode 100644 packages/shared/contract-test-utils/tsconfig.json diff --git a/package.json b/package.json index e9d729393a..cbac111351 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,8 @@ "packages/sdk/shopify-oxygen", "packages/sdk/shopify-oxygen/contract-tests", "packages/sdk/shopify-oxygen/example", - "packages/sdk/browser/example" + "packages/sdk/browser/example", + "packages/shared/contract-test-utils" ], "private": true, "scripts": { diff --git a/packages/sdk/browser/contract-tests/entity/package.json b/packages/sdk/browser/contract-tests/entity/package.json index 6994e63bb1..d2123889ff 100644 --- a/packages/sdk/browser/contract-tests/entity/package.json +++ b/packages/sdk/browser/contract-tests/entity/package.json @@ -12,7 +12,8 @@ "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../../.prettierignore" }, "dependencies": { - "@launchdarkly/js-client-sdk": "*" + "@launchdarkly/js-client-sdk": "*", + "@launchdarkly/js-contract-test-utils": "workspace:^" }, "devDependencies": { "@trivago/prettier-plugin-sort-imports": "^4.1.1", diff --git a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts index 0d50a90bb8..121138a237 100644 --- a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts @@ -1,9 +1,13 @@ import { createClient, LDClient, LDLogger, LDOptions } from '@launchdarkly/js-client-sdk'; - -import { CommandParams, CommandType, ValueType } from './CommandParams'; -import { CreateInstanceParams, SDKConfigParams } from './ConfigParams'; -import { makeLogger } from './makeLogger'; -import TestHook from './TestHook'; +import { + CommandParams, + CommandType, + CreateInstanceParams, + makeLogger, + SDKConfigParams, + ClientSideTestHook as TestHook, + ValueType, +} from '@launchdarkly/js-contract-test-utils'; export const badCommandError = new Error('unsupported command'); export const malformedCommand = new Error('command was malformed'); diff --git a/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts b/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts index 86ba4cf6ac..b8eba8d147 100644 --- a/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts +++ b/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts @@ -1,96 +1,22 @@ -import { LDLogger } from '@launchdarkly/js-client-sdk'; - -import { ClientEntity, newSdkClientEntity } from './ClientEntity'; -import { makeLogger } from './makeLogger'; - -export default class TestHarnessWebSocket { - private _ws?: WebSocket; - private readonly _entities: Record = {}; - private _clientCounter = 0; - private _logger: LDLogger = makeLogger('TestHarnessWebSocket'); - - constructor(private readonly _url: string) {} - - connect() { - this._logger.info(`Connecting to web socket.`); - this._ws = new WebSocket(this._url, ['v1']); - this._ws.onopen = () => { - this._logger.info('Connected to websocket.'); - }; - this._ws.onclose = () => { - this._logger.info('Websocket closed. Attempting to reconnect in 1 second.'); - setTimeout(() => { - this.connect(); - }, 1000); - }; - this._ws.onerror = (err) => { - this._logger.info(`error:`, err); - }; - - this._ws.onmessage = async (msg) => { - this._logger.info('Test harness message', msg); - const data = JSON.parse(msg.data); - const resData: any = { reqId: data.reqId }; - 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': - { - resData.resourceUrl = `/clients/${this._clientCounter}`; - resData.status = 201; - const entity = await newSdkClientEntity(data.body); - this._entities[this._clientCounter] = entity; - this._clientCounter += 1; - } - break; - case 'runCommand': - if (Object.prototype.hasOwnProperty.call(this._entities, data.id)) { - const entity = this._entities[data.id]; - const body = await entity.doCommand(data.body); - resData.body = body; - resData.status = body ? 200 : 204; - } else { - resData.status = 404; - this._logger.warn(`Client did not exist: ${data.id}`); - } - - break; - case 'deleteClient': - if (Object.prototype.hasOwnProperty.call(this._entities, data.id)) { - const entity = this._entities[data.id]; - entity.close(); - delete this._entities[data.id]; - } else { - resData.status = 404; - this._logger.warn(`Could not delete client because it did not exist: ${data.id}`); - } - break; - default: - break; - } - - this.send(resData); - }; - } - - disconnect() { - this._ws?.close(); - } - - send(data: unknown) { - this._ws?.send(JSON.stringify(data)); +import { TestHarnessWebSocket as SharedTestHarnessWebSocket } from '@launchdarkly/js-contract-test-utils'; + +import { newSdkClientEntity } from './ClientEntity'; + +const CAPABILITIES = [ + 'client-side', + 'service-endpoints', + 'tags', + 'user-type', + 'inline-context-all', + 'anonymous-redaction', + 'strongly-typed', + 'client-prereq-events', + 'client-per-context-summaries', + 'track-hooks', +]; + +export default class TestHarnessWebSocket extends SharedTestHarnessWebSocket { + constructor(url: string) { + super(url, CAPABILITIES, newSdkClientEntity); } } diff --git a/packages/sdk/server-node/contract-tests/package.json b/packages/sdk/server-node/contract-tests/package.json index 2b00e78f3f..061721a5b6 100644 --- a/packages/sdk/server-node/contract-tests/package.json +++ b/packages/sdk/server-node/contract-tests/package.json @@ -12,6 +12,7 @@ "license": "Apache-2.0", "private": true, "dependencies": { + "@launchdarkly/js-contract-test-utils": "workspace:^", "@launchdarkly/node-server-sdk": "workspace:^", "body-parser": "^1.19.0", "express": "^4.17.1", diff --git a/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts b/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts index 2c061f05f3..e51742ce01 100644 --- a/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts +++ b/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts @@ -1,5 +1,6 @@ import got from 'got'; +import { ServerSideTestHook as TestHook } from '@launchdarkly/js-contract-test-utils'; import ld, { createMigration, DataSourceOptions, @@ -20,7 +21,6 @@ import ld, { import BigSegmentTestStore from './BigSegmentTestStore.js'; import { Log, sdkLogger } from './log.js'; -import TestHook from './TestHook.js'; const badCommandError = new Error('unsupported command'); export { badCommandError }; diff --git a/packages/sdk/shopify-oxygen/contract-tests/package.json b/packages/sdk/shopify-oxygen/contract-tests/package.json index 30d72f702e..f2124f2020 100644 --- a/packages/sdk/shopify-oxygen/contract-tests/package.json +++ b/packages/sdk/shopify-oxygen/contract-tests/package.json @@ -11,6 +11,7 @@ "license": "Apache-2.0", "private": true, "dependencies": { + "@launchdarkly/js-contract-test-utils": "workspace:^", "@launchdarkly/js-server-sdk-common": "workspace:^", "@launchdarkly/shopify-oxygen-sdk": "workspace:^", "express": "^5.0.1" diff --git a/packages/sdk/shopify-oxygen/contract-tests/src/utils/clientPool.ts b/packages/sdk/shopify-oxygen/contract-tests/src/utils/clientPool.ts index f509631aee..bf61f76b5f 100644 --- a/packages/sdk/shopify-oxygen/contract-tests/src/utils/clientPool.ts +++ b/packages/sdk/shopify-oxygen/contract-tests/src/utils/clientPool.ts @@ -1,42 +1,23 @@ import { Response } from 'express'; +import { ClientPool as GenericClientPool } from '@launchdarkly/js-contract-test-utils'; import { LDClient } from '@launchdarkly/js-server-sdk-common'; import { init } from '@launchdarkly/shopify-oxygen-sdk'; /* eslint-disable no-console */ -// NOTE: Currently, this is a very simple client pool that only really handles the -// very limited Oxygen specific use cases... we should be expand this to be more -// general purpose in the future and maybe even come up with some shared ts interface -// to facilitate future contract testing. - -// TODO: currently this class will handle the response sending as well, which may technically -// sit outside the scope of what it SHOULD be doing. We should refactor this to be more -// general purpose and allow the caller to handle the response sending. - /** - * ClientPool is a singleton that manages a pool of LDClient instances. Currently there is - * no separation between a managed client and this pool. Which means all of the client specs - * will be implemented in this class. + * ClientPool manages a pool of LDClient instances for Shopify Oxygen contract tests. + * It uses the shared generic ClientPool for client storage and ID generation, and + * handles the Oxygen-specific client creation, command execution, and response sending. * * @see https://github.com/launchdarkly/sdk-test-harness/blob/v2/docs/service_spec.md */ export default class ClientPool { - private _clients: Record = {}; - private _clientCounter = 0; - - constructor() { - this._clients = {}; - this._clientCounter = 0; - } - - private _makeId(): string { - this._clientCounter += 1; - return `client-${this._clientCounter}`; - } + private _pool = new GenericClientPool(); public async runCommand(id: string, body: any, res: Response): Promise { - const client = this._clients[id]; + const client = this._pool.get(id); // TODO: handle the 'itCanFailCase' if (client) { try { @@ -69,10 +50,10 @@ export default class ClientPool { } public async deleteClient(id: string, res: Response): Promise { - const client = this._clients[id]; + const client = this._pool.get(id); if (client) { client.close(); - delete this._clients[id]; + this._pool.remove(id); res.status(204); res.send(); } else { @@ -83,7 +64,7 @@ export default class ClientPool { public async createClient(options: any, res: Response): Promise { try { - const id = this._makeId(); + const id = this._pool.nextId(); const { configuration: { credential = 'unknown-sdk-key', polling }, } = options; @@ -101,7 +82,7 @@ export default class ClientPool { }); await client.waitForInitialization({ timeout: 10 }); - this._clients[id] = client; + this._pool.add(id, client); res.status(201); res.set('Location', `/clients/${id}`); if (!client.initialized()) { diff --git a/packages/shared/contract-test-utils/package.json b/packages/shared/contract-test-utils/package.json new file mode 100644 index 0000000000..fda76de0b3 --- /dev/null +++ b/packages/shared/contract-test-utils/package.json @@ -0,0 +1,22 @@ +{ + "name": "@launchdarkly/js-contract-test-utils", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "scripts": { + "build": "tsc", + "clean": "rimraf dist" + }, + "dependencies": { + "@launchdarkly/js-client-sdk": "workspace:^", + "@launchdarkly/js-sdk-common": "workspace:^", + "@launchdarkly/node-server-sdk": "workspace:^", + "got": "14.4.7" + }, + "devDependencies": { + "@types/node": "^18.11.9", + "typescript": "^4.9.0" + } +} diff --git a/packages/shared/contract-test-utils/src/client-side/TestHarnessWebSocket.ts b/packages/shared/contract-test-utils/src/client-side/TestHarnessWebSocket.ts new file mode 100644 index 0000000000..51ccd7e39b --- /dev/null +++ b/packages/shared/contract-test-utils/src/client-side/TestHarnessWebSocket.ts @@ -0,0 +1,100 @@ +import { LDLogger } from '@launchdarkly/js-sdk-common'; + +import { makeLogger } from '../logging/makeLogger.js'; + +export interface ClientEntity { + close: () => void; + doCommand: (params: any) => Promise; +} + +export type CreateClientEntityFn = (options: any) => Promise; + +export default class TestHarnessWebSocket { + private _ws?: WebSocket; + private readonly _entities: Record = {}; + private _clientCounter = 0; + private _logger: LDLogger = makeLogger('TestHarnessWebSocket'); + private _capabilities: string[]; + private _createClientEntity: CreateClientEntityFn; + + constructor( + private readonly _url: string, + capabilities: string[], + createClientEntity: CreateClientEntityFn, + ) { + this._capabilities = capabilities; + this._createClientEntity = createClientEntity; + } + + connect() { + this._logger.info(`Connecting to web socket.`); + this._ws = new WebSocket(this._url, ['v1']); + this._ws.onopen = () => { + this._logger.info('Connected to websocket.'); + }; + this._ws.onclose = () => { + this._logger.info('Websocket closed. Attempting to reconnect in 1 second.'); + setTimeout(() => { + this.connect(); + }, 1000); + }; + this._ws.onerror = (err) => { + this._logger.info(`error:`, err); + }; + + this._ws.onmessage = async (msg) => { + this._logger.info('Test harness message', msg); + const data = JSON.parse(msg.data); + const resData: any = { reqId: data.reqId }; + switch (data.command) { + case 'getCapabilities': + resData.capabilities = this._capabilities; + + break; + case 'createClient': + { + resData.resourceUrl = `/clients/${this._clientCounter}`; + resData.status = 201; + const entity = await this._createClientEntity(data.body); + this._entities[this._clientCounter] = entity; + this._clientCounter += 1; + } + break; + case 'runCommand': + if (Object.prototype.hasOwnProperty.call(this._entities, data.id)) { + const entity = this._entities[data.id]; + const body = await entity.doCommand(data.body); + resData.body = body; + resData.status = body ? 200 : 204; + } else { + resData.status = 404; + this._logger.warn(`Client did not exist: ${data.id}`); + } + + break; + case 'deleteClient': + if (Object.prototype.hasOwnProperty.call(this._entities, data.id)) { + const entity = this._entities[data.id]; + entity.close(); + delete this._entities[data.id]; + } else { + resData.status = 404; + this._logger.warn(`Could not delete client because it did not exist: ${data.id}`); + } + break; + default: + break; + } + + this.send(resData); + }; + } + + disconnect() { + this._ws?.close(); + } + + send(data: unknown) { + this._ws?.send(JSON.stringify(data)); + } +} diff --git a/packages/sdk/browser/contract-tests/entity/src/TestHook.ts b/packages/shared/contract-test-utils/src/client-side/TestHook.ts similarity index 100% rename from packages/sdk/browser/contract-tests/entity/src/TestHook.ts rename to packages/shared/contract-test-utils/src/client-side/TestHook.ts diff --git a/packages/shared/contract-test-utils/src/index.ts b/packages/shared/contract-test-utils/src/index.ts new file mode 100644 index 0000000000..bcb082e346 --- /dev/null +++ b/packages/shared/contract-test-utils/src/index.ts @@ -0,0 +1,36 @@ +// Universal exports (no SDK dependency) +export * from './types/CommandParams.js'; +export { + type CreateInstanceParams, + type SDKConfigParams, + type SDKConfigTLSParams, + type SDKConfigServiceEndpointsParams, + type SDKConfigStreamingParams, + type SDKConfigPollingParams, + type SDKConfigEventParams, + type SDKConfigTagsParams, + type SDKConfigClientSideParams, + type SDKConfigEvaluationHookData, + type SDKConfigHookInstance, + type SDKConfigHooksParams, + type SDKConfigWrapper, + type HookStage as ConfigHookStage, +} from './types/ConfigParams.js'; +export { makeLogger } from './logging/makeLogger.js'; + +// Client-side exports +export { default as ClientSideTestHook } from './client-side/TestHook.js'; +export type { + HookData as ClientSideHookData, + HookErrors as ClientSideHookErrors, +} from './client-side/TestHook.js'; +export { default as TestHarnessWebSocket } from './client-side/TestHarnessWebSocket.js'; +export type { ClientEntity, CreateClientEntityFn } from './client-side/TestHarnessWebSocket.js'; + +// Server-side exports +export { default as ServerSideTestHook } from './server-side/TestHook.js'; +export type { + HookData as ServerSideHookData, + HookErrors as ServerSideHookErrors, +} from './server-side/TestHook.js'; +export { ClientPool } from './server-side/ClientPool.js'; diff --git a/packages/sdk/browser/contract-tests/entity/src/makeLogger.ts b/packages/shared/contract-test-utils/src/logging/makeLogger.ts similarity index 93% rename from packages/sdk/browser/contract-tests/entity/src/makeLogger.ts rename to packages/shared/contract-test-utils/src/logging/makeLogger.ts index a8cf9f165d..2657d884e9 100644 --- a/packages/sdk/browser/contract-tests/entity/src/makeLogger.ts +++ b/packages/shared/contract-test-utils/src/logging/makeLogger.ts @@ -1,4 +1,4 @@ -import { LDLogger } from '@launchdarkly/js-client-sdk'; +import { LDLogger } from '@launchdarkly/js-sdk-common'; export function makeLogger(tag: string): LDLogger { return { diff --git a/packages/shared/contract-test-utils/src/server-side/ClientPool.ts b/packages/shared/contract-test-utils/src/server-side/ClientPool.ts new file mode 100644 index 0000000000..864bb90ce8 --- /dev/null +++ b/packages/shared/contract-test-utils/src/server-side/ClientPool.ts @@ -0,0 +1,60 @@ +/** + * ClientPool is a generic pool that manages a collection of client entities. + * It provides methods to add, retrieve, remove, and generate IDs for clients. + * + * This eliminates duplication of the Record + counter pattern + * used across multiple contract test implementations. + */ +export class ClientPool { + private _clients: Record = {}; + private _clientCounter = 0; + + /** + * Generate a new unique client ID and increment the counter. + * @returns A new unique string ID for the client. + */ + nextId(): string { + this._clientCounter += 1; + return this._clientCounter.toString(); + } + + /** + * Add a client entity to the pool with the given ID. + * @param id - The unique identifier for the client. + * @param client - The client entity to store. + */ + add(id: string, client: T): void { + this._clients[id] = client; + } + + /** + * Retrieve a client entity by its ID. + * @param id - The unique identifier for the client. + * @returns The client entity, or undefined if not found. + */ + get(id: string): T | undefined { + return this._clients[id]; + } + + /** + * Check if a client entity exists in the pool. + * @param id - The unique identifier for the client. + * @returns True if the client exists. + */ + has(id: string): boolean { + return Object.prototype.hasOwnProperty.call(this._clients, id); + } + + /** + * Remove a client entity from the pool by its ID. + * @param id - The unique identifier for the client. + * @returns True if the client was removed, false if it did not exist. + */ + remove(id: string): boolean { + if (this.has(id)) { + delete this._clients[id]; + return true; + } + return false; + } +} diff --git a/packages/sdk/server-node/contract-tests/src/TestHook.ts b/packages/shared/contract-test-utils/src/server-side/TestHook.ts similarity index 100% rename from packages/sdk/server-node/contract-tests/src/TestHook.ts rename to packages/shared/contract-test-utils/src/server-side/TestHook.ts diff --git a/packages/sdk/browser/contract-tests/entity/src/CommandParams.ts b/packages/shared/contract-test-utils/src/types/CommandParams.ts similarity index 99% rename from packages/sdk/browser/contract-tests/entity/src/CommandParams.ts rename to packages/shared/contract-test-utils/src/types/CommandParams.ts index 11251d31ed..667d8713f5 100644 --- a/packages/sdk/browser/contract-tests/entity/src/CommandParams.ts +++ b/packages/shared/contract-test-utils/src/types/CommandParams.ts @@ -1,4 +1,4 @@ -import { LDContext, LDEvaluationReason } from '@launchdarkly/js-client-sdk'; +import { LDContext, LDEvaluationReason } from '@launchdarkly/js-sdk-common'; export enum CommandType { EvaluateFlag = 'evaluate', diff --git a/packages/sdk/browser/contract-tests/entity/src/ConfigParams.ts b/packages/shared/contract-test-utils/src/types/ConfigParams.ts similarity index 97% rename from packages/sdk/browser/contract-tests/entity/src/ConfigParams.ts rename to packages/shared/contract-test-utils/src/types/ConfigParams.ts index 520170e82c..a752ff7836 100644 --- a/packages/sdk/browser/contract-tests/entity/src/ConfigParams.ts +++ b/packages/shared/contract-test-utils/src/types/ConfigParams.ts @@ -1,4 +1,4 @@ -import { LDContext } from '@launchdarkly/js-client-sdk'; +import { LDContext } from '@launchdarkly/js-sdk-common'; export interface CreateInstanceParams { configuration: SDKConfigParams; diff --git a/packages/shared/contract-test-utils/tsconfig.json b/packages/shared/contract-test-utils/tsconfig.json new file mode 100644 index 0000000000..02fedf96b4 --- /dev/null +++ b/packages/shared/contract-test-utils/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "rootDir": ".", + "outDir": "dist", + "target": "ES2020", + "lib": ["ES2020", "DOM"], + "module": "ESNext", + "moduleResolution": "node", + "strict": true, + "noImplicitOverride": true, + "allowSyntheticDefaultImports": true, + "sourceMap": true, + "declaration": true, + "declarationMap": true, + "stripInternal": true, + "esModuleInterop": true, + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "got": ["../../../node_modules/got/dist/source"] + } + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules"] +} From d5fedb935964d8746aa109a3034b0e06e96ebf0b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:02:05 +0000 Subject: [PATCH 02/19] fix: resolve CI failures - point to source files and fix import paths - Change package.json main/types/exports to point to src/index.ts instead of dist/ so consumers can resolve the package without a separate build step - Change imports from @launchdarkly/js-sdk-common to @launchdarkly/js-client-sdk which has proper exports for both bundler and node16 module resolution - Remove @launchdarkly/js-sdk-common from dependencies (no longer needed directly) Co-Authored-By: Steven Zhang --- packages/shared/contract-test-utils/package.json | 12 +++++++++--- .../src/client-side/TestHarnessWebSocket.ts | 2 +- .../contract-test-utils/src/logging/makeLogger.ts | 2 +- .../contract-test-utils/src/types/CommandParams.ts | 2 +- .../contract-test-utils/src/types/ConfigParams.ts | 2 +- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/shared/contract-test-utils/package.json b/packages/shared/contract-test-utils/package.json index fda76de0b3..d29d2a6e5e 100644 --- a/packages/shared/contract-test-utils/package.json +++ b/packages/shared/contract-test-utils/package.json @@ -3,15 +3,21 @@ "version": "0.0.0", "private": true, "type": "module", - "main": "./dist/src/index.js", - "types": "./dist/src/index.d.ts", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts", + "default": "./src/index.ts" + } + }, "scripts": { "build": "tsc", "clean": "rimraf dist" }, "dependencies": { "@launchdarkly/js-client-sdk": "workspace:^", - "@launchdarkly/js-sdk-common": "workspace:^", "@launchdarkly/node-server-sdk": "workspace:^", "got": "14.4.7" }, diff --git a/packages/shared/contract-test-utils/src/client-side/TestHarnessWebSocket.ts b/packages/shared/contract-test-utils/src/client-side/TestHarnessWebSocket.ts index 51ccd7e39b..59d218a5de 100644 --- a/packages/shared/contract-test-utils/src/client-side/TestHarnessWebSocket.ts +++ b/packages/shared/contract-test-utils/src/client-side/TestHarnessWebSocket.ts @@ -1,4 +1,4 @@ -import { LDLogger } from '@launchdarkly/js-sdk-common'; +import { LDLogger } from '@launchdarkly/js-client-sdk'; import { makeLogger } from '../logging/makeLogger.js'; diff --git a/packages/shared/contract-test-utils/src/logging/makeLogger.ts b/packages/shared/contract-test-utils/src/logging/makeLogger.ts index 2657d884e9..a8cf9f165d 100644 --- a/packages/shared/contract-test-utils/src/logging/makeLogger.ts +++ b/packages/shared/contract-test-utils/src/logging/makeLogger.ts @@ -1,4 +1,4 @@ -import { LDLogger } from '@launchdarkly/js-sdk-common'; +import { LDLogger } from '@launchdarkly/js-client-sdk'; export function makeLogger(tag: string): LDLogger { return { diff --git a/packages/shared/contract-test-utils/src/types/CommandParams.ts b/packages/shared/contract-test-utils/src/types/CommandParams.ts index 667d8713f5..11251d31ed 100644 --- a/packages/shared/contract-test-utils/src/types/CommandParams.ts +++ b/packages/shared/contract-test-utils/src/types/CommandParams.ts @@ -1,4 +1,4 @@ -import { LDContext, LDEvaluationReason } from '@launchdarkly/js-sdk-common'; +import { LDContext, LDEvaluationReason } from '@launchdarkly/js-client-sdk'; export enum CommandType { EvaluateFlag = 'evaluate', diff --git a/packages/shared/contract-test-utils/src/types/ConfigParams.ts b/packages/shared/contract-test-utils/src/types/ConfigParams.ts index a752ff7836..520170e82c 100644 --- a/packages/shared/contract-test-utils/src/types/ConfigParams.ts +++ b/packages/shared/contract-test-utils/src/types/ConfigParams.ts @@ -1,4 +1,4 @@ -import { LDContext } from '@launchdarkly/js-sdk-common'; +import { LDContext } from '@launchdarkly/js-client-sdk'; export interface CreateInstanceParams { configuration: SDKConfigParams; From d16c527892924d5692216ee2a0f05c958a70bc54 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:09:34 +0000 Subject: [PATCH 03/19] fix: split into subpath exports to avoid cross-dependency resolution - Create separate entry points: /client and /server subpaths - Move ClientPool to universal exports (zero external dependencies) - Browser imports from /client (no node-server-sdk dependency) - Server-node imports from /server (includes got + node-server-sdk) - Shopify-oxygen imports ClientPool from base path - Add typesVersions for moduleResolution: node compatibility Co-Authored-By: Steven Zhang --- .../contract-tests/entity/src/ClientEntity.ts | 2 +- .../entity/src/TestHarnessWebSocket.ts | 2 +- .../contract-tests/src/sdkClientEntity.ts | 2 +- packages/shared/contract-test-utils/package.json | 16 ++++++++++++++++ .../shared/contract-test-utils/src/client.ts | 11 +++++++++++ packages/shared/contract-test-utils/src/index.ts | 16 ---------------- .../shared/contract-test-utils/src/server.ts | 10 ++++++++++ 7 files changed, 40 insertions(+), 19 deletions(-) create mode 100644 packages/shared/contract-test-utils/src/client.ts create mode 100644 packages/shared/contract-test-utils/src/server.ts diff --git a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts index 121138a237..da8165a9af 100644 --- a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts @@ -7,7 +7,7 @@ import { SDKConfigParams, ClientSideTestHook as TestHook, ValueType, -} from '@launchdarkly/js-contract-test-utils'; +} from '@launchdarkly/js-contract-test-utils/client'; export const badCommandError = new Error('unsupported command'); export const malformedCommand = new Error('command was malformed'); diff --git a/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts b/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts index b8eba8d147..82bb577a9e 100644 --- a/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts +++ b/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts @@ -1,4 +1,4 @@ -import { TestHarnessWebSocket as SharedTestHarnessWebSocket } from '@launchdarkly/js-contract-test-utils'; +import { TestHarnessWebSocket as SharedTestHarnessWebSocket } from '@launchdarkly/js-contract-test-utils/client'; import { newSdkClientEntity } from './ClientEntity'; diff --git a/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts b/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts index e51742ce01..406f7b1a86 100644 --- a/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts +++ b/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts @@ -1,6 +1,6 @@ import got from 'got'; -import { ServerSideTestHook as TestHook } from '@launchdarkly/js-contract-test-utils'; +import { ServerSideTestHook as TestHook } from '@launchdarkly/js-contract-test-utils/server'; import ld, { createMigration, DataSourceOptions, diff --git a/packages/shared/contract-test-utils/package.json b/packages/shared/contract-test-utils/package.json index d29d2a6e5e..f328549475 100644 --- a/packages/shared/contract-test-utils/package.json +++ b/packages/shared/contract-test-utils/package.json @@ -10,6 +10,22 @@ "types": "./src/index.ts", "import": "./src/index.ts", "default": "./src/index.ts" + }, + "./client": { + "types": "./src/client.ts", + "import": "./src/client.ts", + "default": "./src/client.ts" + }, + "./server": { + "types": "./src/server.ts", + "import": "./src/server.ts", + "default": "./src/server.ts" + } + }, + "typesVersions": { + "*": { + "client": ["./src/client.ts"], + "server": ["./src/server.ts"] } }, "scripts": { diff --git a/packages/shared/contract-test-utils/src/client.ts b/packages/shared/contract-test-utils/src/client.ts new file mode 100644 index 0000000000..183362f372 --- /dev/null +++ b/packages/shared/contract-test-utils/src/client.ts @@ -0,0 +1,11 @@ +// Re-export universal exports +export * from './index.js'; + +// Client-side exports +export { default as ClientSideTestHook } from './client-side/TestHook.js'; +export type { + HookData as ClientSideHookData, + HookErrors as ClientSideHookErrors, +} from './client-side/TestHook.js'; +export { default as TestHarnessWebSocket } from './client-side/TestHarnessWebSocket.js'; +export type { ClientEntity, CreateClientEntityFn } from './client-side/TestHarnessWebSocket.js'; diff --git a/packages/shared/contract-test-utils/src/index.ts b/packages/shared/contract-test-utils/src/index.ts index bcb082e346..ff63582d0b 100644 --- a/packages/shared/contract-test-utils/src/index.ts +++ b/packages/shared/contract-test-utils/src/index.ts @@ -17,20 +17,4 @@ export { type HookStage as ConfigHookStage, } from './types/ConfigParams.js'; export { makeLogger } from './logging/makeLogger.js'; - -// Client-side exports -export { default as ClientSideTestHook } from './client-side/TestHook.js'; -export type { - HookData as ClientSideHookData, - HookErrors as ClientSideHookErrors, -} from './client-side/TestHook.js'; -export { default as TestHarnessWebSocket } from './client-side/TestHarnessWebSocket.js'; -export type { ClientEntity, CreateClientEntityFn } from './client-side/TestHarnessWebSocket.js'; - -// Server-side exports -export { default as ServerSideTestHook } from './server-side/TestHook.js'; -export type { - HookData as ServerSideHookData, - HookErrors as ServerSideHookErrors, -} from './server-side/TestHook.js'; export { ClientPool } from './server-side/ClientPool.js'; diff --git a/packages/shared/contract-test-utils/src/server.ts b/packages/shared/contract-test-utils/src/server.ts new file mode 100644 index 0000000000..6ab12a347a --- /dev/null +++ b/packages/shared/contract-test-utils/src/server.ts @@ -0,0 +1,10 @@ +// Re-export universal exports +export * from './index.js'; + +// Server-side exports +export { default as ServerSideTestHook } from './server-side/TestHook.js'; +export type { + HookData as ServerSideHookData, + HookErrors as ServerSideHookErrors, +} from './server-side/TestHook.js'; +export { ClientPool } from './server-side/ClientPool.js'; From 8b8163de9fdd690f971f70e16c1ef977e51323e5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:20:48 +0000 Subject: [PATCH 04/19] refactor: move contract-test-utils to packages/tooling/ and use local compat types - Move package from packages/shared/contract-test-utils/ to packages/tooling/contract-test-utils/ - Replace SDK-specific type imports (LDContext, LDLogger, LDEvaluationReason) with local compat types - Remove duplicate ClientPool export from server.ts - Update root workspace entry Co-Authored-By: Steven Zhang --- package.json | 2 +- .../contract-test-utils/package.json | 8 +++-- .../src/client-side/TestHarnessWebSocket.ts | 2 +- .../src/client-side/TestHook.ts | 0 .../contract-test-utils/src/client.ts | 0 .../contract-test-utils/src/index.ts | 0 .../src/logging/makeLogger.ts | 2 +- .../src/server-side/ClientPool.ts | 0 .../src/server-side/TestHook.ts | 0 .../contract-test-utils/src/server.ts | 3 +- .../src/types/CommandParams.ts | 2 +- .../src/types/ConfigParams.ts | 2 +- .../contract-test-utils/src/types/compat.ts | 32 +++++++++++++++++++ .../contract-test-utils/tsconfig.json | 0 14 files changed, 44 insertions(+), 9 deletions(-) rename packages/{shared => tooling}/contract-test-utils/package.json (89%) rename packages/{shared => tooling}/contract-test-utils/src/client-side/TestHarnessWebSocket.ts (98%) rename packages/{shared => tooling}/contract-test-utils/src/client-side/TestHook.ts (100%) rename packages/{shared => tooling}/contract-test-utils/src/client.ts (100%) rename packages/{shared => tooling}/contract-test-utils/src/index.ts (100%) rename packages/{shared => tooling}/contract-test-utils/src/logging/makeLogger.ts (93%) rename packages/{shared => tooling}/contract-test-utils/src/server-side/ClientPool.ts (100%) rename packages/{shared => tooling}/contract-test-utils/src/server-side/TestHook.ts (100%) rename packages/{shared => tooling}/contract-test-utils/src/server.ts (73%) rename packages/{shared => tooling}/contract-test-utils/src/types/CommandParams.ts (97%) rename packages/{shared => tooling}/contract-test-utils/src/types/ConfigParams.ts (97%) create mode 100644 packages/tooling/contract-test-utils/src/types/compat.ts rename packages/{shared => tooling}/contract-test-utils/tsconfig.json (100%) diff --git a/package.json b/package.json index cbac111351..a155fc97ba 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "packages/sdk/shopify-oxygen/contract-tests", "packages/sdk/shopify-oxygen/example", "packages/sdk/browser/example", - "packages/shared/contract-test-utils" + "packages/tooling/contract-test-utils" ], "private": true, "scripts": { diff --git a/packages/shared/contract-test-utils/package.json b/packages/tooling/contract-test-utils/package.json similarity index 89% rename from packages/shared/contract-test-utils/package.json rename to packages/tooling/contract-test-utils/package.json index f328549475..700c4408de 100644 --- a/packages/shared/contract-test-utils/package.json +++ b/packages/tooling/contract-test-utils/package.json @@ -24,8 +24,12 @@ }, "typesVersions": { "*": { - "client": ["./src/client.ts"], - "server": ["./src/server.ts"] + "client": [ + "./src/client.ts" + ], + "server": [ + "./src/server.ts" + ] } }, "scripts": { diff --git a/packages/shared/contract-test-utils/src/client-side/TestHarnessWebSocket.ts b/packages/tooling/contract-test-utils/src/client-side/TestHarnessWebSocket.ts similarity index 98% rename from packages/shared/contract-test-utils/src/client-side/TestHarnessWebSocket.ts rename to packages/tooling/contract-test-utils/src/client-side/TestHarnessWebSocket.ts index 59d218a5de..4d9f0fedc1 100644 --- a/packages/shared/contract-test-utils/src/client-side/TestHarnessWebSocket.ts +++ b/packages/tooling/contract-test-utils/src/client-side/TestHarnessWebSocket.ts @@ -1,4 +1,4 @@ -import { LDLogger } from '@launchdarkly/js-client-sdk'; +import { LDLogger } from '../types/compat.js'; import { makeLogger } from '../logging/makeLogger.js'; diff --git a/packages/shared/contract-test-utils/src/client-side/TestHook.ts b/packages/tooling/contract-test-utils/src/client-side/TestHook.ts similarity index 100% rename from packages/shared/contract-test-utils/src/client-side/TestHook.ts rename to packages/tooling/contract-test-utils/src/client-side/TestHook.ts diff --git a/packages/shared/contract-test-utils/src/client.ts b/packages/tooling/contract-test-utils/src/client.ts similarity index 100% rename from packages/shared/contract-test-utils/src/client.ts rename to packages/tooling/contract-test-utils/src/client.ts diff --git a/packages/shared/contract-test-utils/src/index.ts b/packages/tooling/contract-test-utils/src/index.ts similarity index 100% rename from packages/shared/contract-test-utils/src/index.ts rename to packages/tooling/contract-test-utils/src/index.ts diff --git a/packages/shared/contract-test-utils/src/logging/makeLogger.ts b/packages/tooling/contract-test-utils/src/logging/makeLogger.ts similarity index 93% rename from packages/shared/contract-test-utils/src/logging/makeLogger.ts rename to packages/tooling/contract-test-utils/src/logging/makeLogger.ts index a8cf9f165d..65954ad83f 100644 --- a/packages/shared/contract-test-utils/src/logging/makeLogger.ts +++ b/packages/tooling/contract-test-utils/src/logging/makeLogger.ts @@ -1,4 +1,4 @@ -import { LDLogger } from '@launchdarkly/js-client-sdk'; +import { LDLogger } from '../types/compat.js'; export function makeLogger(tag: string): LDLogger { return { diff --git a/packages/shared/contract-test-utils/src/server-side/ClientPool.ts b/packages/tooling/contract-test-utils/src/server-side/ClientPool.ts similarity index 100% rename from packages/shared/contract-test-utils/src/server-side/ClientPool.ts rename to packages/tooling/contract-test-utils/src/server-side/ClientPool.ts diff --git a/packages/shared/contract-test-utils/src/server-side/TestHook.ts b/packages/tooling/contract-test-utils/src/server-side/TestHook.ts similarity index 100% rename from packages/shared/contract-test-utils/src/server-side/TestHook.ts rename to packages/tooling/contract-test-utils/src/server-side/TestHook.ts diff --git a/packages/shared/contract-test-utils/src/server.ts b/packages/tooling/contract-test-utils/src/server.ts similarity index 73% rename from packages/shared/contract-test-utils/src/server.ts rename to packages/tooling/contract-test-utils/src/server.ts index 6ab12a347a..3b2a7c9893 100644 --- a/packages/shared/contract-test-utils/src/server.ts +++ b/packages/tooling/contract-test-utils/src/server.ts @@ -1,4 +1,4 @@ -// Re-export universal exports +// Re-export universal exports (includes ClientPool) export * from './index.js'; // Server-side exports @@ -7,4 +7,3 @@ export type { HookData as ServerSideHookData, HookErrors as ServerSideHookErrors, } from './server-side/TestHook.js'; -export { ClientPool } from './server-side/ClientPool.js'; diff --git a/packages/shared/contract-test-utils/src/types/CommandParams.ts b/packages/tooling/contract-test-utils/src/types/CommandParams.ts similarity index 97% rename from packages/shared/contract-test-utils/src/types/CommandParams.ts rename to packages/tooling/contract-test-utils/src/types/CommandParams.ts index 11251d31ed..04dba0194b 100644 --- a/packages/shared/contract-test-utils/src/types/CommandParams.ts +++ b/packages/tooling/contract-test-utils/src/types/CommandParams.ts @@ -1,4 +1,4 @@ -import { LDContext, LDEvaluationReason } from '@launchdarkly/js-client-sdk'; +import { LDContext, LDEvaluationReason } from './compat.js'; export enum CommandType { EvaluateFlag = 'evaluate', diff --git a/packages/shared/contract-test-utils/src/types/ConfigParams.ts b/packages/tooling/contract-test-utils/src/types/ConfigParams.ts similarity index 97% rename from packages/shared/contract-test-utils/src/types/ConfigParams.ts rename to packages/tooling/contract-test-utils/src/types/ConfigParams.ts index 520170e82c..1d42f448be 100644 --- a/packages/shared/contract-test-utils/src/types/ConfigParams.ts +++ b/packages/tooling/contract-test-utils/src/types/ConfigParams.ts @@ -1,4 +1,4 @@ -import { LDContext } from '@launchdarkly/js-client-sdk'; +import { LDContext } from './compat.js'; export interface CreateInstanceParams { configuration: SDKConfigParams; diff --git a/packages/tooling/contract-test-utils/src/types/compat.ts b/packages/tooling/contract-test-utils/src/types/compat.ts new file mode 100644 index 0000000000..687e8b0f20 --- /dev/null +++ b/packages/tooling/contract-test-utils/src/types/compat.ts @@ -0,0 +1,32 @@ +/** + * Minimal type definitions for contract test utilities. + * + * These are compatible with the corresponding types from both + * @launchdarkly/js-client-sdk and @launchdarkly/node-server-sdk, + * allowing the shared package to work without depending on either SDK directly. + */ + +/** + * A minimal LDContext type compatible with both client and server SDKs. + * Contract test harness passes context objects through without deep inspection. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type LDContext = Record; + +/** + * A minimal LDLogger interface compatible with both client and server SDKs. + */ +export interface LDLogger { + debug(...args: any[]): void; + info(...args: any[]): void; + warn(...args: any[]): void; + error(...args: any[]): void; +} + +/** + * A minimal LDEvaluationReason type compatible with both client and server SDKs. + */ +export interface LDEvaluationReason { + kind: string; + [key: string]: unknown; +} diff --git a/packages/shared/contract-test-utils/tsconfig.json b/packages/tooling/contract-test-utils/tsconfig.json similarity index 100% rename from packages/shared/contract-test-utils/tsconfig.json rename to packages/tooling/contract-test-utils/tsconfig.json From 987b0c2b4e0fd95e8c89ae31d3f65f9c5ee0cba0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:30:52 +0000 Subject: [PATCH 05/19] fix: compile shared package to dist/ and build before contract tests - Update exports to point to compiled dist/ files for runtime compatibility - Change tsconfig rootDir to 'src' for clean dist output - Add shared package build step to all contract test build scripts - Fixes ERR_MODULE_NOT_FOUND at runtime in server-node and shopify-oxygen Co-Authored-By: Steven Zhang --- .../contract-tests/entity/package.json | 2 +- .../server-node/contract-tests/package.json | 2 +- .../contract-tests/package.json | 2 +- .../tooling/contract-test-utils/package.json | 26 +++++++++---------- .../tooling/contract-test-utils/tsconfig.json | 2 +- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/sdk/browser/contract-tests/entity/package.json b/packages/sdk/browser/contract-tests/entity/package.json index d2123889ff..cce38bde91 100644 --- a/packages/sdk/browser/contract-tests/entity/package.json +++ b/packages/sdk/browser/contract-tests/entity/package.json @@ -7,7 +7,7 @@ "scripts": { "install-playwright-browsers": "playwright install --with-deps chromium", "start": "tsc --noEmit && vite --open=true", - "build": "tsc --noEmit && vite build", + "build": "yarn workspace @launchdarkly/js-contract-test-utils build && tsc --noEmit && vite build", "lint": "eslint ./src", "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../../.prettierignore" }, diff --git a/packages/sdk/server-node/contract-tests/package.json b/packages/sdk/server-node/contract-tests/package.json index 061721a5b6..1ad327368b 100644 --- a/packages/sdk/server-node/contract-tests/package.json +++ b/packages/sdk/server-node/contract-tests/package.json @@ -4,7 +4,7 @@ "main": "dist/src/index.js", "scripts": { "start": "node --inspect dist/src/index.js", - "build": "tsc", + "build": "yarn workspace @launchdarkly/js-contract-test-utils build && tsc", "dev": "tsc --watch" }, "type": "module", diff --git a/packages/sdk/shopify-oxygen/contract-tests/package.json b/packages/sdk/shopify-oxygen/contract-tests/package.json index f2124f2020..930757ed68 100644 --- a/packages/sdk/shopify-oxygen/contract-tests/package.json +++ b/packages/sdk/shopify-oxygen/contract-tests/package.json @@ -4,7 +4,7 @@ "main": "dist/index.js", "scripts": { "start": "node --inspect dist/index.js", - "build": "tsup", + "build": "yarn workspace @launchdarkly/js-contract-test-utils build && tsup", "dev": "tsc --watch" }, "type": "module", diff --git a/packages/tooling/contract-test-utils/package.json b/packages/tooling/contract-test-utils/package.json index 700c4408de..654ed029e7 100644 --- a/packages/tooling/contract-test-utils/package.json +++ b/packages/tooling/contract-test-utils/package.json @@ -3,32 +3,32 @@ "version": "0.0.0", "private": true, "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "exports": { ".": { - "types": "./src/index.ts", - "import": "./src/index.ts", - "default": "./src/index.ts" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" }, "./client": { - "types": "./src/client.ts", - "import": "./src/client.ts", - "default": "./src/client.ts" + "types": "./dist/client.d.ts", + "import": "./dist/client.js", + "default": "./dist/client.js" }, "./server": { - "types": "./src/server.ts", - "import": "./src/server.ts", - "default": "./src/server.ts" + "types": "./dist/server.d.ts", + "import": "./dist/server.js", + "default": "./dist/server.js" } }, "typesVersions": { "*": { "client": [ - "./src/client.ts" + "./dist/client.d.ts" ], "server": [ - "./src/server.ts" + "./dist/server.d.ts" ] } }, diff --git a/packages/tooling/contract-test-utils/tsconfig.json b/packages/tooling/contract-test-utils/tsconfig.json index 02fedf96b4..3c47238f16 100644 --- a/packages/tooling/contract-test-utils/tsconfig.json +++ b/packages/tooling/contract-test-utils/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "rootDir": ".", + "rootDir": "src", "outDir": "dist", "target": "ES2020", "lib": ["ES2020", "DOM"], From bb68d02f9e68c0178a76d5d8d933281abeeccee6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:37:29 +0000 Subject: [PATCH 06/19] fix: split exports - source for client/base, compiled for server subpath - Client/base exports point to .ts source (Vite/tsup handle TS natively) - Server export points to compiled dist/ (Node.js runtime needs .js) - Add tsconfig.server.json to compile only server-relevant files - Server-node build uses build:server to avoid compiling client code - Browser/shopify-oxygen don't need shared package pre-built (bundlers handle TS) Co-Authored-By: Steven Zhang --- .../contract-tests/entity/package.json | 2 +- .../server-node/contract-tests/package.json | 2 +- .../contract-tests/package.json | 2 +- .../tooling/contract-test-utils/package.json | 19 ++++++++++--------- .../contract-test-utils/tsconfig.server.json | 5 +++++ 5 files changed, 18 insertions(+), 12 deletions(-) create mode 100644 packages/tooling/contract-test-utils/tsconfig.server.json diff --git a/packages/sdk/browser/contract-tests/entity/package.json b/packages/sdk/browser/contract-tests/entity/package.json index cce38bde91..d2123889ff 100644 --- a/packages/sdk/browser/contract-tests/entity/package.json +++ b/packages/sdk/browser/contract-tests/entity/package.json @@ -7,7 +7,7 @@ "scripts": { "install-playwright-browsers": "playwright install --with-deps chromium", "start": "tsc --noEmit && vite --open=true", - "build": "yarn workspace @launchdarkly/js-contract-test-utils build && tsc --noEmit && vite build", + "build": "tsc --noEmit && vite build", "lint": "eslint ./src", "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../../.prettierignore" }, diff --git a/packages/sdk/server-node/contract-tests/package.json b/packages/sdk/server-node/contract-tests/package.json index 1ad327368b..a239d4c665 100644 --- a/packages/sdk/server-node/contract-tests/package.json +++ b/packages/sdk/server-node/contract-tests/package.json @@ -4,7 +4,7 @@ "main": "dist/src/index.js", "scripts": { "start": "node --inspect dist/src/index.js", - "build": "yarn workspace @launchdarkly/js-contract-test-utils build && tsc", + "build": "yarn workspace @launchdarkly/js-contract-test-utils build:server && tsc", "dev": "tsc --watch" }, "type": "module", diff --git a/packages/sdk/shopify-oxygen/contract-tests/package.json b/packages/sdk/shopify-oxygen/contract-tests/package.json index 930757ed68..f2124f2020 100644 --- a/packages/sdk/shopify-oxygen/contract-tests/package.json +++ b/packages/sdk/shopify-oxygen/contract-tests/package.json @@ -4,7 +4,7 @@ "main": "dist/index.js", "scripts": { "start": "node --inspect dist/index.js", - "build": "yarn workspace @launchdarkly/js-contract-test-utils build && tsup", + "build": "tsup", "dev": "tsc --watch" }, "type": "module", diff --git a/packages/tooling/contract-test-utils/package.json b/packages/tooling/contract-test-utils/package.json index 654ed029e7..fb82d31a6c 100644 --- a/packages/tooling/contract-test-utils/package.json +++ b/packages/tooling/contract-test-utils/package.json @@ -3,18 +3,18 @@ "version": "0.0.0", "private": true, "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", + "main": "./src/index.ts", + "types": "./src/index.ts", "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "default": "./dist/index.js" + "types": "./src/index.ts", + "import": "./src/index.ts", + "default": "./src/index.ts" }, "./client": { - "types": "./dist/client.d.ts", - "import": "./dist/client.js", - "default": "./dist/client.js" + "types": "./src/client.ts", + "import": "./src/client.ts", + "default": "./src/client.ts" }, "./server": { "types": "./dist/server.d.ts", @@ -25,7 +25,7 @@ "typesVersions": { "*": { "client": [ - "./dist/client.d.ts" + "./src/client.ts" ], "server": [ "./dist/server.d.ts" @@ -34,6 +34,7 @@ }, "scripts": { "build": "tsc", + "build:server": "tsc -p tsconfig.server.json", "clean": "rimraf dist" }, "dependencies": { diff --git a/packages/tooling/contract-test-utils/tsconfig.server.json b/packages/tooling/contract-test-utils/tsconfig.server.json new file mode 100644 index 0000000000..8b221e028d --- /dev/null +++ b/packages/tooling/contract-test-utils/tsconfig.server.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "exclude": ["src/client-side/**", "src/client.ts", "dist", "node_modules"] +} From fd38015078f5bd04d5abb347acdb8eb6167b0766 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:45:24 +0000 Subject: [PATCH 07/19] fix: inline shared package in shopify-oxygen tsup bundle Add noExternal for @launchdarkly/js-contract-test-utils so tsup bundles the shared package code instead of keeping it as an external import. This is needed because the base export points to .ts source files which Node.js cannot execute at runtime. Co-Authored-By: Steven Zhang --- packages/sdk/shopify-oxygen/contract-tests/tsup.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/sdk/shopify-oxygen/contract-tests/tsup.config.ts b/packages/sdk/shopify-oxygen/contract-tests/tsup.config.ts index 0758f21c40..300be1bd30 100644 --- a/packages/sdk/shopify-oxygen/contract-tests/tsup.config.ts +++ b/packages/sdk/shopify-oxygen/contract-tests/tsup.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ entry: { index: 'src/index.ts', }, + noExternal: ['@launchdarkly/js-contract-test-utils'], minify: true, format: ['esm', 'cjs'], splitting: false, From d04d7ae1e264f100bede03f79df0a4d42196df14 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:53:42 +0000 Subject: [PATCH 08/19] feat: migrate electron and react-native contract tests to shared package - Delete local CommandParams, ConfigParams, makeLogger, TestHook from electron contract tests - Delete local CommandParams, ConfigParams, makeLogger, TestHook, TestHarnessWebSocket from react-native contract tests - Update electron ClientEntity and ClientFactory to import from shared package - Update react-native ClientEntity and App.tsx to import from shared package - Change shared TestHook import from js-client-sdk to js-client-sdk-common for cross-SDK compatibility - Add intentionalClose and onConnectionChange support to shared TestHarnessWebSocket - Add SDKConfigProxyParams to shared ConfigParams for electron proxy support - Add @launchdarkly/js-contract-test-utils dependency to both electron and react-native package.json Co-Authored-By: Steven Zhang --- .../contract-tests/entity/package.json | 1 + .../contract-tests/entity/src/ClientEntity.ts | 13 +- .../entity/src/ClientFactory.ts | 4 +- .../entity/src/CommandParams.ts | 158 ------------------ .../contract-tests/entity/src/ConfigParams.ts | 96 ----------- .../contract-tests/entity/src/TestHook.ts | 95 ----------- .../contract-tests/entity/src/makeLogger.ts | 22 --- .../contract-tests/entity/App.tsx | 25 ++- .../contract-tests/entity/package.json | 1 + .../contract-tests/entity/src/ClientEntity.ts | 13 +- .../entity/src/CommandParams.ts | 157 ----------------- .../contract-tests/entity/src/ConfigParams.ts | 90 ---------- .../entity/src/TestHarnessWebSocket.ts | 118 ------------- .../contract-tests/entity/src/TestHook.ts | 94 ----------- .../contract-tests/entity/src/makeLogger.ts | 22 --- .../tooling/contract-test-utils/package.json | 2 +- .../src/client-side/TestHarnessWebSocket.ts | 34 +++- .../src/client-side/TestHook.ts | 2 +- .../tooling/contract-test-utils/src/index.ts | 1 + .../src/types/ConfigParams.ts | 5 + 20 files changed, 79 insertions(+), 874 deletions(-) delete mode 100644 packages/sdk/electron/contract-tests/entity/src/CommandParams.ts delete mode 100644 packages/sdk/electron/contract-tests/entity/src/ConfigParams.ts delete mode 100644 packages/sdk/electron/contract-tests/entity/src/TestHook.ts delete mode 100644 packages/sdk/electron/contract-tests/entity/src/makeLogger.ts delete mode 100644 packages/sdk/react-native/contract-tests/entity/src/CommandParams.ts delete mode 100644 packages/sdk/react-native/contract-tests/entity/src/ConfigParams.ts delete mode 100644 packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts delete mode 100644 packages/sdk/react-native/contract-tests/entity/src/TestHook.ts delete mode 100644 packages/sdk/react-native/contract-tests/entity/src/makeLogger.ts diff --git a/packages/sdk/electron/contract-tests/entity/package.json b/packages/sdk/electron/contract-tests/entity/package.json index ee86de0614..422b9f1820 100644 --- a/packages/sdk/electron/contract-tests/entity/package.json +++ b/packages/sdk/electron/contract-tests/entity/package.json @@ -32,6 +32,7 @@ "vite": "^5.4.21" }, "dependencies": { + "@launchdarkly/js-contract-test-utils": "workspace:^", "body-parser": "^2.2.2", "electron-squirrel-startup": "^1.0.1", "express": "^5.2.1" diff --git a/packages/sdk/electron/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/electron/contract-tests/entity/src/ClientEntity.ts index 23e22af963..061fcc0c43 100644 --- a/packages/sdk/electron/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/electron/contract-tests/entity/src/ClientEntity.ts @@ -7,10 +7,15 @@ import path from 'node:path'; // eslint-disable-next-line import/no-extraneous-dependencies import { createClient, LDClient, LDLogger, LDOptions } from '@launchdarkly/electron-client-sdk'; -import { CommandParams, CommandType, ValueType } from './CommandParams'; -import { CreateInstanceParams, SDKConfigParams } from './ConfigParams'; -import { makeLogger } from './makeLogger'; -import TestHook from './TestHook'; +import { + CommandParams, + CommandType, + CreateInstanceParams, + makeLogger, + SDKConfigParams, + ValueType, +} from '@launchdarkly/js-contract-test-utils'; +import { ClientSideTestHook as TestHook } from '@launchdarkly/js-contract-test-utils/client'; export const badCommandError = new Error('unsupported command'); export const malformedCommand = new Error('command was malformed'); diff --git a/packages/sdk/electron/contract-tests/entity/src/ClientFactory.ts b/packages/sdk/electron/contract-tests/entity/src/ClientFactory.ts index ed5158d9e7..f0965b00d7 100644 --- a/packages/sdk/electron/contract-tests/entity/src/ClientFactory.ts +++ b/packages/sdk/electron/contract-tests/entity/src/ClientFactory.ts @@ -1,6 +1,6 @@ +import { CommandParams, CreateInstanceParams } from '@launchdarkly/js-contract-test-utils'; + import { ClientEntity, createEntity } from './ClientEntity'; -import { CommandParams } from './CommandParams'; -import { CreateInstanceParams } from './ConfigParams'; export default class ClientFactory { private _clientCounter = 0; diff --git a/packages/sdk/electron/contract-tests/entity/src/CommandParams.ts b/packages/sdk/electron/contract-tests/entity/src/CommandParams.ts deleted file mode 100644 index c5ed0f70e9..0000000000 --- a/packages/sdk/electron/contract-tests/entity/src/CommandParams.ts +++ /dev/null @@ -1,158 +0,0 @@ -// eslint-disable-next-line import/no-extraneous-dependencies -import { LDContext, LDEvaluationReason } from '@launchdarkly/electron-client-sdk'; - -export enum CommandType { - EvaluateFlag = 'evaluate', - EvaluateAllFlags = 'evaluateAll', - IdentifyEvent = 'identifyEvent', - CustomEvent = 'customEvent', - AliasEvent = 'aliasEvent', - FlushEvents = 'flushEvents', - ContextBuild = 'contextBuild', - ContextConvert = 'contextConvert', - ContextComparison = 'contextComparison', - SecureModeHash = 'secureModeHash', -} - -export enum ValueType { - Bool = 'bool', - Int = 'int', - Double = 'double', - String = 'string', - Any = 'any', -} - -export interface CommandParams { - command: CommandType; - evaluate?: EvaluateFlagParams; - evaluateAll?: EvaluateAllFlagsParams; - customEvent?: CustomEventParams; - identifyEvent?: IdentifyEventParams; - contextBuild?: ContextBuildParams; - contextConvert?: ContextConvertParams; - contextComparison?: ContextComparisonPairParams; - secureModeHash?: SecureModeHashParams; -} - -export interface EvaluateFlagParams { - flagKey: string; - context?: LDContext; - user?: any; - valueType: ValueType; - defaultValue: unknown; - detail: boolean; -} - -export interface EvaluateFlagResponse { - value: unknown; - variationIndex?: number; - reason?: LDEvaluationReason; -} - -export interface EvaluateAllFlagsParams { - context?: LDContext; - user?: any; - withReasons: boolean; - clientSideOnly: boolean; - detailsOnlyForTrackedFlags: boolean; -} - -export interface EvaluateAllFlagsResponse { - state: Record; -} - -export interface CustomEventParams { - eventKey: string; - context?: LDContext; - user?: any; - data?: unknown; - omitNullData: boolean; - metricValue?: number; -} - -export interface IdentifyEventParams { - context?: LDContext; - user?: any; -} - -export interface ContextBuildParams { - single?: ContextBuildSingleParams; - multi?: ContextBuildSingleParams[]; -} - -export interface ContextBuildSingleParams { - kind?: string; - key: string; - name?: string; - anonymous?: boolean; - private?: string[]; - custom?: Record; -} - -export interface ContextBuildResponse { - output: string; - error: string; -} - -export interface ContextConvertParams { - input: string; -} - -export interface ContextComparisonPairParams { - context1: ContextComparisonParams; - context2: ContextComparisonParams; -} - -export interface ContextComparisonParams { - single?: ContextComparisonSingleParams; - multi?: ContextComparisonSingleParams[]; -} - -export interface ContextComparisonSingleParams { - kind: string; - key: string; - attributes?: AttributeDefinition[]; - privateAttributes?: PrivateAttribute[]; -} - -export interface AttributeDefinition { - name: string; - value?: unknown; -} - -export interface PrivateAttribute { - value: string; - literal: boolean; -} - -export interface ContextComparisonResponse { - equals: boolean; -} - -export interface SecureModeHashParams { - context?: LDContext; - user?: any; -} - -export interface SecureModeHashResponse { - result: string; -} - -export enum HookStage { - BeforeEvaluation = 'beforeEvaluation', - AfterEvaluation = 'afterEvaluation', -} - -export interface EvaluationSeriesContext { - flagKey: string; - context: LDContext; - defaultValue: unknown; - method: string; -} - -export interface HookExecutionPayload { - evaluationSeriesContext?: EvaluationSeriesContext; - evaluationSeriesData?: Record; - evaluationDetail?: EvaluateFlagResponse; - stage?: HookStage; -} diff --git a/packages/sdk/electron/contract-tests/entity/src/ConfigParams.ts b/packages/sdk/electron/contract-tests/entity/src/ConfigParams.ts deleted file mode 100644 index 73a7917a98..0000000000 --- a/packages/sdk/electron/contract-tests/entity/src/ConfigParams.ts +++ /dev/null @@ -1,96 +0,0 @@ -// eslint-disable-next-line import/no-extraneous-dependencies -import { LDContext } from '@launchdarkly/electron-client-sdk'; - -export interface CreateInstanceParams { - configuration: SDKConfigParams; - tag: string; -} - -export interface SDKConfigParams { - credential: string; - startWaitTimeMs?: number; // UnixMillisecondTime - initCanFail?: boolean; - serviceEndpoints?: SDKConfigServiceEndpointsParams; - tls?: SDKConfigTLSParams; - streaming?: SDKConfigStreamingParams; - polling?: SDKConfigPollingParams; - events?: SDKConfigEventParams; - tags?: SDKConfigTagsParams; - clientSide?: SDKConfigClientSideParams; - hooks?: SDKConfigHooksParams; - wrapper?: SDKConfigWrapper; - proxy?: SDKConfigProxyParams; -} - -export interface SDKConfigTLSParams { - skipVerifyPeer?: boolean; - customCAFile?: string; -} - -export interface SDKConfigServiceEndpointsParams { - streaming?: string; - polling?: string; - events?: string; -} - -export interface SDKConfigStreamingParams { - baseUri?: string; - initialRetryDelayMs?: number; // UnixMillisecondTime - filter?: string; -} - -export interface SDKConfigPollingParams { - baseUri?: string; - pollIntervalMs?: number; // UnixMillisecondTime - filter?: string; -} - -export interface SDKConfigEventParams { - baseUri?: string; - capacity?: number; - enableDiagnostics: boolean; - allAttributesPrivate?: boolean; - globalPrivateAttributes?: string[]; - flushIntervalMs?: number; // UnixMillisecondTime - omitAnonymousContexts?: boolean; - enableGzip?: boolean; -} - -export interface SDKConfigTagsParams { - applicationId?: string; - applicationVersion?: string; -} - -export interface SDKConfigClientSideParams { - initialContext?: LDContext; - initialUser?: any; - evaluationReasons?: boolean; - useReport?: boolean; - includeEnvironmentAttributes?: boolean; -} - -export interface SDKConfigEvaluationHookData { - [key: string]: unknown; -} - -export interface SDKConfigHookInstance { - name: string; - callbackUri: string; - data?: Record; - errors?: Record; -} - -export interface SDKConfigHooksParams { - hooks: SDKConfigHookInstance[]; -} - -export interface SDKConfigProxyParams { - httpProxy?: string; -} - -export interface SDKConfigWrapper { - name: string; - version: string; -} - -export type HookStage = 'beforeEvaluation' | 'afterEvaluation'; diff --git a/packages/sdk/electron/contract-tests/entity/src/TestHook.ts b/packages/sdk/electron/contract-tests/entity/src/TestHook.ts deleted file mode 100644 index 33af83119b..0000000000 --- a/packages/sdk/electron/contract-tests/entity/src/TestHook.ts +++ /dev/null @@ -1,95 +0,0 @@ -// eslint-disable-next-line import/no-extraneous-dependencies -import { - EvaluationSeriesContext, - EvaluationSeriesData, - Hook, - HookMetadata, - LDEvaluationDetail, - TrackSeriesContext, -} from '@launchdarkly/electron-client-sdk'; - -export interface HookData { - beforeEvaluation?: Record; - afterEvaluation?: Record; -} - -export interface HookErrors { - beforeEvaluation?: string; - afterEvaluation?: string; - afterTrack?: string; -} - -export default class TestHook implements Hook { - private _name: string; - private _endpoint: string; - private _data?: HookData; - private _errors?: HookErrors; - - constructor(name: string, endpoint: string, data?: HookData, errors?: HookErrors) { - this._name = name; - this._endpoint = endpoint; - this._data = data; - this._errors = errors; - } - - private async _safePost(body: unknown): Promise { - try { - await fetch(this._endpoint, { - method: 'POST', - body: JSON.stringify(body), - }); - } catch { - // The test could move on before the post, so we are ignoring - // failed posts. - } - } - - getMetadata(): HookMetadata { - return { - name: this._name, - }; - } - - beforeEvaluation( - hookContext: EvaluationSeriesContext, - data: EvaluationSeriesData, - ): EvaluationSeriesData { - if (this._errors?.beforeEvaluation) { - throw new Error(this._errors.beforeEvaluation); - } - this._safePost({ - evaluationSeriesContext: hookContext, - evaluationSeriesData: data, - stage: 'beforeEvaluation', - }); - return { ...data, ...(this._data?.beforeEvaluation || {}) }; - } - - afterEvaluation( - hookContext: EvaluationSeriesContext, - data: EvaluationSeriesData, - detail: LDEvaluationDetail, - ): EvaluationSeriesData { - if (this._errors?.afterEvaluation) { - throw new Error(this._errors.afterEvaluation); - } - this._safePost({ - evaluationSeriesContext: hookContext, - evaluationSeriesData: data, - stage: 'afterEvaluation', - evaluationDetail: detail, - }); - - return { ...data, ...(this._data?.afterEvaluation || {}) }; - } - - afterTrack(hookContext: TrackSeriesContext): void { - if (this._errors?.afterTrack) { - throw new Error(this._errors.afterTrack); - } - this._safePost({ - trackSeriesContext: hookContext, - stage: 'afterTrack', - }); - } -} diff --git a/packages/sdk/electron/contract-tests/entity/src/makeLogger.ts b/packages/sdk/electron/contract-tests/entity/src/makeLogger.ts deleted file mode 100644 index 58292e8e75..0000000000 --- a/packages/sdk/electron/contract-tests/entity/src/makeLogger.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { LDLogger } from '@launchdarkly/electron-client-sdk'; - -export function makeLogger(tag: string): LDLogger { - return { - debug(message: any, ...args: any[]) { - // eslint-disable-next-line no-console - console.debug(`${new Date().toISOString()} [${tag}]: ${message}`, ...args); - }, - info(message: any, ...args: any[]) { - // eslint-disable-next-line no-console - console.info(`${new Date().toISOString()} [${tag}]: ${message}`, ...args); - }, - warn(message: any, ...args: any[]) { - // eslint-disable-next-line no-console - console.warn(`${new Date().toISOString()} [${tag}]: ${message}`, ...args); - }, - error(message: any, ...args: any[]) { - // eslint-disable-next-line no-console - console.error(`${new Date().toISOString()} [${tag}]: ${message}`, ...args); - }, - }; -} diff --git a/packages/sdk/react-native/contract-tests/entity/App.tsx b/packages/sdk/react-native/contract-tests/entity/App.tsx index 0293facee3..12928670ba 100644 --- a/packages/sdk/react-native/contract-tests/entity/App.tsx +++ b/packages/sdk/react-native/contract-tests/entity/App.tsx @@ -1,7 +1,23 @@ import React, { useEffect, useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; -import TestHarnessWebSocket from './src/TestHarnessWebSocket'; +import { TestHarnessWebSocket } from '@launchdarkly/js-contract-test-utils/client'; + +import { newSdkClientEntity } from './src/ClientEntity'; + +const capabilities = [ + 'client-side', + 'mobile', + 'service-endpoints', + 'tags', + 'user-type', + 'inline-context-all', + 'anonymous-redaction', + 'strongly-typed', + 'client-prereq-events', + 'client-per-context-summaries', + 'track-hooks', +]; const styles = StyleSheet.create({ container: { @@ -24,7 +40,12 @@ export default function App() { const [connected, setConnected] = useState(false); useEffect(() => { - const ws = new TestHarnessWebSocket('ws://localhost:8001', setConnected); + const ws = new TestHarnessWebSocket( + 'ws://localhost:8001', + capabilities, + newSdkClientEntity, + setConnected, + ); ws.connect(); return () => ws.disconnect(); }, []); diff --git a/packages/sdk/react-native/contract-tests/entity/package.json b/packages/sdk/react-native/contract-tests/entity/package.json index 38401d25da..5d527273f8 100644 --- a/packages/sdk/react-native/contract-tests/entity/package.json +++ b/packages/sdk/react-native/contract-tests/entity/package.json @@ -11,6 +11,7 @@ "ios": "expo run:ios" }, "dependencies": { + "@launchdarkly/js-contract-test-utils": "workspace:^", "@launchdarkly/react-native-client-sdk": "workspace:^", "@react-native-async-storage/async-storage": "^2.0.0", "expo": "52.0.14", diff --git a/packages/sdk/react-native/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/react-native/contract-tests/entity/src/ClientEntity.ts index 7f2b9ba6ad..bb611d7b4a 100644 --- a/packages/sdk/react-native/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/react-native/contract-tests/entity/src/ClientEntity.ts @@ -4,10 +4,15 @@ import { ReactNativeLDClient, } from '@launchdarkly/react-native-client-sdk'; -import { CommandParams, CommandType, ValueType } from './CommandParams'; -import { CreateInstanceParams, SDKConfigParams } from './ConfigParams'; -import { makeLogger } from './makeLogger'; -import TestHook from './TestHook'; +import { + CommandParams, + CommandType, + CreateInstanceParams, + makeLogger, + SDKConfigParams, + ValueType, +} from '@launchdarkly/js-contract-test-utils'; +import { ClientSideTestHook as TestHook } from '@launchdarkly/js-contract-test-utils/client'; export const badCommandError = new Error('unsupported command'); export const malformedCommand = new Error('command was malformed'); diff --git a/packages/sdk/react-native/contract-tests/entity/src/CommandParams.ts b/packages/sdk/react-native/contract-tests/entity/src/CommandParams.ts deleted file mode 100644 index 55174970f2..0000000000 --- a/packages/sdk/react-native/contract-tests/entity/src/CommandParams.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { LDContext, LDEvaluationReason } from '@launchdarkly/react-native-client-sdk'; - -export enum CommandType { - EvaluateFlag = 'evaluate', - EvaluateAllFlags = 'evaluateAll', - IdentifyEvent = 'identifyEvent', - CustomEvent = 'customEvent', - AliasEvent = 'aliasEvent', - FlushEvents = 'flushEvents', - ContextBuild = 'contextBuild', - ContextConvert = 'contextConvert', - ContextComparison = 'contextComparison', - SecureModeHash = 'secureModeHash', -} - -export enum ValueType { - Bool = 'bool', - Int = 'int', - Double = 'double', - String = 'string', - Any = 'any', -} - -export interface CommandParams { - command: CommandType; - evaluate?: EvaluateFlagParams; - evaluateAll?: EvaluateAllFlagsParams; - customEvent?: CustomEventParams; - identifyEvent?: IdentifyEventParams; - contextBuild?: ContextBuildParams; - contextConvert?: ContextConvertParams; - contextComparison?: ContextComparisonPairParams; - secureModeHash?: SecureModeHashParams; -} - -export interface EvaluateFlagParams { - flagKey: string; - context?: LDContext; - user?: any; - valueType: ValueType; - defaultValue: unknown; - detail: boolean; -} - -export interface EvaluateFlagResponse { - value: unknown; - variationIndex?: number; - reason?: LDEvaluationReason; -} - -export interface EvaluateAllFlagsParams { - context?: LDContext; - user?: any; - withReasons: boolean; - clientSideOnly: boolean; - detailsOnlyForTrackedFlags: boolean; -} - -export interface EvaluateAllFlagsResponse { - state: Record; -} - -export interface CustomEventParams { - eventKey: string; - context?: LDContext; - user?: any; - data?: unknown; - omitNullData: boolean; - metricValue?: number; -} - -export interface IdentifyEventParams { - context?: LDContext; - user?: any; -} - -export interface ContextBuildParams { - single?: ContextBuildSingleParams; - multi?: ContextBuildSingleParams[]; -} - -export interface ContextBuildSingleParams { - kind?: string; - key: string; - name?: string; - anonymous?: boolean; - private?: string[]; - custom?: Record; -} - -export interface ContextBuildResponse { - output: string; - error: string; -} - -export interface ContextConvertParams { - input: string; -} - -export interface ContextComparisonPairParams { - context1: ContextComparisonParams; - context2: ContextComparisonParams; -} - -export interface ContextComparisonParams { - single?: ContextComparisonSingleParams; - multi?: ContextComparisonSingleParams[]; -} - -export interface ContextComparisonSingleParams { - kind: string; - key: string; - attributes?: AttributeDefinition[]; - privateAttributes?: PrivateAttribute[]; -} - -export interface AttributeDefinition { - name: string; - value?: unknown; -} - -export interface PrivateAttribute { - value: string; - literal: boolean; -} - -export interface ContextComparisonResponse { - equals: boolean; -} - -export interface SecureModeHashParams { - context?: LDContext; - user?: any; -} - -export interface SecureModeHashResponse { - result: string; -} - -export enum HookStage { - BeforeEvaluation = 'beforeEvaluation', - AfterEvaluation = 'afterEvaluation', -} - -export interface EvaluationSeriesContext { - flagKey: string; - context: LDContext; - defaultValue: unknown; - method: string; -} - -export interface HookExecutionPayload { - evaluationSeriesContext?: EvaluationSeriesContext; - evaluationSeriesData?: Record; - evaluationDetail?: EvaluateFlagResponse; - stage?: HookStage; -} diff --git a/packages/sdk/react-native/contract-tests/entity/src/ConfigParams.ts b/packages/sdk/react-native/contract-tests/entity/src/ConfigParams.ts deleted file mode 100644 index 7b15ab053a..0000000000 --- a/packages/sdk/react-native/contract-tests/entity/src/ConfigParams.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { LDContext } from '@launchdarkly/react-native-client-sdk'; - -export interface CreateInstanceParams { - configuration: SDKConfigParams; - tag: string; -} - -export interface SDKConfigParams { - credential: string; - startWaitTimeMs?: number; // UnixMillisecondTime - initCanFail?: boolean; - serviceEndpoints?: SDKConfigServiceEndpointsParams; - tls?: SDKConfigTLSParams; - streaming?: SDKConfigStreamingParams; - polling?: SDKConfigPollingParams; - events?: SDKConfigEventParams; - tags?: SDKConfigTagsParams; - clientSide?: SDKConfigClientSideParams; - hooks?: SDKConfigHooksParams; - wrapper?: SDKConfigWrapper; -} - -export interface SDKConfigTLSParams { - skipVerifyPeer?: boolean; - customCAFile?: string; -} - -export interface SDKConfigServiceEndpointsParams { - streaming?: string; - polling?: string; - events?: string; -} - -export interface SDKConfigStreamingParams { - baseUri?: string; - initialRetryDelayMs?: number; // UnixMillisecondTime - filter?: string; -} - -export interface SDKConfigPollingParams { - baseUri?: string; - pollIntervalMs?: number; // UnixMillisecondTime - filter?: string; -} - -export interface SDKConfigEventParams { - baseUri?: string; - capacity?: number; - enableDiagnostics: boolean; - allAttributesPrivate?: boolean; - globalPrivateAttributes?: string[]; - flushIntervalMs?: number; // UnixMillisecondTime - omitAnonymousContexts?: boolean; - enableGzip?: boolean; -} - -export interface SDKConfigTagsParams { - applicationId?: string; - applicationVersion?: string; -} - -export interface SDKConfigClientSideParams { - initialContext?: LDContext; - initialUser?: any; - evaluationReasons?: boolean; - useReport?: boolean; - includeEnvironmentAttributes?: boolean; -} - -export interface SDKConfigEvaluationHookData { - [key: string]: unknown; -} - -export interface SDKConfigHookInstance { - name: string; - callbackUri: string; - data?: Record; - errors?: Record; -} - -export interface SDKConfigHooksParams { - hooks: SDKConfigHookInstance[]; -} - -export interface SDKConfigWrapper { - name: string; - version: string; -} - -export type HookStage = 'beforeEvaluation' | 'afterEvaluation'; diff --git a/packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts b/packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts deleted file mode 100644 index 4bc88606a4..0000000000 --- a/packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { LDLogger } from '@launchdarkly/react-native-client-sdk'; - -import { ClientEntity, newSdkClientEntity } from './ClientEntity'; -import { makeLogger } from './makeLogger'; - -export default class TestHarnessWebSocket { - private _ws?: WebSocket; - private readonly _entities: Record = {}; - private _clientCounter = 0; - private _logger: LDLogger = makeLogger('TestHarnessWebSocket'); - private _intentionalClose = false; - private _onConnectionChange?: (connected: boolean) => void; - - constructor( - private readonly _url: string, - onConnectionChange?: (connected: boolean) => void, - ) { - this._onConnectionChange = onConnectionChange; - } - - connect() { - this._intentionalClose = false; - this._logger.info(`Connecting to web socket.`); - this._ws = new WebSocket(this._url, 'v1'); - this._ws.onopen = () => { - this._logger.info('Connected to websocket.'); - this._onConnectionChange?.(true); - }; - this._ws.onclose = () => { - this._logger.info('Websocket closed. Attempting to reconnect in 1 second.'); - this._onConnectionChange?.(false); - if (!this._intentionalClose) { - setTimeout(() => { - this.connect(); - }, 1000); - } - }; - this._ws.onerror = (err) => { - this._logger.info(`error:`, err); - }; - - this._ws.onmessage = async (msg) => { - this._logger.info('Test harness message', msg); - const data = JSON.parse(msg.data as string); - const resData: any = { reqId: data.reqId }; - switch (data.command) { - case 'getCapabilities': - resData.capabilities = [ - 'client-side', - 'mobile', - 'service-endpoints', - 'tags', - 'user-type', - 'inline-context-all', - 'anonymous-redaction', - 'strongly-typed', - 'client-prereq-events', - 'client-per-context-summaries', - 'track-hooks', - ]; - - break; - case 'createClient': - try { - resData.resourceUrl = `/clients/${this._clientCounter}`; - resData.status = 201; - const entity = await newSdkClientEntity(data.body); - this._entities[this._clientCounter] = entity; - this._clientCounter += 1; - } catch (e: any) { - this._logger.error(`Failed to create client: ${e?.message ?? e}`); - resData.status = 500; - } - break; - case 'runCommand': - if (Object.prototype.hasOwnProperty.call(this._entities, data.id)) { - const entity = this._entities[data.id]; - try { - const body = await entity.doCommand(data.body); - resData.body = body; - resData.status = body ? 200 : 204; - } catch (e: any) { - this._logger.error(`Command failed: ${e?.message ?? e}`); - resData.status = 500; - } - } else { - resData.status = 404; - this._logger.warn(`Client did not exist: ${data.id}`); - } - - break; - case 'deleteClient': - if (Object.prototype.hasOwnProperty.call(this._entities, data.id)) { - const entity = this._entities[data.id]; - entity.close(); - delete this._entities[data.id]; - } else { - resData.status = 404; - this._logger.warn(`Could not delete client because it did not exist: ${data.id}`); - } - break; - default: - break; - } - - this.send(resData); - }; - } - - disconnect() { - this._intentionalClose = true; - this._ws?.close(); - } - - send(data: unknown) { - this._ws?.send(JSON.stringify(data)); - } -} diff --git a/packages/sdk/react-native/contract-tests/entity/src/TestHook.ts b/packages/sdk/react-native/contract-tests/entity/src/TestHook.ts deleted file mode 100644 index 80db5add4e..0000000000 --- a/packages/sdk/react-native/contract-tests/entity/src/TestHook.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { - EvaluationSeriesContext, - EvaluationSeriesData, - Hook, - HookMetadata, - LDEvaluationDetail, - TrackSeriesContext, -} from '@launchdarkly/react-native-client-sdk'; - -export interface HookData { - beforeEvaluation?: Record; - afterEvaluation?: Record; -} - -export interface HookErrors { - beforeEvaluation?: string; - afterEvaluation?: string; - afterTrack?: string; -} - -export default class TestHook implements Hook { - private _name: string; - private _endpoint: string; - private _data?: HookData; - private _errors?: HookErrors; - - constructor(name: string, endpoint: string, data?: HookData, errors?: HookErrors) { - this._name = name; - this._endpoint = endpoint; - this._data = data; - this._errors = errors; - } - - private async _safePost(body: unknown): Promise { - try { - await fetch(this._endpoint, { - method: 'POST', - body: JSON.stringify(body), - }); - } catch { - // The test could move on before the post, so we are ignoring - // failed posts. - } - } - - getMetadata(): HookMetadata { - return { - name: this._name, - }; - } - - beforeEvaluation( - hookContext: EvaluationSeriesContext, - data: EvaluationSeriesData, - ): EvaluationSeriesData { - if (this._errors?.beforeEvaluation) { - throw new Error(this._errors.beforeEvaluation); - } - this._safePost({ - evaluationSeriesContext: hookContext, - evaluationSeriesData: data, - stage: 'beforeEvaluation', - }); - return { ...data, ...(this._data?.beforeEvaluation || {}) }; - } - - afterEvaluation( - hookContext: EvaluationSeriesContext, - data: EvaluationSeriesData, - detail: LDEvaluationDetail, - ): EvaluationSeriesData { - if (this._errors?.afterEvaluation) { - throw new Error(this._errors.afterEvaluation); - } - this._safePost({ - evaluationSeriesContext: hookContext, - evaluationSeriesData: data, - stage: 'afterEvaluation', - evaluationDetail: detail, - }); - - return { ...data, ...(this._data?.afterEvaluation || {}) }; - } - - afterTrack(hookContext: TrackSeriesContext): void { - if (this._errors?.afterTrack) { - throw new Error(this._errors.afterTrack); - } - this._safePost({ - trackSeriesContext: hookContext, - stage: 'afterTrack', - }); - } -} diff --git a/packages/sdk/react-native/contract-tests/entity/src/makeLogger.ts b/packages/sdk/react-native/contract-tests/entity/src/makeLogger.ts deleted file mode 100644 index 076710fec2..0000000000 --- a/packages/sdk/react-native/contract-tests/entity/src/makeLogger.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { LDLogger } from '@launchdarkly/react-native-client-sdk'; - -export function makeLogger(tag: string): LDLogger { - return { - debug(message: any, ...args: any[]) { - // eslint-disable-next-line no-console - console.debug(`${new Date().toISOString()} [${tag}]: ${message}`, ...args); - }, - info(message: any, ...args: any[]) { - // eslint-disable-next-line no-console - console.info(`${new Date().toISOString()} [${tag}]: ${message}`, ...args); - }, - warn(message: any, ...args: any[]) { - // eslint-disable-next-line no-console - console.warn(`${new Date().toISOString()} [${tag}]: ${message}`, ...args); - }, - error(message: any, ...args: any[]) { - // eslint-disable-next-line no-console - console.error(`${new Date().toISOString()} [${tag}]: ${message}`, ...args); - }, - }; -} diff --git a/packages/tooling/contract-test-utils/package.json b/packages/tooling/contract-test-utils/package.json index fb82d31a6c..0273942430 100644 --- a/packages/tooling/contract-test-utils/package.json +++ b/packages/tooling/contract-test-utils/package.json @@ -38,7 +38,7 @@ "clean": "rimraf dist" }, "dependencies": { - "@launchdarkly/js-client-sdk": "workspace:^", + "@launchdarkly/js-client-sdk-common": "workspace:^", "@launchdarkly/node-server-sdk": "workspace:^", "got": "14.4.7" }, diff --git a/packages/tooling/contract-test-utils/src/client-side/TestHarnessWebSocket.ts b/packages/tooling/contract-test-utils/src/client-side/TestHarnessWebSocket.ts index 4d9f0fedc1..26b82412e1 100644 --- a/packages/tooling/contract-test-utils/src/client-side/TestHarnessWebSocket.ts +++ b/packages/tooling/contract-test-utils/src/client-side/TestHarnessWebSocket.ts @@ -16,27 +16,36 @@ export default class TestHarnessWebSocket { private _logger: LDLogger = makeLogger('TestHarnessWebSocket'); private _capabilities: string[]; private _createClientEntity: CreateClientEntityFn; + private _intentionalClose = false; + private _onConnectionChange?: (connected: boolean) => void; constructor( private readonly _url: string, capabilities: string[], createClientEntity: CreateClientEntityFn, + onConnectionChange?: (connected: boolean) => void, ) { this._capabilities = capabilities; this._createClientEntity = createClientEntity; + this._onConnectionChange = onConnectionChange; } connect() { + this._intentionalClose = false; this._logger.info(`Connecting to web socket.`); this._ws = new WebSocket(this._url, ['v1']); this._ws.onopen = () => { this._logger.info('Connected to websocket.'); + this._onConnectionChange?.(true); }; this._ws.onclose = () => { this._logger.info('Websocket closed. Attempting to reconnect in 1 second.'); - setTimeout(() => { - this.connect(); - }, 1000); + this._onConnectionChange?.(false); + if (!this._intentionalClose) { + setTimeout(() => { + this.connect(); + }, 1000); + } }; this._ws.onerror = (err) => { this._logger.info(`error:`, err); @@ -44,7 +53,7 @@ export default class TestHarnessWebSocket { this._ws.onmessage = async (msg) => { this._logger.info('Test harness message', msg); - const data = JSON.parse(msg.data); + const data = JSON.parse(msg.data as string); const resData: any = { reqId: data.reqId }; switch (data.command) { case 'getCapabilities': @@ -52,20 +61,28 @@ export default class TestHarnessWebSocket { break; case 'createClient': - { + try { resData.resourceUrl = `/clients/${this._clientCounter}`; resData.status = 201; const entity = await this._createClientEntity(data.body); this._entities[this._clientCounter] = entity; this._clientCounter += 1; + } catch (e: any) { + this._logger.error(`Failed to create client: ${e?.message ?? e}`); + resData.status = 500; } break; case 'runCommand': if (Object.prototype.hasOwnProperty.call(this._entities, data.id)) { const entity = this._entities[data.id]; - const body = await entity.doCommand(data.body); - resData.body = body; - resData.status = body ? 200 : 204; + try { + const body = await entity.doCommand(data.body); + resData.body = body; + resData.status = body ? 200 : 204; + } catch (e: any) { + this._logger.error(`Command failed: ${e?.message ?? e}`); + resData.status = 500; + } } else { resData.status = 404; this._logger.warn(`Client did not exist: ${data.id}`); @@ -91,6 +108,7 @@ export default class TestHarnessWebSocket { } disconnect() { + this._intentionalClose = true; this._ws?.close(); } diff --git a/packages/tooling/contract-test-utils/src/client-side/TestHook.ts b/packages/tooling/contract-test-utils/src/client-side/TestHook.ts index 046e058096..9a107f420f 100644 --- a/packages/tooling/contract-test-utils/src/client-side/TestHook.ts +++ b/packages/tooling/contract-test-utils/src/client-side/TestHook.ts @@ -5,7 +5,7 @@ import { HookMetadata, LDEvaluationDetail, TrackSeriesContext, -} from '@launchdarkly/js-client-sdk'; +} from '@launchdarkly/js-client-sdk-common'; export interface HookData { beforeEvaluation?: Record; diff --git a/packages/tooling/contract-test-utils/src/index.ts b/packages/tooling/contract-test-utils/src/index.ts index ff63582d0b..7a27da4a11 100644 --- a/packages/tooling/contract-test-utils/src/index.ts +++ b/packages/tooling/contract-test-utils/src/index.ts @@ -13,6 +13,7 @@ export { type SDKConfigEvaluationHookData, type SDKConfigHookInstance, type SDKConfigHooksParams, + type SDKConfigProxyParams, type SDKConfigWrapper, type HookStage as ConfigHookStage, } from './types/ConfigParams.js'; diff --git a/packages/tooling/contract-test-utils/src/types/ConfigParams.ts b/packages/tooling/contract-test-utils/src/types/ConfigParams.ts index 1d42f448be..3225565057 100644 --- a/packages/tooling/contract-test-utils/src/types/ConfigParams.ts +++ b/packages/tooling/contract-test-utils/src/types/ConfigParams.ts @@ -18,6 +18,7 @@ export interface SDKConfigParams { clientSide?: SDKConfigClientSideParams; hooks?: SDKConfigHooksParams; wrapper?: SDKConfigWrapper; + proxy?: SDKConfigProxyParams; } export interface SDKConfigTLSParams { @@ -82,6 +83,10 @@ export interface SDKConfigHooksParams { hooks: SDKConfigHookInstance[]; } +export interface SDKConfigProxyParams { + httpProxy?: string; +} + export interface SDKConfigWrapper { name: string; version: string; From 496d5458bf2f5c3db92b3b26ef672ef6f1195bf3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:56:31 +0000 Subject: [PATCH 09/19] fix: resolve prettier import ordering in electron and react-native contract tests Co-Authored-By: Steven Zhang --- .../contract-tests/entity/src/ClientEntity.ts | 1 - .../contract-tests/entity/src/ClientEntity.ts | 11 +++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/sdk/electron/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/electron/contract-tests/entity/src/ClientEntity.ts index 061fcc0c43..23188fc6cd 100644 --- a/packages/sdk/electron/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/electron/contract-tests/entity/src/ClientEntity.ts @@ -6,7 +6,6 @@ import path from 'node:path'; // eslint-disable-next-line import/no-extraneous-dependencies import { createClient, LDClient, LDLogger, LDOptions } from '@launchdarkly/electron-client-sdk'; - import { CommandParams, CommandType, diff --git a/packages/sdk/react-native/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/react-native/contract-tests/entity/src/ClientEntity.ts index bb611d7b4a..c78d035358 100644 --- a/packages/sdk/react-native/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/react-native/contract-tests/entity/src/ClientEntity.ts @@ -1,9 +1,3 @@ -import { - AutoEnvAttributes, - LDOptions, - ReactNativeLDClient, -} from '@launchdarkly/react-native-client-sdk'; - import { CommandParams, CommandType, @@ -13,6 +7,11 @@ import { ValueType, } from '@launchdarkly/js-contract-test-utils'; import { ClientSideTestHook as TestHook } from '@launchdarkly/js-contract-test-utils/client'; +import { + AutoEnvAttributes, + LDOptions, + ReactNativeLDClient, +} from '@launchdarkly/react-native-client-sdk'; export const badCommandError = new Error('unsupported command'); export const malformedCommand = new Error('command was malformed'); From 68929cb8541536c503f2c9085a92570889dfc22b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:03:35 +0000 Subject: [PATCH 10/19] fix: enable package exports in Metro config for subpath import resolution Co-Authored-By: Steven Zhang --- packages/sdk/react-native/contract-tests/entity/metro.config.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/sdk/react-native/contract-tests/entity/metro.config.js b/packages/sdk/react-native/contract-tests/entity/metro.config.js index 6cb167be47..12078f3085 100644 --- a/packages/sdk/react-native/contract-tests/entity/metro.config.js +++ b/packages/sdk/react-native/contract-tests/entity/metro.config.js @@ -22,5 +22,7 @@ config.resolver.nodeModulesPaths = [ ]; // 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths` config.resolver.disableHierarchicalLookup = true; +// 4. Enable package.json "exports" field resolution for subpath imports +config.resolver.unstable_enablePackageExports = true; module.exports = config; From ce6a745ca67f404c075020a124cc939d99b4374f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:10:10 +0000 Subject: [PATCH 11/19] fix: add custom Metro resolver for TypeScript .js extension convention Co-Authored-By: Steven Zhang --- .../contract-tests/entity/metro.config.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/sdk/react-native/contract-tests/entity/metro.config.js b/packages/sdk/react-native/contract-tests/entity/metro.config.js index 12078f3085..02667bf4f6 100644 --- a/packages/sdk/react-native/contract-tests/entity/metro.config.js +++ b/packages/sdk/react-native/contract-tests/entity/metro.config.js @@ -5,6 +5,7 @@ */ const { getDefaultConfig } = require('expo/metro-config'); const path = require('path'); +const fs = require('fs'); // Find the project and workspace directories const projectRoot = __dirname; @@ -24,5 +25,22 @@ config.resolver.nodeModulesPaths = [ config.resolver.disableHierarchicalLookup = true; // 4. Enable package.json "exports" field resolution for subpath imports config.resolver.unstable_enablePackageExports = true; +// 5. Handle TypeScript .js extension convention: when a .js import is not found, +// try resolving the .ts equivalent (needed for workspace packages using ESM imports) +const originalResolveRequest = config.resolver.resolveRequest; +config.resolver.resolveRequest = (context, moduleName, platform) => { + if (moduleName.startsWith('.') && moduleName.endsWith('.js')) { + const tsName = moduleName.replace(/\.js$/, '.ts'); + const fromDir = path.dirname(context.originModulePath); + const tsPath = path.resolve(fromDir, tsName); + if (fs.existsSync(tsPath)) { + return context.resolveRequest(context, tsName, platform); + } + } + if (originalResolveRequest) { + return originalResolveRequest(context, moduleName, platform); + } + return context.resolveRequest(context, moduleName, platform); +}; module.exports = config; From 5b4b3c1525ff2c5aa8232c18f6751310e4f61197 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:26:53 +0000 Subject: [PATCH 12/19] refactor: extract shared adapter implementation for browser and react-native contract tests Co-Authored-By: Steven Zhang --- .../contract-tests/adapter/package.json | 8 +- .../contract-tests/adapter/src/index.ts | 113 +-------------- .../contract-tests/adapter/package.json | 8 +- .../contract-tests/adapter/src/index.ts | 113 +-------------- .../tooling/contract-test-utils/package.json | 17 ++- .../contract-test-utils/src/adapter.ts | 3 + .../src/adapter/startAdapter.ts | 131 ++++++++++++++++++ 7 files changed, 156 insertions(+), 237 deletions(-) create mode 100644 packages/tooling/contract-test-utils/src/adapter.ts create mode 100644 packages/tooling/contract-test-utils/src/adapter/startAdapter.ts diff --git a/packages/sdk/browser/contract-tests/adapter/package.json b/packages/sdk/browser/contract-tests/adapter/package.json index 13515342f0..e47b0e75c3 100644 --- a/packages/sdk/browser/contract-tests/adapter/package.json +++ b/packages/sdk/browser/contract-tests/adapter/package.json @@ -12,17 +12,11 @@ "author": "", "license": "UNLICENSED", "dependencies": { - "body-parser": "^1.20.3", - "cors": "^2.8.5", - "express": "^4.21.0", - "ws": "^8.18.0" + "@launchdarkly/js-contract-test-utils": "workspace:*" }, "devDependencies": { "@eslint/js": "^9.10.0", "@trivago/prettier-plugin-sort-imports": "^4.1.1", - "@types/cors": "^2.8.17", - "@types/express": "^4.17.21", - "@types/ws": "^8.5.12", "@typescript-eslint/eslint-plugin": "^6.20.0", "@typescript-eslint/parser": "^6.20.0", "eslint": "^8.45.0", diff --git a/packages/sdk/browser/contract-tests/adapter/src/index.ts b/packages/sdk/browser/contract-tests/adapter/src/index.ts index 5dfffa3bd8..a606dcc72a 100644 --- a/packages/sdk/browser/contract-tests/adapter/src/index.ts +++ b/packages/sdk/browser/contract-tests/adapter/src/index.ts @@ -1,112 +1,3 @@ -/* eslint-disable no-console */ +import { startAdapter } from '@launchdarkly/js-contract-test-utils/adapter'; -/* 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 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 => { - 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(); +startAdapter(); diff --git a/packages/sdk/react-native/contract-tests/adapter/package.json b/packages/sdk/react-native/contract-tests/adapter/package.json index 88c409a3c7..ce2cf94a59 100644 --- a/packages/sdk/react-native/contract-tests/adapter/package.json +++ b/packages/sdk/react-native/contract-tests/adapter/package.json @@ -10,15 +10,9 @@ "author": "", "license": "UNLICENSED", "dependencies": { - "body-parser": "^1.20.3", - "cors": "^2.8.5", - "express": "^4.21.0", - "ws": "^8.18.0" + "@launchdarkly/js-contract-test-utils": "workspace:*" }, "devDependencies": { - "@types/cors": "^2.8.17", - "@types/express": "^4.17.21", - "@types/ws": "^8.5.12", "typescript": "^5.6.2" } } diff --git a/packages/sdk/react-native/contract-tests/adapter/src/index.ts b/packages/sdk/react-native/contract-tests/adapter/src/index.ts index 5dfffa3bd8..a606dcc72a 100644 --- a/packages/sdk/react-native/contract-tests/adapter/src/index.ts +++ b/packages/sdk/react-native/contract-tests/adapter/src/index.ts @@ -1,112 +1,3 @@ -/* eslint-disable no-console */ +import { startAdapter } from '@launchdarkly/js-contract-test-utils/adapter'; -/* 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 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 => { - 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(); +startAdapter(); diff --git a/packages/tooling/contract-test-utils/package.json b/packages/tooling/contract-test-utils/package.json index 0273942430..1ddd89428b 100644 --- a/packages/tooling/contract-test-utils/package.json +++ b/packages/tooling/contract-test-utils/package.json @@ -20,6 +20,11 @@ "types": "./dist/server.d.ts", "import": "./dist/server.js", "default": "./dist/server.js" + }, + "./adapter": { + "types": "./dist/adapter.d.ts", + "import": "./dist/adapter.js", + "default": "./dist/adapter.js" } }, "typesVersions": { @@ -29,6 +34,9 @@ ], "server": [ "./dist/server.d.ts" + ], + "adapter": [ + "./dist/adapter.d.ts" ] } }, @@ -40,10 +48,17 @@ "dependencies": { "@launchdarkly/js-client-sdk-common": "workspace:^", "@launchdarkly/node-server-sdk": "workspace:^", - "got": "14.4.7" + "body-parser": "^1.20.3", + "cors": "^2.8.5", + "express": "^4.21.0", + "got": "14.4.7", + "ws": "^8.18.0" }, "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", "@types/node": "^18.11.9", + "@types/ws": "^8.5.12", "typescript": "^4.9.0" } } diff --git a/packages/tooling/contract-test-utils/src/adapter.ts b/packages/tooling/contract-test-utils/src/adapter.ts new file mode 100644 index 0000000000..8b407dbfaa --- /dev/null +++ b/packages/tooling/contract-test-utils/src/adapter.ts @@ -0,0 +1,3 @@ +// Adapter exports (Node.js REST-to-WebSocket bridge for browser-like contract tests) +export { startAdapter } from './adapter/startAdapter.js'; +export type { AdapterOptions } from './adapter/startAdapter.js'; diff --git a/packages/tooling/contract-test-utils/src/adapter/startAdapter.ts b/packages/tooling/contract-test-utils/src/adapter/startAdapter.ts new file mode 100644 index 0000000000..8544fa9daf --- /dev/null +++ b/packages/tooling/contract-test-utils/src/adapter/startAdapter.ts @@ -0,0 +1,131 @@ +/* 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'; + +export interface AdapterOptions { + /** Port for the WebSocket server that connects to the entity. Defaults to 8001. */ + wsPort?: number; + /** Port for the HTTP REST server that the test harness connects to. Defaults to 8000. */ + httpPort?: number; +} + +/** + * Starts the contract test adapter that bridges the REST-based test harness + * protocol to a WebSocket connection for browser-like environments (browser, + * React Native, etc.). + * + * The adapter runs two servers: + * 1. A WebSocket server that the entity (browser/RN app) connects to + * 2. An Express HTTP server that the test harness sends REST commands to + * + * Commands from the test harness are forwarded over the WebSocket to the entity, + * and responses are relayed back. + */ +export function startAdapter(options?: AdapterOptions): void { + const wsPort = options?.wsPort ?? 8001; + const httpPort = options?.httpPort ?? 8000; + + let server: http.Server | undefined; + + const wss = new WebSocketServer({ port: wsPort }); + const waiters: Record 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 => { + 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(); + + 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(httpPort, () => { + console.log('Listening on port %d', httpPort); + }); + }); +} From 961ba7bfd2c07437090df08ab60a0263f63acadf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:06:52 +0000 Subject: [PATCH 13/19] ci: re-trigger CI checks Co-Authored-By: Steven Zhang From e79615f4e6fceb29f5c093449255491e8f117b89 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:15:20 +0000 Subject: [PATCH 14/19] style: consolidate eslint-disable comments in adapter Co-Authored-By: Steven Zhang --- packages/tooling/contract-test-utils/src/adapter/startAdapter.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/tooling/contract-test-utils/src/adapter/startAdapter.ts b/packages/tooling/contract-test-utils/src/adapter/startAdapter.ts index 8544fa9daf..d3673ed8d1 100644 --- a/packages/tooling/contract-test-utils/src/adapter/startAdapter.ts +++ b/packages/tooling/contract-test-utils/src/adapter/startAdapter.ts @@ -1,5 +1,4 @@ /* eslint-disable no-console */ - /* eslint-disable @typescript-eslint/no-explicit-any */ import bodyParser from 'body-parser'; import cors from 'cors'; From 35e3007035d52bab0ad6697ddfcd01763d2789d5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:34:40 +0000 Subject: [PATCH 15/19] fix: pre-build shared package server output before adapter compilation Co-Authored-By: Steven Zhang --- packages/sdk/browser/contract-tests/adapter/package.json | 2 +- packages/sdk/react-native/contract-tests/adapter/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sdk/browser/contract-tests/adapter/package.json b/packages/sdk/browser/contract-tests/adapter/package.json index e47b0e75c3..897a3f6947 100644 --- a/packages/sdk/browser/contract-tests/adapter/package.json +++ b/packages/sdk/browser/contract-tests/adapter/package.json @@ -4,7 +4,7 @@ "description": "Adapts REST interface to a websocket for use in browsers.", "main": "dist/index.js", "scripts": { - "build": "tsc", + "build": "yarn workspace @launchdarkly/js-contract-test-utils build:server && tsc", "start": "yarn build && node dist/index.js", "lint": "eslint ./src", "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../../.prettierignore" diff --git a/packages/sdk/react-native/contract-tests/adapter/package.json b/packages/sdk/react-native/contract-tests/adapter/package.json index ce2cf94a59..2b85ef3bb3 100644 --- a/packages/sdk/react-native/contract-tests/adapter/package.json +++ b/packages/sdk/react-native/contract-tests/adapter/package.json @@ -4,7 +4,7 @@ "description": "Adapts REST interface to a websocket for use in React Native.", "main": "dist/index.js", "scripts": { - "build": "tsc", + "build": "yarn workspace @launchdarkly/js-contract-test-utils build:server && tsc", "start": "yarn build && node dist/index.js" }, "author": "", From 2f8a2e39a9ad80be13f53fb28dabce6f1163b11b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:48:08 +0000 Subject: [PATCH 16/19] fix: use adapter-only tsconfig to avoid node-server-sdk dependency in CI Co-Authored-By: Steven Zhang --- packages/sdk/browser/contract-tests/adapter/package.json | 2 +- .../sdk/react-native/contract-tests/adapter/package.json | 2 +- packages/tooling/contract-test-utils/package.json | 1 + packages/tooling/contract-test-utils/tsconfig.adapter.json | 5 +++++ 4 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 packages/tooling/contract-test-utils/tsconfig.adapter.json diff --git a/packages/sdk/browser/contract-tests/adapter/package.json b/packages/sdk/browser/contract-tests/adapter/package.json index 897a3f6947..ab36b0db6a 100644 --- a/packages/sdk/browser/contract-tests/adapter/package.json +++ b/packages/sdk/browser/contract-tests/adapter/package.json @@ -4,7 +4,7 @@ "description": "Adapts REST interface to a websocket for use in browsers.", "main": "dist/index.js", "scripts": { - "build": "yarn workspace @launchdarkly/js-contract-test-utils build:server && tsc", + "build": "yarn workspace @launchdarkly/js-contract-test-utils build:adapter && tsc", "start": "yarn build && node dist/index.js", "lint": "eslint ./src", "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../../.prettierignore" diff --git a/packages/sdk/react-native/contract-tests/adapter/package.json b/packages/sdk/react-native/contract-tests/adapter/package.json index 2b85ef3bb3..1e0f9744a1 100644 --- a/packages/sdk/react-native/contract-tests/adapter/package.json +++ b/packages/sdk/react-native/contract-tests/adapter/package.json @@ -4,7 +4,7 @@ "description": "Adapts REST interface to a websocket for use in React Native.", "main": "dist/index.js", "scripts": { - "build": "yarn workspace @launchdarkly/js-contract-test-utils build:server && tsc", + "build": "yarn workspace @launchdarkly/js-contract-test-utils build:adapter && tsc", "start": "yarn build && node dist/index.js" }, "author": "", diff --git a/packages/tooling/contract-test-utils/package.json b/packages/tooling/contract-test-utils/package.json index 1ddd89428b..6548c69105 100644 --- a/packages/tooling/contract-test-utils/package.json +++ b/packages/tooling/contract-test-utils/package.json @@ -43,6 +43,7 @@ "scripts": { "build": "tsc", "build:server": "tsc -p tsconfig.server.json", + "build:adapter": "tsc -p tsconfig.adapter.json", "clean": "rimraf dist" }, "dependencies": { diff --git a/packages/tooling/contract-test-utils/tsconfig.adapter.json b/packages/tooling/contract-test-utils/tsconfig.adapter.json new file mode 100644 index 0000000000..10efda6115 --- /dev/null +++ b/packages/tooling/contract-test-utils/tsconfig.adapter.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/adapter/**/*", "src/adapter.ts"], + "exclude": ["dist", "node_modules"] +} From 45b270fe79517c7cf30472163953d1178f7fdeed Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:59:34 +0000 Subject: [PATCH 17/19] fix: switch adapter packages to ESM to resolve ERR_REQUIRE_ESM at runtime Co-Authored-By: Steven Zhang --- .../browser/contract-tests/adapter/package.json | 1 + .../browser/contract-tests/adapter/tsconfig.json | 14 +++++++------- .../contract-tests/adapter/package.json | 1 + .../contract-tests/adapter/tsconfig.json | 14 +++++++------- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/sdk/browser/contract-tests/adapter/package.json b/packages/sdk/browser/contract-tests/adapter/package.json index ab36b0db6a..c61ccd4d9f 100644 --- a/packages/sdk/browser/contract-tests/adapter/package.json +++ b/packages/sdk/browser/contract-tests/adapter/package.json @@ -2,6 +2,7 @@ "name": "browser-contract-test-adapter", "version": "1.0.0", "description": "Adapts REST interface to a websocket for use in browsers.", + "type": "module", "main": "dist/index.js", "scripts": { "build": "yarn workspace @launchdarkly/js-contract-test-utils build:adapter && tsc", diff --git a/packages/sdk/browser/contract-tests/adapter/tsconfig.json b/packages/sdk/browser/contract-tests/adapter/tsconfig.json index f6ad77fb4c..4f8d3c4aba 100644 --- a/packages/sdk/browser/contract-tests/adapter/tsconfig.json +++ b/packages/sdk/browser/contract-tests/adapter/tsconfig.json @@ -1,15 +1,15 @@ { "compilerOptions": { - "target": "ES6", - "module": "commonjs", - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "strict": true, - "moduleResolution": "node", + "target": "ES2020", + "module": "ES2020", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "moduleResolution": "node", "outDir": "dist", "sourceMap": true, "skipLibCheck": true }, - "lib": ["ES6"], + "lib": ["ES2020"], "exclude": ["**/*.test.ts", "dist", "node_modules"] } diff --git a/packages/sdk/react-native/contract-tests/adapter/package.json b/packages/sdk/react-native/contract-tests/adapter/package.json index 1e0f9744a1..10453a069b 100644 --- a/packages/sdk/react-native/contract-tests/adapter/package.json +++ b/packages/sdk/react-native/contract-tests/adapter/package.json @@ -2,6 +2,7 @@ "name": "react-native-contract-test-adapter", "version": "1.0.0", "description": "Adapts REST interface to a websocket for use in React Native.", + "type": "module", "main": "dist/index.js", "scripts": { "build": "yarn workspace @launchdarkly/js-contract-test-utils build:adapter && tsc", diff --git a/packages/sdk/react-native/contract-tests/adapter/tsconfig.json b/packages/sdk/react-native/contract-tests/adapter/tsconfig.json index 693bf1f0c2..95660dfab9 100644 --- a/packages/sdk/react-native/contract-tests/adapter/tsconfig.json +++ b/packages/sdk/react-native/contract-tests/adapter/tsconfig.json @@ -1,12 +1,12 @@ { "compilerOptions": { - "target": "ES6", - "lib": ["ES6"], - "module": "commonjs", - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "strict": true, - "moduleResolution": "node", + "target": "ES2020", + "lib": ["ES2020"], + "module": "ES2020", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "moduleResolution": "node", "outDir": "dist", "sourceMap": true, "skipLibCheck": true From 3860a3604b88d8ef7c080b56947d6a5edc6d5c2b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:39:13 +0000 Subject: [PATCH 18/19] feat: add CLI executable (sdk-testharness-server / sts) with config file support - Create sdk-testharness-server CLI with 'adapter' command - Add contract-test.config.{json,js,mjs,cjs,ts,mts} config file support - Register bin entries in shared package (sdk-testharness-server + sts alias) - Simplify browser and react-native adapter packages to use CLI - Remove adapter src/index.ts and tsconfig.json (no longer needed) - Export ContractTestConfig type for typed configuration files Co-Authored-By: Steven Zhang --- .../contract-tests/adapter/package.json | 25 +---------- .../contract-tests/adapter/src/index.ts | 3 -- .../contract-tests/adapter/tsconfig.json | 15 ------- .../contract-tests/adapter/package.json | 9 +--- .../contract-tests/adapter/src/index.ts | 3 -- .../contract-tests/adapter/tsconfig.json | 15 ------- .../tooling/contract-test-utils/package.json | 6 ++- .../contract-test-utils/src/adapter.ts | 1 + .../contract-test-utils/src/bin/loadConfig.ts | 42 +++++++++++++++++++ .../src/bin/sdk-testharness-server.ts | 35 ++++++++++++++++ .../src/types/ContractTestConfig.ts | 10 +++++ .../contract-test-utils/tsconfig.adapter.json | 2 +- 12 files changed, 98 insertions(+), 68 deletions(-) delete mode 100644 packages/sdk/browser/contract-tests/adapter/src/index.ts delete mode 100644 packages/sdk/browser/contract-tests/adapter/tsconfig.json delete mode 100644 packages/sdk/react-native/contract-tests/adapter/src/index.ts delete mode 100644 packages/sdk/react-native/contract-tests/adapter/tsconfig.json create mode 100644 packages/tooling/contract-test-utils/src/bin/loadConfig.ts create mode 100644 packages/tooling/contract-test-utils/src/bin/sdk-testharness-server.ts create mode 100644 packages/tooling/contract-test-utils/src/types/ContractTestConfig.ts diff --git a/packages/sdk/browser/contract-tests/adapter/package.json b/packages/sdk/browser/contract-tests/adapter/package.json index c61ccd4d9f..235d339233 100644 --- a/packages/sdk/browser/contract-tests/adapter/package.json +++ b/packages/sdk/browser/contract-tests/adapter/package.json @@ -2,34 +2,13 @@ "name": "browser-contract-test-adapter", "version": "1.0.0", "description": "Adapts REST interface to a websocket for use in browsers.", - "type": "module", - "main": "dist/index.js", "scripts": { - "build": "yarn workspace @launchdarkly/js-contract-test-utils build:adapter && tsc", - "start": "yarn build && node dist/index.js", - "lint": "eslint ./src", - "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../../.prettierignore" + "build": "yarn workspace @launchdarkly/js-contract-test-utils build:adapter", + "start": "yarn build && sdk-testharness-server adapter" }, "author": "", "license": "UNLICENSED", "dependencies": { "@launchdarkly/js-contract-test-utils": "workspace:*" - }, - "devDependencies": { - "@eslint/js": "^9.10.0", - "@trivago/prettier-plugin-sort-imports": "^4.1.1", - "@typescript-eslint/eslint-plugin": "^6.20.0", - "@typescript-eslint/parser": "^6.20.0", - "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", - "globals": "^15.9.0", - "prettier": "^3.0.0", - "typescript": "^5.6.2", - "typescript-eslint": "^8.5.0" } } diff --git a/packages/sdk/browser/contract-tests/adapter/src/index.ts b/packages/sdk/browser/contract-tests/adapter/src/index.ts deleted file mode 100644 index a606dcc72a..0000000000 --- a/packages/sdk/browser/contract-tests/adapter/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { startAdapter } from '@launchdarkly/js-contract-test-utils/adapter'; - -startAdapter(); diff --git a/packages/sdk/browser/contract-tests/adapter/tsconfig.json b/packages/sdk/browser/contract-tests/adapter/tsconfig.json deleted file mode 100644 index 4f8d3c4aba..0000000000 --- a/packages/sdk/browser/contract-tests/adapter/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "ES2020", - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "strict": true, - "moduleResolution": "node", - "outDir": "dist", - "sourceMap": true, - "skipLibCheck": true - }, - "lib": ["ES2020"], - "exclude": ["**/*.test.ts", "dist", "node_modules"] -} diff --git a/packages/sdk/react-native/contract-tests/adapter/package.json b/packages/sdk/react-native/contract-tests/adapter/package.json index 10453a069b..b105967cb7 100644 --- a/packages/sdk/react-native/contract-tests/adapter/package.json +++ b/packages/sdk/react-native/contract-tests/adapter/package.json @@ -2,18 +2,13 @@ "name": "react-native-contract-test-adapter", "version": "1.0.0", "description": "Adapts REST interface to a websocket for use in React Native.", - "type": "module", - "main": "dist/index.js", "scripts": { - "build": "yarn workspace @launchdarkly/js-contract-test-utils build:adapter && tsc", - "start": "yarn build && node dist/index.js" + "build": "yarn workspace @launchdarkly/js-contract-test-utils build:adapter", + "start": "yarn build && sdk-testharness-server adapter" }, "author": "", "license": "UNLICENSED", "dependencies": { "@launchdarkly/js-contract-test-utils": "workspace:*" - }, - "devDependencies": { - "typescript": "^5.6.2" } } diff --git a/packages/sdk/react-native/contract-tests/adapter/src/index.ts b/packages/sdk/react-native/contract-tests/adapter/src/index.ts deleted file mode 100644 index a606dcc72a..0000000000 --- a/packages/sdk/react-native/contract-tests/adapter/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { startAdapter } from '@launchdarkly/js-contract-test-utils/adapter'; - -startAdapter(); diff --git a/packages/sdk/react-native/contract-tests/adapter/tsconfig.json b/packages/sdk/react-native/contract-tests/adapter/tsconfig.json deleted file mode 100644 index 95660dfab9..0000000000 --- a/packages/sdk/react-native/contract-tests/adapter/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "lib": ["ES2020"], - "module": "ES2020", - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "strict": true, - "moduleResolution": "node", - "outDir": "dist", - "sourceMap": true, - "skipLibCheck": true - }, - "exclude": ["**/*.test.ts", "dist", "node_modules"] -} diff --git a/packages/tooling/contract-test-utils/package.json b/packages/tooling/contract-test-utils/package.json index 6548c69105..ed867efa0a 100644 --- a/packages/tooling/contract-test-utils/package.json +++ b/packages/tooling/contract-test-utils/package.json @@ -3,6 +3,10 @@ "version": "0.0.0", "private": true, "type": "module", + "bin": { + "sdk-testharness-server": "./dist/bin/sdk-testharness-server.js", + "sts": "./dist/bin/sdk-testharness-server.js" + }, "main": "./src/index.ts", "types": "./src/index.ts", "exports": { @@ -43,7 +47,7 @@ "scripts": { "build": "tsc", "build:server": "tsc -p tsconfig.server.json", - "build:adapter": "tsc -p tsconfig.adapter.json", + "build:adapter": "tsc -p tsconfig.adapter.json && chmod +x dist/bin/sdk-testharness-server.js", "clean": "rimraf dist" }, "dependencies": { diff --git a/packages/tooling/contract-test-utils/src/adapter.ts b/packages/tooling/contract-test-utils/src/adapter.ts index 8b407dbfaa..5aabfa2442 100644 --- a/packages/tooling/contract-test-utils/src/adapter.ts +++ b/packages/tooling/contract-test-utils/src/adapter.ts @@ -1,3 +1,4 @@ // Adapter exports (Node.js REST-to-WebSocket bridge for browser-like contract tests) export { startAdapter } from './adapter/startAdapter.js'; export type { AdapterOptions } from './adapter/startAdapter.js'; +export type { ContractTestConfig } from './types/ContractTestConfig.js'; diff --git a/packages/tooling/contract-test-utils/src/bin/loadConfig.ts b/packages/tooling/contract-test-utils/src/bin/loadConfig.ts new file mode 100644 index 0000000000..f0c29e6351 --- /dev/null +++ b/packages/tooling/contract-test-utils/src/bin/loadConfig.ts @@ -0,0 +1,42 @@ +/* eslint-disable no-console */ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import type { ContractTestConfig } from '../types/ContractTestConfig.js'; + +const CONFIG_BASE = 'contract-test.config'; +const EXTENSIONS = ['.json', '.js', '.mjs', '.cjs', '.ts', '.mts']; + +/** + * Loads a contract test configuration file from the current working directory. + * Searches for `contract-test.config.{json,js,mjs,cjs,ts,mts}` in order. + * + * - `.json` files are parsed with JSON.parse + * - `.js`, `.mjs`, `.cjs`, `.ts`, `.mts` files are loaded via dynamic import + * (TypeScript files require Node.js >= 22 or a loader like tsx) + * + * If no config file is found, returns an empty config (defaults will be used). + */ +export async function loadConfig(cwd: string = process.cwd()): Promise { + for (const ext of EXTENSIONS) { + const filePath = path.join(cwd, `${CONFIG_BASE}${ext}`); + if (!fs.existsSync(filePath)) { + continue; + } + + console.log(`Loading config from ${CONFIG_BASE}${ext}`); + + if (ext === '.json') { + const content = fs.readFileSync(filePath, 'utf-8'); + return JSON.parse(content) as ContractTestConfig; + } + + // For JS/TS files, use dynamic import + const module = await import(pathToFileURL(filePath).href); + return (module.default ?? module) as ContractTestConfig; + } + + // No config file found — use defaults + return {}; +} diff --git a/packages/tooling/contract-test-utils/src/bin/sdk-testharness-server.ts b/packages/tooling/contract-test-utils/src/bin/sdk-testharness-server.ts new file mode 100644 index 0000000000..b5570b6888 --- /dev/null +++ b/packages/tooling/contract-test-utils/src/bin/sdk-testharness-server.ts @@ -0,0 +1,35 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ + +import { startAdapter } from '../adapter/startAdapter.js'; +import { loadConfig } from './loadConfig.js'; + +const COMMANDS = ['adapter'] as const; +type Command = (typeof COMMANDS)[number]; + +async function main() { + const args = process.argv.slice(2); + const command = args[0] as Command | undefined; + + if (!command || !COMMANDS.includes(command)) { + console.error('Usage: sdk-testharness-server '); + console.error(`Commands: ${COMMANDS.join(', ')}`); + process.exit(1); + } + + const config = await loadConfig(); + + switch (command) { + case 'adapter': + startAdapter(config.adapter); + break; + default: + console.error(`Unknown command: ${command}`); + process.exit(1); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/tooling/contract-test-utils/src/types/ContractTestConfig.ts b/packages/tooling/contract-test-utils/src/types/ContractTestConfig.ts new file mode 100644 index 0000000000..39291ff06c --- /dev/null +++ b/packages/tooling/contract-test-utils/src/types/ContractTestConfig.ts @@ -0,0 +1,10 @@ +import type { AdapterOptions } from '../adapter/startAdapter.js'; + +/** + * Configuration for the contract test tooling. + * Can be specified in a `contract-test.config.{json,js,mjs,ts,mts}` file. + */ +export interface ContractTestConfig { + /** Configuration for the adapter command. */ + adapter?: AdapterOptions; +} diff --git a/packages/tooling/contract-test-utils/tsconfig.adapter.json b/packages/tooling/contract-test-utils/tsconfig.adapter.json index 10efda6115..67f1ca3325 100644 --- a/packages/tooling/contract-test-utils/tsconfig.adapter.json +++ b/packages/tooling/contract-test-utils/tsconfig.adapter.json @@ -1,5 +1,5 @@ { "extends": "./tsconfig.json", - "include": ["src/adapter/**/*", "src/adapter.ts"], + "include": ["src/adapter/**/*", "src/adapter.ts", "src/bin/**/*", "src/types/ContractTestConfig.ts"], "exclude": ["dist", "node_modules"] } From 498810c917db2addf2340cb344b993916a42e35b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:42:40 +0000 Subject: [PATCH 19/19] docs: add README.md for contract-test-utils package Co-Authored-By: Steven Zhang --- .../tooling/contract-test-utils/README.md | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 packages/tooling/contract-test-utils/README.md diff --git a/packages/tooling/contract-test-utils/README.md b/packages/tooling/contract-test-utils/README.md new file mode 100644 index 0000000000..c3236266cc --- /dev/null +++ b/packages/tooling/contract-test-utils/README.md @@ -0,0 +1,228 @@ +# @launchdarkly/js-contract-test-utils + +Shared utilities for LaunchDarkly JavaScript SDK contract tests. This package consolidates duplicated contract test code across the browser, React Native, Electron, server-node, and Shopify Oxygen SDKs. + +This is a **private** package (not published to npm) used only within this monorepo. + +## CLI + +The package provides a CLI executable for running contract test infrastructure: + +```bash +# Full name +sdk-testharness-server + +# Alias +sts +``` + +### Commands + +#### `adapter` + +Starts the REST-to-WebSocket adapter server. This bridges the HTTP REST-based test harness protocol to a WebSocket connection for browser-like environments (browser, React Native, etc.). + +The adapter runs two servers: +1. A **WebSocket server** (default port `8001`) that the entity (browser/RN app) connects to +2. An **HTTP REST server** (default port `8000`) that the test harness sends commands to + +```bash +sdk-testharness-server adapter +``` + +### Configuration + +The CLI loads configuration from a `contract-test.config` file in the current working directory. Supported formats (searched in this order): + +- `contract-test.config.json` +- `contract-test.config.js` +- `contract-test.config.mjs` +- `contract-test.config.cjs` +- `contract-test.config.ts` (requires Node.js >= 22 or a loader like `tsx`) +- `contract-test.config.mts` + +If no config file is found, defaults are used. + +**Example `contract-test.config.json`:** + +```json +{ + "adapter": { + "wsPort": 8001, + "httpPort": 8000 + } +} +``` + +**Example typed config (`contract-test.config.ts`):** + +```ts +import type { ContractTestConfig } from '@launchdarkly/js-contract-test-utils/adapter'; + +const config: ContractTestConfig = { + adapter: { + wsPort: 8001, + httpPort: 8000, + }, +}; + +export default config; +``` + +## Subpath Exports + +The package uses subpath exports to organize code by platform: + +| Import Path | Contents | Resolution | +|---|---|---| +| `@launchdarkly/js-contract-test-utils` | Universal types, logging, `ClientPool` | Source `.ts` (for bundlers) | +| `@launchdarkly/js-contract-test-utils/client` | Client-side `TestHook`, `TestHarnessWebSocket` | Source `.ts` (for bundlers) | +| `@launchdarkly/js-contract-test-utils/server` | Server-side `TestHook` | Compiled `dist/` (for Node.js) | +| `@launchdarkly/js-contract-test-utils/adapter` | `startAdapter()`, `ContractTestConfig` type | Compiled `dist/` (for Node.js) | + +### Universal (`"."`) + +Types and utilities with no SDK dependency: + +```ts +import { + CommandType, + ValueType, + CommandParams, + CreateInstanceParams, + SDKConfigParams, + makeLogger, + ClientPool, +} from '@launchdarkly/js-contract-test-utils'; +``` + +### Client-side (`"./client"`) + +For browser, React Native, and Electron contract tests. Includes all universal exports plus: + +```ts +import { + ClientSideTestHook, + TestHarnessWebSocket, +} from '@launchdarkly/js-contract-test-utils/client'; +``` + +- **`TestHarnessWebSocket`** -- Manages the WebSocket dispatch loop between the adapter and the entity. Constructed with a URL, capabilities list, and a factory function for creating client entities. +- **`ClientSideTestHook`** -- Hook implementation using `fetch()` to report hook execution data back to the test harness. + +### Server-side (`"./server"`) + +For server-node and Shopify Oxygen contract tests. Includes all universal exports plus: + +```ts +import { + ServerSideTestHook, + ClientPool, +} from '@launchdarkly/js-contract-test-utils/server'; +``` + +- **`ServerSideTestHook`** -- Hook implementation using `got` for HTTP requests. +- **`ClientPool`** -- Generic pool for managing client entity lifecycles (`add`, `get`, `remove`, `nextId`). + +### Adapter (`"./adapter"`) + +For the REST-to-WebSocket bridge used by browser and React Native adapters: + +```ts +import { startAdapter } from '@launchdarkly/js-contract-test-utils/adapter'; +import type { ContractTestConfig, AdapterOptions } from '@launchdarkly/js-contract-test-utils/adapter'; +``` + +## Build Scripts + +```bash +# Full build (all outputs) +yarn build + +# Server-side only (for server-node contract tests) +yarn build:server + +# Adapter + CLI only (for browser/RN adapter packages) +yarn build:adapter +``` + +## Usage in Adapter Packages + +Browser and React Native adapter packages delegate entirely to the CLI: + +```json +{ + "scripts": { + "build": "yarn workspace @launchdarkly/js-contract-test-utils build:adapter", + "start": "yarn build && sdk-testharness-server adapter" + }, + "dependencies": { + "@launchdarkly/js-contract-test-utils": "workspace:*" + } +} +``` + +No local source code or TypeScript configuration is needed -- the adapter package is just a thin wrapper that invokes the shared CLI. + +## Usage in Entity Packages + +### Browser / React Native / Electron (client-side) + +```ts +import { + TestHarnessWebSocket, + ClientSideTestHook, + CommandParams, + makeLogger, +} from '@launchdarkly/js-contract-test-utils/client'; + +const ws = new TestHarnessWebSocket( + 'ws://localhost:8001', + ['client-side', 'mobile', 'service-endpoints', /* ... */], + async (config) => createClientEntity(config), +); +ws.connect(); +``` + +### Server-node / Shopify Oxygen (server-side) + +```ts +import { + ServerSideTestHook, + ClientPool, + makeLogger, +} from '@launchdarkly/js-contract-test-utils/server'; + +const pool = new ClientPool(); +const id = pool.nextId(); +pool.add(id, entity); +``` + +## Architecture + +``` +contract-test-utils/ + src/ + bin/ + sdk-testharness-server.ts # CLI entry point + loadConfig.ts # Config file loader + adapter/ + startAdapter.ts # REST-to-WebSocket bridge + client-side/ + TestHook.ts # fetch()-based hook reporting + TestHarnessWebSocket.ts # WebSocket dispatch loop + server-side/ + TestHook.ts # got-based hook reporting + ClientPool.ts # Generic entity pool + logging/ + makeLogger.ts # Logger factory + types/ + CommandParams.ts # Command/response type definitions + ConfigParams.ts # SDK configuration type definitions + ContractTestConfig.ts # CLI config file type + compat.ts # Minimal cross-SDK type aliases + index.ts # Universal exports + client.ts # Client-side exports + server.ts # Server-side exports + adapter.ts # Adapter exports +```