From cd9f47ba8e8f1d7bee9668792aaf5515987e7c17 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Thu, 29 Oct 2026 15:02:31 -0400 Subject: [PATCH 1/2] feat: Add a shared WebSocket base to the contract test utils and use it in the browser contract tests --- .../entity/src/TestHarnessWebSocket.ts | 163 +++++++----------- .../src/client-side/TestHarnessWebSocket.ts | 143 +++++++++++++++ .../tooling/contract-test-utils/src/client.ts | 2 + 3 files changed, 204 insertions(+), 104 deletions(-) create mode 100644 packages/tooling/contract-test-utils/src/client-side/TestHarnessWebSocket.ts diff --git a/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts b/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts index be5005db62..e856808047 100644 --- a/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts +++ b/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts @@ -1,117 +1,72 @@ -import { LDLogger } from '@launchdarkly/js-client-sdk'; -import { makeLogger } from '@launchdarkly/js-contract-test-utils/client'; +import { + CreateInstanceParams, + TestHarnessWebSocket as BaseTestHarnessWebSocket, +} from '@launchdarkly/js-contract-test-utils/client'; import { ClientEntity, newSdkClientEntity, useFetchEventSource } from './ClientEntity'; -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-prereq-cycle-detection', - 'client-per-context-summaries', - 'track-hooks', - 'fdv1-fallback', - 'bootstrap', - // The FDv1 streaming data source retries every HTTP error. On the - // fetch transport an unexpected status retries after the extended - // delay of minutes, which is longer than the legacy permanent-stop - // tests watch, so those tests would pass for the wrong reason. The - // native transport cannot report a status, so the harness already - // skips those tests in native mode. - 'retry-conformance-fdv1-streaming', - ]; +const BASE_CAPABILITIES = [ + 'client-side', + 'service-endpoints', + 'tags', + 'user-type', + 'inline-context-all', + 'anonymous-redaction', + 'strongly-typed', + 'client-prereq-events', + 'client-prereq-cycle-detection', + 'client-per-context-summaries', + 'track-hooks', + 'fdv1-fallback', + 'bootstrap', + // The FDv1 streaming data source retries every HTTP error. On the + // fetch transport an unexpected status retries after the extended + // delay of minutes, which is longer than the legacy permanent-stop + // tests watch, so those tests would pass for the wrong reason. The + // native transport cannot report a status, so the harness already + // skips those tests in native mode. + 'retry-conformance-fdv1-streaming', +]; - // The native browser EventSource cannot send a POST request and hides HTTP error - // details. Only advertise the capabilities that need them when the run uses the - // fetch-based EventSource. - if (useFetchEventSource()) { - resData.capabilities.push('client-use-post'); - resData.capabilities.push('client-event-source-http-errors'); - } +// The native browser EventSource cannot send a POST request and hides HTTP error +// details. Only advertise the capabilities that need them when the run uses the +// fetch-based EventSource. +function browserCapabilities(): string[] { + return useFetchEventSource() + ? [...BASE_CAPABILITIES, 'client-use-post', 'client-event-source-http-errors'] + : BASE_CAPABILITIES; +} - break; - case 'createClient': - try { - const entity = await newSdkClientEntity(data.body); - resData.resourceUrl = `/clients/${this._clientCounter}`; - resData.status = 201; - this._entities[this._clientCounter] = entity; - this._clientCounter += 1; - } catch (e: any) { - resData.status = 500; - resData.error = e?.message ?? String(e); - } - 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}`); - } +export default class TestHarnessWebSocket extends BaseTestHarnessWebSocket { + private readonly _entities: Record = {}; + private _clientCounter = 0; - 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; - } + constructor(url: string) { + super(url, { capabilities: browserCapabilities() }); + } - this.send(resData); - }; + protected async createClient(params: CreateInstanceParams): Promise { + const entity = await newSdkClientEntity(params); + const id = String(this._clientCounter); + this._clientCounter += 1; + this._entities[id] = entity; + return id; } - disconnect() { - this._ws?.close(); + protected getCommandHandler(id: string) { + if (!Object.prototype.hasOwnProperty.call(this._entities, id)) { + return undefined; + } + return (params: Parameters[0]) => + this._entities[id].doCommand(params); } - send(data: unknown) { - this._ws?.send(JSON.stringify(data)); + protected deleteClient(id: string): boolean { + if (!Object.prototype.hasOwnProperty.call(this._entities, id)) { + return false; + } + this._entities[id].close(); + delete this._entities[id]; + return true; } } diff --git a/packages/tooling/contract-test-utils/src/client-side/TestHarnessWebSocket.ts b/packages/tooling/contract-test-utils/src/client-side/TestHarnessWebSocket.ts new file mode 100644 index 0000000000..c90985bdd5 --- /dev/null +++ b/packages/tooling/contract-test-utils/src/client-side/TestHarnessWebSocket.ts @@ -0,0 +1,143 @@ +import { makeLogger } from '../logging/makeLogger.js'; +import { CommandParams } from '../types/CommandParams.js'; +import { LDLogger } from '../types/compat.js'; +import { CreateInstanceParams } from '../types/ConfigParams.js'; + +export type CommandHandler = (params: CommandParams) => Promise; + +const DEFAULT_CAPABILITIES = [ + 'client-side', + 'service-endpoints', + 'tags', + 'user-type', + 'inline-context-all', + 'anonymous-redaction', + 'strongly-typed', + 'client-prereq-events', + 'client-prereq-cycle-detection', + 'client-per-context-summaries', + 'track-hooks', + 'fdv1-fallback', + 'bootstrap', +]; + +export interface TestHarnessWebSocketOptions { + /** Capabilities reported to the harness. Defaults to the browser list. */ + capabilities?: string[]; +} + +/** + * Browser WebSocket connection to the contract test harness. It owns the + * connect, reconnect, and command dispatch loop. Subclasses decide how clients + * are created, looked up, and deleted. + */ +export default abstract class TestHarnessWebSocket { + private _ws?: WebSocket; + protected readonly logger: LDLogger = makeLogger('TestHarnessWebSocket'); + + constructor( + private readonly _url: string, + private readonly _options: TestHarnessWebSocketOptions = {}, + ) {} + + /** + * Create a client and return the id the harness uses to address it. + * Throw to report a failed creation. + */ + protected abstract createClient(params: CreateInstanceParams): Promise; + + /** + * Return the command handler for a client, or undefined when no client has this id. + */ + protected abstract getCommandHandler(id: string): CommandHandler | undefined; + + /** + * Close and forget a client. Return false when no client has this id. + */ + protected abstract deleteClient(id: string): boolean; + + 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._options.capabilities ?? DEFAULT_CAPABILITIES; + break; + + case 'createClient': + try { + const id = await this.createClient(data.body); + resData.resourceUrl = `/clients/${id}`; + resData.status = 201; + } catch (e: any) { + resData.status = 500; + resData.error = e?.message ?? String(e); + } + break; + + case 'runCommand': { + const handler = this.getCommandHandler(data.id); + if (handler) { + try { + const body = await handler(data.body); + resData.body = body; + resData.status = body ? 200 : 204; + } catch (e: any) { + if (e?.message === 'unsupported command') { + resData.status = 400; + resData.error = e.message; + } else { + resData.status = 500; + resData.error = e?.message ?? String(e); + } + } + } else { + resData.status = 404; + this.logger.warn(`Client did not exist: ${data.id}`); + } + break; + } + + case 'deleteClient': + if (this.deleteClient(data.id)) { + resData.status = 200; + } 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/tooling/contract-test-utils/src/client.ts b/packages/tooling/contract-test-utils/src/client.ts index 80469be6b1..642e1f02cc 100644 --- a/packages/tooling/contract-test-utils/src/client.ts +++ b/packages/tooling/contract-test-utils/src/client.ts @@ -3,3 +3,5 @@ export * from './index.js'; // Client-side exports export { default as ClientSideTestHook } from './client-side/TestHook.js'; +export { default as TestHarnessWebSocket } from './client-side/TestHarnessWebSocket.js'; +export type { CommandHandler } from './client-side/TestHarnessWebSocket.js'; From 37025ee3e0c09510e57ab68d7d1e07b941ecfa9d Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 30 Oct 2026 17:25:23 -0400 Subject: [PATCH 2/2] fix: disconnect calls won't close connection in the test entity --- .../src/client-side/TestHarnessWebSocket.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 c90985bdd5..a32e1533cc 100644 --- a/packages/tooling/contract-test-utils/src/client-side/TestHarnessWebSocket.ts +++ b/packages/tooling/contract-test-utils/src/client-side/TestHarnessWebSocket.ts @@ -33,6 +33,7 @@ export interface TestHarnessWebSocketOptions { */ export default abstract class TestHarnessWebSocket { private _ws?: WebSocket; + private _intentionalClose = false; protected readonly logger: LDLogger = makeLogger('TestHarnessWebSocket'); constructor( @@ -58,14 +59,22 @@ export default abstract class TestHarnessWebSocket { connect() { this.logger.info(`Connecting to web socket.`); + this._intentionalClose = false; this._ws = new WebSocket(this._url, ['v1']); this._ws.onopen = () => { this.logger.info('Connected to websocket.'); }; this._ws.onclose = () => { + if (this._intentionalClose) { + this.logger.info('Websocket closed.'); + return; + } this.logger.info('Websocket closed. Attempting to reconnect in 1 second.'); setTimeout(() => { - this.connect(); + // disconnect() may run while the reconnect timer is pending. + if (!this._intentionalClose) { + this.connect(); + } }, 1000); }; this._ws.onerror = (err) => { @@ -134,6 +143,7 @@ export default abstract class TestHarnessWebSocket { } disconnect() { + this._intentionalClose = true; this._ws?.close(); }