Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<string, ClientEntity> = {};
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<string, ClientEntity> = {};
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<string> {
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<ClientEntity['doCommand']>[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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
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<unknown>;

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;
private _intentionalClose = false;
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<string>;

/**
* 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._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(() => {
// disconnect() may run while the reconnect timer is pending.
if (!this._intentionalClose) {
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._intentionalClose = true;
this._ws?.close();
}

send(data: unknown) {
this._ws?.send(JSON.stringify(data));
}
}
2 changes: 2 additions & 0 deletions packages/tooling/contract-test-utils/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading