diff --git a/.github/workflows/browser.yml b/.github/workflows/browser.yml index 6e24d4ec0c..c121998d80 100644 --- a/.github/workflows/browser.yml +++ b/.github/workflows/browser.yml @@ -50,6 +50,9 @@ jobs: - name: Install Playwright browsers run: yarn workspace browser-contract-test-service install-playwright-browsers + - name: Build shared contract test utils + run: yarn workspace @launchdarkly/js-contract-test-utils build:client + - name: Build contract test adapter run: yarn workspace browser-contract-test-adapter run build diff --git a/.github/workflows/electron.yaml b/.github/workflows/electron.yaml index 90dd0e74f7..4a5b5aac00 100644 --- a/.github/workflows/electron.yaml +++ b/.github/workflows/electron.yaml @@ -40,6 +40,7 @@ jobs: ELECTRON_DISABLE_SANDBOX: '1' run: | yarn workspaces focus @internal/electron-contract-tests-entity + yarn workspace @launchdarkly/js-contract-test-utils build:client yarn workspace @internal/electron-contract-tests-entity build sudo apt-get install -y xvfb Xvfb :99 -screen 0 1024x768x24 > /tmp/xvfb.log 2>&1 & diff --git a/.github/workflows/react-native-contract-tests.yml b/.github/workflows/react-native-contract-tests.yml index 687c965736..fb6265b82c 100644 --- a/.github/workflows/react-native-contract-tests.yml +++ b/.github/workflows/react-native-contract-tests.yml @@ -33,6 +33,9 @@ jobs: - name: Build SDK and dependencies run: yarn workspaces foreach -pR --topological-dev --from '@launchdarkly/react-native-client-sdk' run build + - name: Build shared contract test utils + run: yarn workspace @launchdarkly/js-contract-test-utils build:client + - name: Build contract test adapter run: yarn workspace react-native-contract-test-adapter run build diff --git a/.github/workflows/react.yaml b/.github/workflows/react.yaml index 366f40e2af..4766d60a7f 100644 --- a/.github/workflows/react.yaml +++ b/.github/workflows/react.yaml @@ -39,8 +39,7 @@ jobs: yarn workspaces foreach -pR --topological-dev --from "@launchdarkly/react-sdk-contract-tests" install yarn workspaces foreach -pR --topological-dev --from 'browser-contract-test-adapter' run build yarn workspaces foreach -pR --topological-dev --from "@launchdarkly/react-sdk-contract-tests" run build - - name: Install Playwright browsers - run: yarn workspace @launchdarkly/react-sdk-contract-tests install-playwright-browsers + yarn workspace @launchdarkly/react-sdk-contract-tests install-playwright-browsers - name: Run test adapter run: | yarn workspace @launchdarkly/react-sdk-contract-tests run start:adapter > /tmp/adapter.log 2>&1 & diff --git a/.github/workflows/server-node.yml b/.github/workflows/server-node.yml index 46c225f8fd..85abb74a32 100644 --- a/.github/workflows/server-node.yml +++ b/.github/workflows/server-node.yml @@ -33,6 +33,8 @@ jobs: workspace_path: packages/sdk/server-node - name: Install contract test service dependencies run: yarn workspace node-server-sdk-contract-tests install --no-immutable + - name: Build shared contract test utils (server) + run: yarn workspace @launchdarkly/js-contract-test-utils build:server - name: Build the test service run: yarn workspace node-server-sdk-contract-tests build - name: Launch the test service in the background diff --git a/.github/workflows/shopify-oxygen.yml b/.github/workflows/shopify-oxygen.yml index 9eabf2c6dc..26c224b809 100644 --- a/.github/workflows/shopify-oxygen.yml +++ b/.github/workflows/shopify-oxygen.yml @@ -28,6 +28,8 @@ jobs: workspace_path: packages/sdk/shopify-oxygen - name: Install contract test service dependencies run: yarn workspace @launchdarkly/shopify-oxygen-contract-tests install --no-immutable + - name: Build shared contract test utils + run: yarn workspace @launchdarkly/js-contract-test-utils build:server - name: Build the test service run: yarn workspace @launchdarkly/shopify-oxygen-contract-tests build - name: Launch the test service in the background 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/TestHook.ts b/packages/sdk/server-node/contract-tests/src/TestHook.ts deleted file mode 100644 index abad14331d..0000000000 --- a/packages/sdk/server-node/contract-tests/src/TestHook.ts +++ /dev/null @@ -1,75 +0,0 @@ -import got from 'got'; - -import { integrations, LDEvaluationDetail } from '@launchdarkly/node-server-sdk'; - -export interface HookData { - beforeEvaluation?: Record; - afterEvaluation?: Record; -} - -export interface HookErrors { - beforeEvaluation?: string; - afterEvaluation?: string; -} - -export default class TestHook implements integrations.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 got.post(this._endpoint, { json: body }); - } catch { - // The test could move on before the post, so we are ignoring - // failed posts. - } - } - - getMetadata(): integrations.HookMetadata { - return { - name: this._name, - }; - } - - beforeEvaluation( - hookContext: integrations.EvaluationSeriesContext, - data: integrations.EvaluationSeriesData, - ): integrations.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: integrations.EvaluationSeriesContext, - data: integrations.EvaluationSeriesData, - detail: LDEvaluationDetail, - ): integrations.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 || {}) }; - } -} diff --git a/packages/sdk/server-node/contract-tests/src/index.ts b/packages/sdk/server-node/contract-tests/src/index.ts index f366bf9ca4..8265479343 100644 --- a/packages/sdk/server-node/contract-tests/src/index.ts +++ b/packages/sdk/server-node/contract-tests/src/index.ts @@ -2,6 +2,8 @@ import bodyParser from 'body-parser'; import express, { Request, Response } from 'express'; import { Server } from 'http'; +import { ClientPool } from '@launchdarkly/js-contract-test-utils/server'; + import { Log } from './log.js'; import { badCommandError, newSdkClientEntity, SdkClientEntity } from './sdkClientEntity.js'; @@ -10,8 +12,7 @@ let server: Server | null = null; const port = 8000; -let clientCounter = 0; -const clients: Record = {}; +const clients = new ClientPool(); const mainLog = Log('service'); @@ -66,16 +67,12 @@ app.delete('/', (req: Request, res: Response) => { app.post('/', async (req: Request, res: Response) => { const options = req.body; - clientCounter += 1; - const clientId = clientCounter.toString(); - const resourceUrl = `/clients/${clientId}`; - try { const client = await newSdkClientEntity(options); - clients[clientId] = client; + const clientId = clients.add(client); res.status(201); - res.set('Location', resourceUrl); + res.set('Location', `/clients/${clientId}`); } catch (e) { res.status(500); const message = e instanceof Error ? e.message : JSON.stringify(e); @@ -86,7 +83,7 @@ app.post('/', async (req: Request, res: Response) => { }); app.post('/clients/:id', async (req: Request, res: Response) => { - const client = clients[req.params.id]; + const client = clients.get(req.params.id); if (!client) { res.status(404); } else { @@ -113,13 +110,13 @@ app.post('/clients/:id', async (req: Request, res: Response) => { }); app.delete('/clients/:id', async (req: Request, res: Response) => { - const client = clients[req.params.id]; + const client = clients.get(req.params.id); if (!client) { res.status(404); res.send(); } else { client.close(); - delete clients[req.params.id]; + clients.remove(req.params.id); res.status(204); res.send(); } diff --git a/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts b/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts index f1fcfabe2b..2b99380531 100644 --- a/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts +++ b/packages/sdk/server-node/contract-tests/src/sdkClientEntity.ts @@ -1,5 +1,11 @@ import got from 'got'; +import { + CommandParams, + CreateInstanceParams, + ServerSDKConfigParams, + ServerSideTestHook as TestHook, +} from '@launchdarkly/js-contract-test-utils/server'; import ld, { createMigration, DataSourceOptions, @@ -7,7 +13,6 @@ import ld, { LDConcurrentExecution, LDContext, LDExecutionOrdering, - LDFlagValue, LDMigrationError, LDMigrationStage, LDMigrationSuccess, @@ -20,136 +25,11 @@ 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 }; -interface SdkConfigOptions { - streaming?: { - baseUri: string; - initialRetryDelayMs?: number; - filter?: string; - }; - polling?: { - baseUri: string; - pollIntervalMs: number; - filter?: string; - }; - dataSystem?: { - initializers?: SDKDataSystemInitializerParams[]; - synchronizers?: SDKDataSystemSynchronizerParams[]; - payloadFilter?: string; - }; - events?: { - allAttributesPrivate?: boolean; - baseUri: string; - capacity?: number; - enableDiagnostics?: boolean; - flushIntervalMs?: number; - globalPrivateAttributes?: string[]; - enableGzip?: boolean; - }; - tags?: { - applicationId: string; - applicationVersion: string; - }; - bigSegments?: { - callbackUri: string; - userCacheSize?: number; - userCacheTimeMs?: number; - statusPollIntervalMs?: number; - staleAfterMs?: number; - }; - hooks?: { - hooks: { - name: string; - callbackUri: string; - data: any; - errors: any; - }[]; - }; - wrapper?: { - name?: string; - version?: string; - }; -} - -export interface SDKDataSystemSynchronizerParams { - streaming?: SDKDataSourceStreamingParams; - polling?: SDKDataSourcePollingParams; -} - -export interface SDKDataSystemInitializerParams { - polling?: SDKDataSourcePollingParams; -} - -export interface SDKDataSourceStreamingParams { - baseUri?: string; - initialRetryDelayMs?: number; -} - -export interface SDKDataSourcePollingParams { - baseUri?: string; - pollIntervalMs?: number; -} - -interface CommandParams { - command: string; - evaluate?: { - flagKey: string; - context?: LDContext; - user?: LDUser; - defaultValue: LDFlagValue; - detail?: boolean; - valueType?: string; - }; - evaluateAll?: { - context?: LDContext; - user?: LDUser; - clientSideOnly?: boolean; - detailsOnlyForTrackedFlags?: boolean; - withReasons?: boolean; - }; - identifyEvent?: { - context?: LDContext; - user?: LDUser; - }; - customEvent?: { - eventKey: string; - context?: LDContext; - user?: LDUser; - data?: any; - metricValue?: number; - }; - migrationVariation?: { - key: string; - context: LDContext; - defaultStage: LDMigrationStage; - }; - migrationOperation?: { - operation: string; - key: string; - context: LDContext; - defaultStage: LDMigrationStage; - payload: any; - readExecutionOrder: string; - trackLatency?: boolean; - trackErrors?: boolean; - trackConsistency?: boolean; - newEndpoint: string; - oldEndpoint: string; - }; - registerFlagChangeListener?: { - listenerId: string; - callbackUri: string; - }; - unregisterListener?: { - listenerId: string; - }; -} - -export function makeSdkConfig(options: SdkConfigOptions, tag: string): LDOptions { +export function makeSdkConfig(options: ServerSDKConfigParams, tag: string): LDOptions { const cf: LDOptions = { logger: sdkLogger(tag), diagnosticOptOut: true, @@ -169,7 +49,7 @@ export function makeSdkConfig(options: SdkConfigOptions, tag: string): LDOptions if (options.polling) { cf.stream = false; cf.baseUri = options.polling.baseUri; - cf.pollInterval = options.polling.pollIntervalMs / 1000; + cf.pollInterval = maybeTime(options.polling.pollIntervalMs); if (options.polling.filter) { cf.payloadFilterKey = options.polling.filter; } @@ -312,10 +192,14 @@ function makeMigrationPostOptions(payload: any) { } function contextOrUser( - context: LDContext | undefined, + context: Record | undefined, user: LDUser | undefined, ): LDContext | LDUser { - return (context || user)!; + const result = (context as LDContext | undefined) ?? user; + if (!result) { + throw new Error('Neither context nor user provided'); + } + return result; } export interface SdkClientEntity { @@ -328,7 +212,7 @@ interface ListenerEntry { handler: (...args: any[]) => void; } -export async function newSdkClientEntity(options: any): Promise { +export async function newSdkClientEntity(options: CreateInstanceParams): Promise { const c: any = {}; const log = Log(options.tag); const listeners = new Map(); @@ -341,7 +225,7 @@ export async function newSdkClientEntity(options: any): Promise : 5000; const client: LDClient = ld.init( options.configuration.credential || 'unknown-sdk-key', - makeSdkConfig(options.configuration, options.tag), + makeSdkConfig(options.configuration as ServerSDKConfigParams, options.tag), ); try { await client.waitForInitialization({ timeout }); @@ -372,12 +256,12 @@ export async function newSdkClientEntity(options: any): Promise if (pe.detail) { switch (pe.valueType) { case 'bool': - return client.boolVariationDetail(pe.flagKey, context, pe.defaultValue); + return client.boolVariationDetail(pe.flagKey, context, pe.defaultValue as boolean); case 'int': // Intentional fallthrough. case 'double': - return client.numberVariationDetail(pe.flagKey, context, pe.defaultValue); + return client.numberVariationDetail(pe.flagKey, context, pe.defaultValue as number); case 'string': - return client.stringVariationDetail(pe.flagKey, context, pe.defaultValue); + return client.stringVariationDetail(pe.flagKey, context, pe.defaultValue as string); default: return client.variationDetail( pe.flagKey, @@ -389,16 +273,16 @@ export async function newSdkClientEntity(options: any): Promise switch (pe.valueType) { case 'bool': return { - value: await client.boolVariation(pe.flagKey, context, pe.defaultValue), + value: await client.boolVariation(pe.flagKey, context, pe.defaultValue as boolean), }; case 'int': // Intentional fallthrough. case 'double': return { - value: await client.numberVariation(pe.flagKey, context, pe.defaultValue), + value: await client.numberVariation(pe.flagKey, context, pe.defaultValue as number), }; case 'string': return { - value: await client.stringVariation(pe.flagKey, context, pe.defaultValue), + value: await client.stringVariation(pe.flagKey, context, pe.defaultValue as string), }; default: return { @@ -419,7 +303,9 @@ export async function newSdkClientEntity(options: any): Promise } case 'identifyEvent': - client.identify(params.identifyEvent!.context || params.identifyEvent!.user!); + client.identify( + (params.identifyEvent!.context as LDContext) || params.identifyEvent!.user!, + ); return undefined; case 'customEvent': { @@ -439,8 +325,8 @@ export async function newSdkClientEntity(options: any): Promise const migrationVariation = params.migrationVariation!; const res = await client.migrationVariation( migrationVariation.key, - migrationVariation.context, - migrationVariation.defaultStage, + migrationVariation.context as LDContext, + migrationVariation.defaultStage as LDMigrationStage, ); return { result: res.value }; } @@ -504,8 +390,8 @@ export async function newSdkClientEntity(options: any): Promise case 'read': { const res = await migration.read( migrationOperation.key, - migrationOperation.context, - migrationOperation.defaultStage, + migrationOperation.context as LDContext, + migrationOperation.defaultStage as LDMigrationStage, migrationOperation.payload, ); if (res.success) { @@ -516,8 +402,8 @@ export async function newSdkClientEntity(options: any): Promise case 'write': { const res = await migration.write( migrationOperation.key, - migrationOperation.context, - migrationOperation.defaultStage, + migrationOperation.context as LDContext, + migrationOperation.defaultStage as LDMigrationStage, migrationOperation.payload, ); 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..d0ca93dcdb 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 contract tests. + * It uses the shared generic ClientPool for client storage and ID generation, and + * handles SDK-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,6 @@ export default class ClientPool { public async createClient(options: any, res: Response): Promise { try { - const id = this._makeId(); const { configuration: { credential = 'unknown-sdk-key', polling }, } = options; @@ -101,7 +81,7 @@ export default class ClientPool { }); await client.waitForInitialization({ timeout: 10 }); - this._clients[id] = client; + const id = this._pool.add(client); res.status(201); res.set('Location', `/clients/${id}`); if (!client.initialized()) { 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, diff --git a/packages/tooling/contract-test-utils/package.json b/packages/tooling/contract-test-utils/package.json index de1dd22804..6f07b01152 100644 --- a/packages/tooling/contract-test-utils/package.json +++ b/packages/tooling/contract-test-utils/package.json @@ -3,36 +3,60 @@ "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": "./dist/server.d.ts", + "import": "./dist/server.js", + "default": "./dist/server.js" } }, "typesVersions": { "*": { "client": [ - "./src/client.ts" + "./dist/client.d.ts" + ], + "server": [ + "./dist/server.d.ts" ] } }, "scripts": { - "build": "tsc", + "build": "tsc && tsc -p tsconfig.client.json && tsc -p tsconfig.server.json", + "build:client": "tsc -p tsconfig.client.json", + "build:server": "tsc -p tsconfig.server.json", "clean": "rimraf dist" }, - "dependencies": { - "@launchdarkly/js-client-sdk-common": "workspace:^" + "peerDependencies": { + "@launchdarkly/js-client-sdk-common": "workspace:^", + "@launchdarkly/js-server-sdk-common": "workspace:^", + "got": "14.4.7" + }, + "peerDependenciesMeta": { + "@launchdarkly/js-client-sdk-common": { + "optional": true + }, + "@launchdarkly/js-server-sdk-common": { + "optional": true + }, + "got": { + "optional": true + } }, "devDependencies": { "@types/node": "^18.11.9", + "got": "14.4.7", "typescript": "^4.9.0" } } 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 9a107f420f..b9072ca6b0 100644 --- a/packages/tooling/contract-test-utils/src/client-side/TestHook.ts +++ b/packages/tooling/contract-test-utils/src/client-side/TestHook.ts @@ -6,32 +6,10 @@ import { LDEvaluationDetail, TrackSeriesContext, } from '@launchdarkly/js-client-sdk-common'; +import { BaseTestHook } from '../shared/BaseTestHook.js'; -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 { +export default class TestHook extends BaseTestHook implements Hook { + protected async _safePost(body: unknown): Promise { try { await fetch(this._endpoint, { method: 'POST', @@ -43,25 +21,18 @@ export default class TestHook implements Hook { } } - getMetadata(): HookMetadata { - return { - name: this._name, - }; + override getMetadata(): HookMetadata { + return super.getMetadata(); } 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 || {}) }; + return this._beforeEvaluationImpl( + hookContext as unknown as Record, + data, + ) as EvaluationSeriesData; } afterEvaluation( @@ -69,17 +40,11 @@ export default class TestHook implements Hook { 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 || {}) }; + return this._afterEvaluationImpl( + hookContext as unknown as Record, + data, + detail, + ) as EvaluationSeriesData; } afterTrack(hookContext: TrackSeriesContext): void { diff --git a/packages/tooling/contract-test-utils/src/client.ts b/packages/tooling/contract-test-utils/src/client.ts index d0a167b519..80469be6b1 100644 --- a/packages/tooling/contract-test-utils/src/client.ts +++ b/packages/tooling/contract-test-utils/src/client.ts @@ -1,9 +1,5 @@ // Re-export universal exports -export * from './index'; +export * from './index.js'; // Client-side exports -export { default as ClientSideTestHook } from './client-side/TestHook'; -export type { - HookData as ClientSideHookData, - HookErrors as ClientSideHookErrors, -} from './client-side/TestHook'; +export { default as ClientSideTestHook } from './client-side/TestHook.js'; diff --git a/packages/tooling/contract-test-utils/src/index.ts b/packages/tooling/contract-test-utils/src/index.ts index 82c54b0dcb..f227ed20f3 100644 --- a/packages/tooling/contract-test-utils/src/index.ts +++ b/packages/tooling/contract-test-utils/src/index.ts @@ -1,20 +1,5 @@ -// Universal exports (no SDK dependency) -export * from './types/CommandParams'; -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 SDKConfigProxyParams, - type SDKConfigWrapper, -} from './types/ConfigParams'; -export { makeLogger } from './logging/makeLogger'; -export { ClientPool } from './server-side/ClientPool'; +// Universal exports (types use minimal compat types, no SDK dependency) +export * from './types/CommandParams.js'; +export * from './types/ConfigParams.js'; +export { makeLogger } from './logging/makeLogger.js'; +export { ClientPool } from './server-side/ClientPool.js'; diff --git a/packages/tooling/contract-test-utils/src/logging/makeLogger.ts b/packages/tooling/contract-test-utils/src/logging/makeLogger.ts index ad01caf65d..65954ad83f 100644 --- a/packages/tooling/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-common'; +import { LDLogger } from '../types/compat.js'; export function makeLogger(tag: string): LDLogger { return { diff --git a/packages/tooling/contract-test-utils/src/server-side/TestHook.ts b/packages/tooling/contract-test-utils/src/server-side/TestHook.ts new file mode 100644 index 0000000000..37348117a3 --- /dev/null +++ b/packages/tooling/contract-test-utils/src/server-side/TestHook.ts @@ -0,0 +1,40 @@ +import got from 'got'; +import { integrations, LDEvaluationDetail } from '@launchdarkly/js-server-sdk-common'; +import { BaseTestHook } from '../shared/BaseTestHook.js'; + +export default class TestHook extends BaseTestHook implements integrations.Hook { + protected async _safePost(body: unknown): Promise { + try { + await got.post(this._endpoint, { json: body }); + } catch { + // The test could move on before the post, so we are ignoring + // failed posts. + } + } + + override getMetadata(): integrations.HookMetadata { + return super.getMetadata(); + } + + beforeEvaluation( + hookContext: integrations.EvaluationSeriesContext, + data: integrations.EvaluationSeriesData, + ): integrations.EvaluationSeriesData { + return this._beforeEvaluationImpl( + hookContext as unknown as Record, + data, + ) as integrations.EvaluationSeriesData; + } + + afterEvaluation( + hookContext: integrations.EvaluationSeriesContext, + data: integrations.EvaluationSeriesData, + detail: LDEvaluationDetail, + ): integrations.EvaluationSeriesData { + return this._afterEvaluationImpl( + hookContext as unknown as Record, + data, + detail, + ) as integrations.EvaluationSeriesData; + } +} diff --git a/packages/tooling/contract-test-utils/src/server.ts b/packages/tooling/contract-test-utils/src/server.ts new file mode 100644 index 0000000000..4c18533942 --- /dev/null +++ b/packages/tooling/contract-test-utils/src/server.ts @@ -0,0 +1,6 @@ +// Re-export universal exports +export * from './index.js'; + +// Server-side exports +export { default as ServerSideTestHook } from './server-side/TestHook.js'; +export type { ServerSDKConfigParams } from './types/ConfigParams.js'; diff --git a/packages/tooling/contract-test-utils/src/shared/BaseTestHook.ts b/packages/tooling/contract-test-utils/src/shared/BaseTestHook.ts new file mode 100644 index 0000000000..8df84059eb --- /dev/null +++ b/packages/tooling/contract-test-utils/src/shared/BaseTestHook.ts @@ -0,0 +1,53 @@ +import { HookData, HookErrors } from '../types/CommandParams.js'; + +export abstract class BaseTestHook { + protected readonly _name: string; + protected readonly _endpoint: string; + protected readonly _data?: HookData; + protected readonly _errors?: HookErrors; + + constructor(name: string, endpoint: string, data?: HookData, errors?: HookErrors) { + this._name = name; + this._endpoint = endpoint; + this._data = data; + this._errors = errors; + } + + protected abstract _safePost(body: unknown): Promise; + + getMetadata() { + return { name: this._name }; + } + + protected _beforeEvaluationImpl( + hookContext: Record, + data: Record, + ): Record { + if (this._errors?.beforeEvaluation) { + throw new Error(this._errors.beforeEvaluation); + } + this._safePost({ + evaluationSeriesContext: hookContext, + evaluationSeriesData: data, + stage: 'beforeEvaluation', + }); + return { ...data, ...(this._data?.beforeEvaluation ?? {}) }; + } + + protected _afterEvaluationImpl( + hookContext: Record, + data: Record, + detail: unknown, + ): Record { + 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 ?? {}) }; + } +} diff --git a/packages/tooling/contract-test-utils/src/types/CommandParams.ts b/packages/tooling/contract-test-utils/src/types/CommandParams.ts index 2f67b8a10f..dccf9f3928 100644 --- a/packages/tooling/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-common'; +import { LDContext, LDEvaluationReason } from './compat.js'; export const CommandType = { EvaluateFlag: 'evaluate', @@ -11,6 +11,12 @@ export const CommandType = { ContextConvert: 'contextConvert', ContextComparison: 'contextComparison', SecureModeHash: 'secureModeHash', + // Server-specific commands + GetBigSegmentStoreStatus: 'getBigSegmentStoreStatus', + MigrationVariation: 'migrationVariation', + MigrationOperation: 'migrationOperation', + RegisterFlagChangeListener: 'registerFlagChangeListener', + UnregisterListener: 'unregisterListener', } as const; // eslint-disable-next-line @typescript-eslint/no-redeclare export type CommandType = (typeof CommandType)[keyof typeof CommandType]; @@ -35,6 +41,11 @@ export interface CommandParams { contextConvert?: ContextConvertParams; contextComparison?: ContextComparisonPairParams; secureModeHash?: SecureModeHashParams; + // Server-specific command fields + migrationVariation?: MigrationVariationParams; + migrationOperation?: MigrationOperationParams; + registerFlagChangeListener?: RegisterFlagChangeListenerParams; + unregisterListener?: UnregisterListenerParams; } export interface EvaluateFlagParams { @@ -161,3 +172,45 @@ export interface HookExecutionPayload { evaluationDetail?: EvaluateFlagResponse; stage?: HookStage; } + +export interface HookData { + beforeEvaluation?: Record; + afterEvaluation?: Record; +} + +export interface HookErrors { + beforeEvaluation?: string; + afterEvaluation?: string; + afterTrack?: string; // client-only; server ignores this field +} + +// Server-specific command parameter types + +export interface MigrationVariationParams { + key: string; + context: LDContext; + defaultStage: string; +} + +export interface MigrationOperationParams { + operation: string; + key: string; + context: LDContext; + defaultStage: string; + payload: any; + readExecutionOrder: string; + trackLatency?: boolean; + trackErrors?: boolean; + trackConsistency?: boolean; + newEndpoint: string; + oldEndpoint: string; +} + +export interface RegisterFlagChangeListenerParams { + listenerId: string; + callbackUri: string; +} + +export interface UnregisterListenerParams { + listenerId: string; +} diff --git a/packages/tooling/contract-test-utils/src/types/ConfigParams.ts b/packages/tooling/contract-test-utils/src/types/ConfigParams.ts index 6727ff5e0b..971aa67bf0 100644 --- a/packages/tooling/contract-test-utils/src/types/ConfigParams.ts +++ b/packages/tooling/contract-test-utils/src/types/ConfigParams.ts @@ -1,6 +1,5 @@ -import { LDContext } from '@launchdarkly/js-client-sdk-common'; - -import { HookStage } from './CommandParams'; +import { HookStage } from './CommandParams.js'; +import { LDContext } from './compat.js'; export interface CreateInstanceParams { configuration: SDKConfigParams; @@ -23,6 +22,11 @@ export interface SDKConfigParams { proxy?: SDKConfigProxyParams; } +export interface ServerSDKConfigParams extends SDKConfigParams { + bigSegments?: SDKConfigBigSegmentsParams; + dataSystem?: SDKDataSystemParams; +} + export interface SDKConfigTLSParams { skipVerifyPeer?: boolean; customCAFile?: string; @@ -93,3 +97,38 @@ export interface SDKConfigWrapper { name: string; version: string; } + +// Server-specific config types + +export interface SDKConfigBigSegmentsParams { + callbackUri: string; + userCacheSize?: number; + userCacheTimeMs?: number; + statusPollIntervalMs?: number; + staleAfterMs?: number; +} + +export interface SDKDataSourceStreamingParams { + baseUri?: string; + initialRetryDelayMs?: number; +} + +export interface SDKDataSourcePollingParams { + baseUri?: string; + pollIntervalMs?: number; +} + +export interface SDKDataSystemSynchronizerParams { + streaming?: SDKDataSourceStreamingParams; + polling?: SDKDataSourcePollingParams; +} + +export interface SDKDataSystemInitializerParams { + polling?: SDKDataSourcePollingParams; +} + +export interface SDKDataSystemParams { + initializers?: SDKDataSystemInitializerParams[]; + synchronizers?: SDKDataSystemSynchronizerParams[]; + payloadFilter?: string; +} 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..c673f35786 --- /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-common and @launchdarkly/js-server-sdk-common, + * 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 LDEvaluationReason type compatible with both client and server SDKs. + */ +export interface LDEvaluationReason { + kind: string; + [key: string]: unknown; +} + +/** + * A minimal LDLogger type compatible with both client and server SDKs. + */ +export interface LDLogger { + error(...args: any[]): void; + warn(...args: any[]): void; + info(...args: any[]): void; + debug(...args: any[]): void; +} diff --git a/packages/tooling/contract-test-utils/tsconfig.client.json b/packages/tooling/contract-test-utils/tsconfig.client.json new file mode 100644 index 0000000000..57aed148f3 --- /dev/null +++ b/packages/tooling/contract-test-utils/tsconfig.client.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "src/server.ts", "src/server-side/TestHook.ts"] +} diff --git a/packages/tooling/contract-test-utils/tsconfig.json b/packages/tooling/contract-test-utils/tsconfig.json index 166160dbfa..079ac2afc6 100644 --- a/packages/tooling/contract-test-utils/tsconfig.json +++ b/packages/tooling/contract-test-utils/tsconfig.json @@ -17,5 +17,5 @@ "skipLibCheck": true }, "include": ["src/**/*"], - "exclude": ["dist", "node_modules"] + "exclude": ["dist", "node_modules", "src/client.ts", "src/client-side/TestHook.ts", "src/server.ts", "src/server-side/TestHook.ts"] } 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..fb3066bda8 --- /dev/null +++ b/packages/tooling/contract-test-utils/tsconfig.server.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + // 'got' is a devDependency; this path alias is needed because TypeScript's + // 'node' moduleResolution does not follow exports maps for ESM-only packages. + "baseUrl": ".", + "paths": { + "got": ["../../../node_modules/got/dist/source"] + } + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "src/client.ts", "src/client-side/**"] +}