From 38f081ebf04c68123cf83addefbcbfec692cac16 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 16 Sep 2024 10:45:34 -0700 Subject: [PATCH] feat: Add support for browser contract tests. (#582) This adds the contract tests, but does not yet automate running them. --- package.json | 4 +- .../contract-tests/adapter/package.json | 40 +++ .../contract-tests/adapter/src/index.ts | 112 +++++++++ .../adapter/tsconfig.eslint.json | 5 + .../contract-tests/adapter/tsconfig.json | 14 ++ .../contract-tests/adapter/tsconfig.ref.json | 7 + .../browser/contract-tests/entity/index.html | 13 + .../contract-tests/entity/package.json | 30 +++ .../contract-tests/entity/src/ClientEntity.ts | 231 ++++++++++++++++++ .../entity/src/CommandParams.ts | 157 ++++++++++++ .../contract-tests/entity/src/ConfigParams.ts | 90 +++++++ .../entity/src/TestHarnessWebSocket.ts | 93 +++++++ .../browser/contract-tests/entity/src/main.ts | 19 ++ .../contract-tests/entity/src/makeLogger.ts | 22 ++ .../contract-tests/entity/src/style.css | 96 ++++++++ .../contract-tests/entity/src/typescript.svg | 1 + .../contract-tests/entity/src/vite-env.d.ts | 1 + .../entity/tsconfig.eslint.json | 5 + .../contract-tests/entity/tsconfig.json | 23 ++ .../contract-tests/entity/tsconfig.ref.json | 7 + .../contract-tests/run-test-service.sh | 1 + .../browser/contract-tests/suppressions.txt | 12 + packages/sdk/browser/jest.config.js | 9 +- packages/sdk/browser/rollup.config.js | 10 +- packages/sdk/browser/src/index.ts | 6 + packages/sdk/browser/tsconfig.json | 1 + tsconfig.json | 6 + 27 files changed, 1001 insertions(+), 14 deletions(-) create mode 100644 packages/sdk/browser/contract-tests/adapter/package.json create mode 100644 packages/sdk/browser/contract-tests/adapter/src/index.ts create mode 100644 packages/sdk/browser/contract-tests/adapter/tsconfig.eslint.json create mode 100644 packages/sdk/browser/contract-tests/adapter/tsconfig.json create mode 100644 packages/sdk/browser/contract-tests/adapter/tsconfig.ref.json create mode 100644 packages/sdk/browser/contract-tests/entity/index.html create mode 100644 packages/sdk/browser/contract-tests/entity/package.json create mode 100644 packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts create mode 100644 packages/sdk/browser/contract-tests/entity/src/CommandParams.ts create mode 100644 packages/sdk/browser/contract-tests/entity/src/ConfigParams.ts create mode 100644 packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts create mode 100644 packages/sdk/browser/contract-tests/entity/src/main.ts create mode 100644 packages/sdk/browser/contract-tests/entity/src/makeLogger.ts create mode 100644 packages/sdk/browser/contract-tests/entity/src/style.css create mode 100644 packages/sdk/browser/contract-tests/entity/src/typescript.svg create mode 100644 packages/sdk/browser/contract-tests/entity/src/vite-env.d.ts create mode 100644 packages/sdk/browser/contract-tests/entity/tsconfig.eslint.json create mode 100644 packages/sdk/browser/contract-tests/entity/tsconfig.json create mode 100644 packages/sdk/browser/contract-tests/entity/tsconfig.ref.json create mode 100755 packages/sdk/browser/contract-tests/run-test-service.sh create mode 100644 packages/sdk/browser/contract-tests/suppressions.txt diff --git a/package.json b/package.json index 3fa5d4200..dd94fbcb1 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,9 @@ "packages/store/node-server-sdk-dynamodb", "packages/telemetry/node-server-sdk-otel", "packages/tooling/jest", - "packages/sdk/browser" + "packages/sdk/browser", + "packages/sdk/browser/contract-tests/entity", + "packages/sdk/browser/contract-tests/adapter" ], "private": true, "scripts": { diff --git a/packages/sdk/browser/contract-tests/adapter/package.json b/packages/sdk/browser/contract-tests/adapter/package.json new file mode 100644 index 000000000..13515342f --- /dev/null +++ b/packages/sdk/browser/contract-tests/adapter/package.json @@ -0,0 +1,40 @@ +{ + "name": "browser-contract-test-adapter", + "version": "1.0.0", + "description": "Adapts REST interface to a websocket for use in browsers.", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "yarn build && node dist/index.js", + "lint": "eslint ./src", + "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../../.prettierignore" + }, + "author": "", + "license": "UNLICENSED", + "dependencies": { + "body-parser": "^1.20.3", + "cors": "^2.8.5", + "express": "^4.21.0", + "ws": "^8.18.0" + }, + "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", + "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 new file mode 100644 index 000000000..5dfffa3bd --- /dev/null +++ b/packages/sdk/browser/contract-tests/adapter/src/index.ts @@ -0,0 +1,112 @@ +/* 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'; + +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(); diff --git a/packages/sdk/browser/contract-tests/adapter/tsconfig.eslint.json b/packages/sdk/browser/contract-tests/adapter/tsconfig.eslint.json new file mode 100644 index 000000000..56c9b3830 --- /dev/null +++ b/packages/sdk/browser/contract-tests/adapter/tsconfig.eslint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/sdk/browser/contract-tests/adapter/tsconfig.json b/packages/sdk/browser/contract-tests/adapter/tsconfig.json new file mode 100644 index 000000000..8e235feac --- /dev/null +++ b/packages/sdk/browser/contract-tests/adapter/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES6", + "module": "commonjs", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "moduleResolution": "node", + "outDir": "dist", + "sourceMap": true + }, + "lib": ["ES6"], + "exclude": ["**/*.test.ts", "dist", "node_modules"] +} diff --git a/packages/sdk/browser/contract-tests/adapter/tsconfig.ref.json b/packages/sdk/browser/contract-tests/adapter/tsconfig.ref.json new file mode 100644 index 000000000..34a1cb607 --- /dev/null +++ b/packages/sdk/browser/contract-tests/adapter/tsconfig.ref.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*", "package.json"], + "compilerOptions": { + "composite": true + } +} diff --git a/packages/sdk/browser/contract-tests/entity/index.html b/packages/sdk/browser/contract-tests/entity/index.html new file mode 100644 index 000000000..fd67814e1 --- /dev/null +++ b/packages/sdk/browser/contract-tests/entity/index.html @@ -0,0 +1,13 @@ + + + + + + + SDK Contract Test Service + + +
+ + + diff --git a/packages/sdk/browser/contract-tests/entity/package.json b/packages/sdk/browser/contract-tests/entity/package.json new file mode 100644 index 000000000..90b9433a1 --- /dev/null +++ b/packages/sdk/browser/contract-tests/entity/package.json @@ -0,0 +1,30 @@ +{ + "name": "browser-contract-test-service", + "private": true, + "version": "0.0.0", + "type": "module", + "description": "Contract test service implementation for @launchdarkly/js-client-sdk", + "scripts": { + "start": "vite --open=true", + "lint": "eslint ./src", + "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../../.prettierignore" + }, + "dependencies": { + "@launchdarkly/js-client-sdk": "0.0.0" + }, + "devDependencies": { + "@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", + "prettier": "^3.0.0", + "typescript": "^5.5.3", + "vite": "^5.4.1" + } +} diff --git a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts new file mode 100644 index 000000000..e7cbd756c --- /dev/null +++ b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts @@ -0,0 +1,231 @@ +import { + AutoEnvAttributes, + init, + LDClient, + LDLogger, + LDOptions, +} from '@launchdarkly/js-client-sdk'; + +import { CommandParams, CommandType, ValueType } from './CommandParams'; +import { CreateInstanceParams, SDKConfigParams } from './ConfigParams'; +import { makeLogger } from './makeLogger'; + +export const badCommandError = new Error('unsupported command'); +export const malformedCommand = new Error('command was malformed'); + +function makeSdkConfig(options: SDKConfigParams, tag: string) { + if (!options.clientSide) { + throw new Error('configuration did not include clientSide options'); + } + + const isSet = (x?: unknown) => x !== null && x !== undefined; + const maybeTime = (seconds?: number) => (isSet(seconds) ? seconds / 1000 : undefined); + + const cf: LDOptions = { + withReasons: options.clientSide.evaluationReasons, + logger: makeLogger(`${tag}.sdk`), + // useReport: options.clientSide.useReport, + // sendEventsOnlyForVariation: true, + }; + + if (options.serviceEndpoints) { + cf.streamUri = options.serviceEndpoints.streaming; + cf.baseUri = options.serviceEndpoints.polling; + cf.eventsUri = options.serviceEndpoints.events; + } + + if (options.polling) { + cf.initialConnectionMode = 'polling'; + if (options.polling.baseUri) { + cf.baseUri = options.polling.baseUri; + } + } + + // Can contain streaming and polling, if streaming is set override the initial connection + // mode. This can be removed when we add JS specific initialization that uses polling + // and then streaming. + if (options.streaming) { + if (options.streaming.baseUri) { + cf.streamUri = options.streaming.baseUri; + } + cf.initialConnectionMode = 'streaming'; + cf.streamInitialReconnectDelay = maybeTime(options.streaming.initialRetryDelayMs); + } + + if (options.events) { + if (options.events.baseUri) { + cf.eventsUri = options.events.baseUri; + } + cf.allAttributesPrivate = options.events.allAttributesPrivate; + cf.capacity = options.events.capacity; + cf.diagnosticOptOut = !options.events.enableDiagnostics; + cf.flushInterval = maybeTime(options.events.flushIntervalMs); + cf.privateAttributes = options.events.globalPrivateAttributes; + } else { + cf.sendEvents = false; + } + + if (options.tags) { + cf.applicationInfo = { + id: options.tags.applicationId, + version: options.tags.applicationVersion, + }; + } + + return cf; +} + +function makeDefaultInitialContext() { + return { kind: 'user', key: 'key-not-specified' }; +} + +export class ClientEntity { + constructor( + private readonly client: LDClient, + private readonly logger: LDLogger, + ) {} + + close() { + this.client.close(); + this.logger.info('Test ended'); + } + + async doCommand(params: CommandParams) { + this.logger.info(`Received command: ${params.command}`); + switch (params.command) { + case CommandType.EvaluateFlag: { + const evaluationParams = params.evaluate; + if (!evaluationParams) { + throw malformedCommand; + } + if (evaluationParams.detail) { + switch (evaluationParams.valueType) { + case ValueType.Bool: + return this.client.boolVariationDetail( + evaluationParams.flagKey, + evaluationParams.defaultValue as boolean, + ); + case ValueType.Int: // Intentional fallthrough. + case ValueType.Double: + return this.client.numberVariationDetail( + evaluationParams.flagKey, + evaluationParams.defaultValue as number, + ); + case ValueType.String: + return this.client.stringVariationDetail( + evaluationParams.flagKey, + evaluationParams.defaultValue as string, + ); + default: + return this.client.variationDetail( + evaluationParams.flagKey, + evaluationParams.defaultValue, + ); + } + } + switch (evaluationParams.valueType) { + case ValueType.Bool: + return { + value: this.client.boolVariation( + evaluationParams.flagKey, + evaluationParams.defaultValue as boolean, + ), + }; + case ValueType.Int: // Intentional fallthrough. + case ValueType.Double: + return { + value: this.client.numberVariation( + evaluationParams.flagKey, + evaluationParams.defaultValue as number, + ), + }; + case ValueType.String: + return { + value: this.client.stringVariation( + evaluationParams.flagKey, + evaluationParams.defaultValue as string, + ), + }; + default: + return { + value: this.client.variation(evaluationParams.flagKey, evaluationParams.defaultValue), + }; + } + } + + case CommandType.EvaluateAllFlags: + return { state: this.client.allFlags() }; + + case CommandType.IdentifyEvent: { + const identifyParams = params.identifyEvent; + if (!identifyParams) { + throw malformedCommand; + } + await this.client.identify(identifyParams.user || identifyParams.context, { + waitForNetworkResults: true, + }); + return undefined; + } + + case CommandType.CustomEvent: { + const customEventParams = params.customEvent; + if (!customEventParams) { + throw malformedCommand; + } + this.client.track( + customEventParams.eventKey, + customEventParams.data, + customEventParams.metricValue, + ); + return undefined; + } + + case CommandType.FlushEvents: + this.client.flush(); + return undefined; + + default: + throw badCommandError; + } + } +} + +export async function newSdkClientEntity(options: CreateInstanceParams) { + const logger = makeLogger(options.tag); + + logger.info(`Creating client with configuration: ${JSON.stringify(options.configuration)}`); + + const timeout = + options.configuration.startWaitTimeMs !== null && + options.configuration.startWaitTimeMs !== undefined + ? options.configuration.startWaitTimeMs + : 5000; + const sdkConfig = makeSdkConfig(options.configuration, options.tag); + const initialContext = + options.configuration.clientSide?.initialUser || + options.configuration.clientSide?.initialContext || + makeDefaultInitialContext(); + const client = init( + options.configuration.credential || 'unknown-env-id', + AutoEnvAttributes.Disabled, // TODO: Determine capability. + sdkConfig, + ); + let failed = false; + try { + await Promise.race([ + client.identify(initialContext, { waitForNetworkResults: true }), + new Promise((_resolve, reject) => { + setTimeout(reject, timeout); + }), + ]); + } catch (_) { + // we get here if waitForInitialization() rejects or if we timed out + failed = true; + } + if (failed && !options.configuration.initCanFail) { + client.close(); + throw new Error('client initialization failed'); + } + + return new ClientEntity(client, logger); +} diff --git a/packages/sdk/browser/contract-tests/entity/src/CommandParams.ts b/packages/sdk/browser/contract-tests/entity/src/CommandParams.ts new file mode 100644 index 000000000..11251d31e --- /dev/null +++ b/packages/sdk/browser/contract-tests/entity/src/CommandParams.ts @@ -0,0 +1,157 @@ +import { LDContext, LDEvaluationReason } from '@launchdarkly/js-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/browser/contract-tests/entity/src/ConfigParams.ts b/packages/sdk/browser/contract-tests/entity/src/ConfigParams.ts new file mode 100644 index 000000000..520170e82 --- /dev/null +++ b/packages/sdk/browser/contract-tests/entity/src/ConfigParams.ts @@ -0,0 +1,90 @@ +import { LDContext } from '@launchdarkly/js-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/browser/contract-tests/entity/src/TestHarnessWebSocket.ts b/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts new file mode 100644 index 000000000..d01236cb2 --- /dev/null +++ b/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts @@ -0,0 +1,93 @@ +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', + 'anonymous-redaction', + 'strongly-typed', + ]; + + 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)); + } +} diff --git a/packages/sdk/browser/contract-tests/entity/src/main.ts b/packages/sdk/browser/contract-tests/entity/src/main.ts new file mode 100644 index 000000000..671ce48d4 --- /dev/null +++ b/packages/sdk/browser/contract-tests/entity/src/main.ts @@ -0,0 +1,19 @@ +// eslint-disable-next-line prettier/prettier +import './style.css'; +import TestHarnessWebSocket from './TestHarnessWebSocket'; + +// const client = init('618959580d89aa15579acf1d', AutoEnvAttributes.Enabled); + +async function runContractTests() { + const ws = new TestHarnessWebSocket('ws://localhost:8001'); + ws.connect(); +} + +runContractTests(); + +document.querySelector('#app')!.innerHTML = ` +
+

Browser contract test service

+ +
+`; diff --git a/packages/sdk/browser/contract-tests/entity/src/makeLogger.ts b/packages/sdk/browser/contract-tests/entity/src/makeLogger.ts new file mode 100644 index 000000000..6d09061d0 --- /dev/null +++ b/packages/sdk/browser/contract-tests/entity/src/makeLogger.ts @@ -0,0 +1,22 @@ +import { LDLogger } from '@launchdarkly/js-client-sdk'; + +export function makeLogger(tag: string): LDLogger { + return { + debug(message, ...args: any[]) { + // eslint-disable-next-line no-console + console.debug(`${new Date().toISOString()} [${tag}]: ${message}`, ...args); + }, + info(message, ...args: any[]) { + // eslint-disable-next-line no-console + console.info(`${new Date().toISOString()} [${tag}]: ${message}`, ...args); + }, + warn(message, ...args: any[]) { + // eslint-disable-next-line no-console + console.warn(`${new Date().toISOString()} [${tag}]: ${message}`, ...args); + }, + error(message, ...args: any[]) { + // eslint-disable-next-line no-console + console.error(`${new Date().toISOString()} [${tag}]: ${message}`, ...args); + }, + }; +} diff --git a/packages/sdk/browser/contract-tests/entity/src/style.css b/packages/sdk/browser/contract-tests/entity/src/style.css new file mode 100644 index 000000000..f9c735024 --- /dev/null +++ b/packages/sdk/browser/contract-tests/entity/src/style.css @@ -0,0 +1,96 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.vanilla:hover { + filter: drop-shadow(0 0 2em #3178c6aa); +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/packages/sdk/browser/contract-tests/entity/src/typescript.svg b/packages/sdk/browser/contract-tests/entity/src/typescript.svg new file mode 100644 index 000000000..d91c910cc --- /dev/null +++ b/packages/sdk/browser/contract-tests/entity/src/typescript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/sdk/browser/contract-tests/entity/src/vite-env.d.ts b/packages/sdk/browser/contract-tests/entity/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/packages/sdk/browser/contract-tests/entity/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/sdk/browser/contract-tests/entity/tsconfig.eslint.json b/packages/sdk/browser/contract-tests/entity/tsconfig.eslint.json new file mode 100644 index 000000000..56c9b3830 --- /dev/null +++ b/packages/sdk/browser/contract-tests/entity/tsconfig.eslint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/sdk/browser/contract-tests/entity/tsconfig.json b/packages/sdk/browser/contract-tests/entity/tsconfig.json new file mode 100644 index 000000000..0511b9f0e --- /dev/null +++ b/packages/sdk/browser/contract-tests/entity/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/packages/sdk/browser/contract-tests/entity/tsconfig.ref.json b/packages/sdk/browser/contract-tests/entity/tsconfig.ref.json new file mode 100644 index 000000000..34a1cb607 --- /dev/null +++ b/packages/sdk/browser/contract-tests/entity/tsconfig.ref.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*", "package.json"], + "compilerOptions": { + "composite": true + } +} diff --git a/packages/sdk/browser/contract-tests/run-test-service.sh b/packages/sdk/browser/contract-tests/run-test-service.sh new file mode 100755 index 000000000..44ad8774a --- /dev/null +++ b/packages/sdk/browser/contract-tests/run-test-service.sh @@ -0,0 +1 @@ +yarn workspace browser-contract-test-adapter run start & yarn workspace browser-contract-test-service run start && kill $! diff --git a/packages/sdk/browser/contract-tests/suppressions.txt b/packages/sdk/browser/contract-tests/suppressions.txt new file mode 100644 index 000000000..6410dd06f --- /dev/null +++ b/packages/sdk/browser/contract-tests/suppressions.txt @@ -0,0 +1,12 @@ +streaming/requests/method and headers/REPORT/http +streaming/requests/URL path is computed correctly/no environment filter/base URI has no trailing slash/REPORT +streaming/requests/URL path is computed correctly/no environment filter/base URI has a trailing slash/REPORT +streaming/requests/context properties/single kind minimal/REPORT +streaming/requests/context properties/single kind with all attributes/REPORT +streaming/requests/context properties/multi-kind/REPORT +polling/requests/method and headers/REPORT/http +polling/requests/URL path is computed correctly/no environment filter/base URI has no trailing slash/REPORT +polling/requests/URL path is computed correctly/no environment filter/base URI has a trailing slash/REPORT +polling/requests/context properties/single kind minimal/REPORT +polling/requests/context properties/single kind with all attributes/REPORT +polling/requests/context properties/multi-kind/REPORT diff --git a/packages/sdk/browser/jest.config.js b/packages/sdk/browser/jest.config.js index 364918be3..1f0bda372 100644 --- a/packages/sdk/browser/jest.config.js +++ b/packages/sdk/browser/jest.config.js @@ -1,12 +1,11 @@ - export default { preset: 'ts-jest', testEnvironment: 'jest-environment-jsdom', transform: { - "^.+\\.tsx?$": "ts-jest" - // process `*.tsx` files with `ts-jest` + '^.+\\.tsx?$': 'ts-jest', + // process `*.tsx` files with `ts-jest` }, moduleNameMapper: { - '\\.(gif|ttf|eot|svg|png)$': '/test/__ mocks __/fileMock.js', + '\\.(gif|ttf|eot|svg|png)$': '/test/__ mocks __/fileMock.js', }, -} +}; diff --git a/packages/sdk/browser/rollup.config.js b/packages/sdk/browser/rollup.config.js index d78a55a79..d8fb93ed9 100644 --- a/packages/sdk/browser/rollup.config.js +++ b/packages/sdk/browser/rollup.config.js @@ -1,8 +1,8 @@ import common from '@rollup/plugin-commonjs'; +import json from '@rollup/plugin-json'; import resolve from '@rollup/plugin-node-resolve'; import terser from '@rollup/plugin-terser'; import typescript from '@rollup/plugin-typescript'; -import json from '@rollup/plugin-json'; const getSharedConfig = (format, file) => ({ input: 'src/index.ts', @@ -38,12 +38,6 @@ export default [ }, { ...getSharedConfig('cjs', 'dist/index.cjs.js'), - plugins: [ - typescript(), - common(), - resolve(), - terser(), - json(), - ], + plugins: [typescript(), common(), resolve(), terser(), json()], }, ]; diff --git a/packages/sdk/browser/src/index.ts b/packages/sdk/browser/src/index.ts index 5e25241ec..597439a38 100644 --- a/packages/sdk/browser/src/index.ts +++ b/packages/sdk/browser/src/index.ts @@ -3,6 +3,9 @@ import { LDContext, LDContextCommon, LDContextMeta, + LDEvaluationDetail, + LDEvaluationDetailTyped, + LDEvaluationReason, LDFlagSet, LDLogger, LDLogLevel, @@ -26,6 +29,9 @@ export { LDSingleKindContext, LDLogLevel, LDLogger, + LDEvaluationDetail, + LDEvaluationDetailTyped, + LDEvaluationReason, }; export function init( diff --git a/packages/sdk/browser/tsconfig.json b/packages/sdk/browser/tsconfig.json index 66b350fda..0a219e151 100644 --- a/packages/sdk/browser/tsconfig.json +++ b/packages/sdk/browser/tsconfig.json @@ -28,6 +28,7 @@ "docs", "example", "node_modules", + "contract-tests", "babel.config.js", "jest.config.ts", "jestSetupFile.ts", diff --git a/tsconfig.json b/tsconfig.json index 02923fad6..91b995e66 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -57,6 +57,12 @@ }, { "path": "./packages/sdk/browser/tsconfig.ref.json" + }, + { + "path": "./packages/sdk/browser/contract-tests/adapter/tsconfig.ref.json" + }, + { + "path": "./packages/sdk/browser/contract-tests/entity/tsconfig.ref.json" } ] }