diff --git a/.github/workflows/vue.yml b/.github/workflows/vue.yml
index 7c22061e37..158536a8b2 100644
--- a/.github/workflows/vue.yml
+++ b/.github/workflows/vue.yml
@@ -25,4 +25,107 @@ jobs:
should_build_docs: false
# TODO(scaffold): run-example job arrives when examples/getting-started lands
- # TODO(scaffold): contract-tests job arrives when contract-tests land
+
+ contract-tests:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ - uses: ./actions/setup-yarn
+ - name: Install contract test dependencies
+ env:
+ YARN_ENABLE_IMMUTABLE_INSTALLS: 'false'
+ run: yarn workspaces focus @launchdarkly/vue-contract-test-service
+
+ - name: Build the SDK
+ run: yarn workspaces foreach -pR --topological-dev --from '@launchdarkly/vue-client-sdk' run build
+
+ - name: Install Playwright browsers
+ run: yarn workspace @launchdarkly/vue-contract-test-service install-playwright-browsers
+
+ - name: Build shared contract test utils
+ run: yarn workspaces foreach -pR --topological-dev --from '@launchdarkly/js-contract-test-utils' run build
+
+ - name: Build contract test entity (Vue app)
+ run: yarn workspace @launchdarkly/vue-contract-test-service run build
+
+ - name: Start contract test adapter in background
+ run: |
+ yarn workspace @launchdarkly/vue-contract-test-service run start:adapter > /tmp/adapter.log 2>&1 &
+ echo $! > /tmp/adapter.pid
+
+ - name: Serve Vue app with http-server
+ run: |
+ npx http-server packages/sdk/vue/contract-tests/dist -p 5173 --cors > /tmp/http-server.log 2>&1 &
+ echo $! > /tmp/http-server.pid
+
+ - name: Wait for services to be ready
+ run: |
+ echo "Waiting for adapter on port 8001..."
+ for i in {1..30}; do
+ if nc -z localhost 8001; then
+ echo "Adapter WebSocket ready"
+ break
+ fi
+ if [ $i -eq 30 ]; then
+ echo "Timeout waiting for adapter"
+ cat /tmp/adapter.log
+ exit 1
+ fi
+ sleep 1
+ done
+
+ echo "Waiting for HTTP server on port 5173..."
+ for i in {1..30}; do
+ if curl -s http://localhost:5173 > /dev/null; then
+ echo "HTTP server ready"
+ break
+ fi
+ if [ $i -eq 30 ]; then
+ echo "Timeout waiting for HTTP server"
+ cat /tmp/http-server.log
+ exit 1
+ fi
+ sleep 1
+ done
+
+ - name: Open Vue app in headless Chromium
+ run: |
+ node packages/sdk/vue/contract-tests/open-browser.mjs http://localhost:5173 > /tmp/playwright.log 2>&1 &
+ echo $! > /tmp/playwright.pid
+ sleep 5 # Give the browser time to initialize and connect via WebSocket
+
+ - name: Run contract tests (FDv1)
+ uses: launchdarkly/gh-actions/actions/contract-tests@a848aec9c87c29470093b22154107b83a7696374
+ with:
+ test_service_port: 8000
+ token: ${{ secrets.GITHUB_TOKEN }}
+ stop_service: 'false'
+ extra_params: '--skip-from=${{ github.workspace }}/packages/sdk/vue/contract-tests/testharness-suppressions.txt'
+
+ - name: Run contract tests (FDv2)
+ uses: launchdarkly/gh-actions/actions/contract-tests@a848aec9c87c29470093b22154107b83a7696374
+ with:
+ test_service_port: 8000
+ token: ${{ secrets.GITHUB_TOKEN }}
+ version: v3
+ extra_params: '--skip-from=${{ github.workspace }}/packages/sdk/vue/contract-tests/testharness-suppressions-fdv2.txt'
+
+ - name: Print logs on failure
+ if: failure()
+ run: |
+ echo "=== Adapter Log ==="
+ cat /tmp/adapter.log || echo "No adapter log"
+ echo "=== HTTP Server Log ==="
+ cat /tmp/http-server.log || echo "No http-server log"
+ echo "=== Playwright Log ==="
+ cat /tmp/playwright.log || echo "No playwright log"
+
+ - name: Cleanup contract test services
+ if: always()
+ run: |
+ [ -f /tmp/playwright.pid ] && kill $(cat /tmp/playwright.pid) || true
+ [ -f /tmp/http-server.pid ] && kill $(cat /tmp/http-server.pid) || true
+ [ -f /tmp/adapter.pid ] && kill $(cat /tmp/adapter.pid) || true
+ pkill -f "playwright" || true
+ pkill -f "http-server" || true
+ pkill -f "sdk-testharness-server" || true
diff --git a/package.json b/package.json
index a469d4a5d7..b319ad40b5 100644
--- a/package.json
+++ b/package.json
@@ -67,7 +67,8 @@
"packages/sdk/browser/example-fdv2",
"packages/sdk/openfeature-node-server",
"packages/sdk/openfeature-node-server/examples/getting-started",
- "packages/sdk/vue"
+ "packages/sdk/vue",
+ "packages/sdk/vue/contract-tests"
],
"private": true,
"scripts": {
diff --git a/packages/sdk/vue/contract-tests/index.html b/packages/sdk/vue/contract-tests/index.html
new file mode 100644
index 0000000000..29ef599a68
--- /dev/null
+++ b/packages/sdk/vue/contract-tests/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Vue SDK Contract Test Service
+
+
+
+
+
+
diff --git a/packages/sdk/vue/contract-tests/open-browser.mjs b/packages/sdk/vue/contract-tests/open-browser.mjs
new file mode 100644
index 0000000000..ce63f7f843
--- /dev/null
+++ b/packages/sdk/vue/contract-tests/open-browser.mjs
@@ -0,0 +1,42 @@
+#!/usr/bin/env node
+
+/**
+ * Opens a headless browser and navigates to the contract test entity page.
+ * Keeps the browser open until the process is terminated.
+ *
+ * Usage: node open-browser.mjs [url]
+ * Default URL: http://localhost:5173
+ */
+
+import { chromium } from 'playwright';
+
+const url = process.argv[2] || 'http://localhost:5173';
+
+console.log(`Opening headless browser at ${url}...`);
+
+const browser = await chromium.launch({
+ headless: true,
+ args: ['--no-sandbox', '--disable-setuid-sandbox']
+});
+
+const context = await browser.newContext();
+const page = await context.newPage();
+
+// Log console messages from the browser
+page.on('console', (msg) => {
+ console.log(`[Browser Console] ${msg.type()}: ${msg.text()}`);
+});
+
+// Log page errors
+page.on('pageerror', (error) => {
+ console.error(`[Browser Error] ${error.message}`);
+});
+
+await page.goto(url);
+
+console.log('Browser is open and running. Press Ctrl+C to close.');
+
+// Keep the process alive
+await new Promise(() => {
+ // Intentionally never resolve - keeps browser open until process is killed
+});
diff --git a/packages/sdk/vue/contract-tests/package.json b/packages/sdk/vue/contract-tests/package.json
new file mode 100644
index 0000000000..850121d767
--- /dev/null
+++ b/packages/sdk/vue/contract-tests/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "@launchdarkly/vue-contract-test-service",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "description": "Contract test service implementation for @launchdarkly/vue-client-sdk",
+ "scripts": {
+ "install-playwright-browsers": "playwright install --with-deps chromium",
+ "start": "tsc --noEmit && vite --open=true",
+ "start:headless": "tsc --noEmit && vite",
+ "build": "tsc --noEmit && vite build",
+ "lint": "eslint ./src",
+ "start:adapter": "sdk-testharness-server adapter"
+ },
+ "dependencies": {
+ "@launchdarkly/js-client-sdk": "workspace:^",
+ "@launchdarkly/js-contract-test-utils": "workspace:^",
+ "@launchdarkly/vue-client-sdk": "workspace:^",
+ "vue": "^3.2.36"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.0.0",
+ "eslint": "^9.0.0",
+ "eslint-import-resolver-typescript": "^4.0.0",
+ "eslint-plugin-import-x": "^4.0.0",
+ "eslint-plugin-jest": "^28.0.0",
+ "globals": "^16.0.0",
+ "playwright": "^1.49.1",
+ "typescript": "^5.5.3",
+ "typescript-eslint": "^8.0.0",
+ "vite": "^5.4.1"
+ }
+}
diff --git a/packages/sdk/vue/contract-tests/run-test-service.sh b/packages/sdk/vue/contract-tests/run-test-service.sh
new file mode 100755
index 0000000000..2c70ef3ad0
--- /dev/null
+++ b/packages/sdk/vue/contract-tests/run-test-service.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+yarn workspace @launchdarkly/vue-contract-test-service run start:adapter & yarn workspace @launchdarkly/vue-contract-test-service run start && kill $!
diff --git a/packages/sdk/vue/contract-tests/src/ClientEntity.ts b/packages/sdk/vue/contract-tests/src/ClientEntity.ts
new file mode 100644
index 0000000000..13f6041630
--- /dev/null
+++ b/packages/sdk/vue/contract-tests/src/ClientEntity.ts
@@ -0,0 +1,404 @@
+import {
+ InitializerEntry,
+ LDLogger,
+ LDOptions,
+ ModeDefinition,
+ SynchronizerEntry,
+} from '@launchdarkly/js-client-sdk';
+import {
+ CommandParams,
+ CommandType,
+ CreateInstanceParams,
+ makeLogger,
+ SDKConfigDataInitializer,
+ SDKConfigDataSynchronizer,
+ SDKConfigModeDefinition,
+ SDKConfigParams,
+ ClientSideTestHook as TestHook,
+ ValueType,
+} from '@launchdarkly/js-contract-test-utils/client';
+import { createClient, type LDVueClient } from '@launchdarkly/vue-client-sdk';
+
+export const badCommandError = new Error('unsupported command');
+export const malformedCommand = new Error('command was malformed');
+
+function translateInitializer(init: SDKConfigDataInitializer): InitializerEntry | undefined {
+ if (init.polling) {
+ return {
+ type: 'polling',
+ ...(init.polling.pollIntervalMs !== undefined && {
+ pollInterval: init.polling.pollIntervalMs / 1000,
+ }),
+ ...(init.polling.baseUri && {
+ endpoints: { pollingBaseUri: init.polling.baseUri },
+ }),
+ };
+ }
+ return undefined;
+}
+
+function translateSynchronizer(sync: SDKConfigDataSynchronizer): SynchronizerEntry | undefined {
+ if (sync.streaming) {
+ return {
+ type: 'streaming',
+ ...(sync.streaming.initialRetryDelayMs !== undefined && {
+ initialReconnectDelay: sync.streaming.initialRetryDelayMs / 1000,
+ }),
+ ...(sync.streaming.baseUri && {
+ endpoints: { streamingBaseUri: sync.streaming.baseUri },
+ }),
+ };
+ }
+ if (sync.polling) {
+ return {
+ type: 'polling',
+ ...(sync.polling.pollIntervalMs !== undefined && {
+ pollInterval: sync.polling.pollIntervalMs / 1000,
+ }),
+ ...(sync.polling.baseUri && {
+ endpoints: { pollingBaseUri: sync.polling.baseUri },
+ }),
+ };
+ }
+ return undefined;
+}
+
+function translateModeDefinition(modeDef: SDKConfigModeDefinition): ModeDefinition {
+ const initializers: InitializerEntry[] = (modeDef.initializers ?? [])
+ .map(translateInitializer)
+ .filter((x): x is InitializerEntry => x !== undefined);
+
+ const synchronizers: SynchronizerEntry[] = (modeDef.synchronizers ?? [])
+ .map(translateSynchronizer)
+ .filter((x): x is SynchronizerEntry => x !== undefined);
+
+ return { initializers, synchronizers };
+}
+
+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 ?? undefined,
+ diagnosticOptOut: true,
+ disableCache: true,
+ };
+
+ if (options.serviceEndpoints) {
+ cf.streamUri = options.serviceEndpoints.streaming;
+ cf.baseUri = options.serviceEndpoints.polling;
+ cf.eventsUri = options.serviceEndpoints.events;
+ }
+
+ if (options.dataSystem?.payloadFilter) {
+ cf.payloadFilterKey = options.dataSystem.payloadFilter;
+ }
+
+ if (options.dataSystem) {
+ const dataSystem: any = {};
+
+ // Helper to apply endpoint overrides from a mode definition to global URIs.
+ const applyEndpointOverrides = (modeDef: SDKConfigModeDefinition) => {
+ (modeDef.synchronizers ?? []).forEach((sync) => {
+ if (sync.streaming?.baseUri) {
+ cf.streamUri = sync.streaming.baseUri;
+ cf.streamInitialReconnectDelay = maybeTime(sync.streaming.initialRetryDelayMs);
+ }
+ if (sync.polling?.baseUri) {
+ cf.baseUri = sync.polling.baseUri;
+ }
+ });
+ (modeDef.initializers ?? []).forEach((init) => {
+ if (init.polling?.baseUri) {
+ cf.baseUri = init.polling.baseUri;
+ }
+ });
+ };
+
+ if (options.dataSystem.connectionModeConfig) {
+ const connMode = options.dataSystem.connectionModeConfig;
+ dataSystem.automaticModeSwitching = connMode.initialConnectionMode
+ ? { type: 'manual', initialConnectionMode: connMode.initialConnectionMode }
+ : false;
+
+ if (connMode.customConnectionModes) {
+ const connectionModes: Record = {};
+ Object.entries(connMode.customConnectionModes).forEach(([modeName, modeDef]) => {
+ connectionModes[modeName] = translateModeDefinition(modeDef);
+ applyEndpointOverrides(modeDef);
+ });
+ dataSystem.connectionModes = connectionModes;
+ }
+ } else if (options.dataSystem.initializers || options.dataSystem.synchronizers) {
+ // Top-level initializers/synchronizers (no connection modes). Wrap them
+ // into a single 'streaming' connection mode for the Vue SDK.
+ const modeDef: SDKConfigModeDefinition = {
+ initializers: options.dataSystem.initializers,
+ synchronizers: options.dataSystem.synchronizers,
+ };
+ dataSystem.automaticModeSwitching = {
+ type: 'manual',
+ initialConnectionMode: 'streaming',
+ };
+ dataSystem.connectionModes = {
+ streaming: translateModeDefinition(modeDef),
+ };
+ applyEndpointOverrides(modeDef);
+ }
+
+ (cf as any).dataSystem = dataSystem;
+ } else {
+ if (options.polling) {
+ if (options.polling.baseUri) {
+ cf.baseUri = options.polling.baseUri;
+ }
+ }
+
+ if (options.streaming) {
+ if (options.streaming.baseUri) {
+ cf.streamUri = options.streaming.baseUri;
+ }
+ cf.streaming = true;
+ 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,
+ };
+ }
+
+ if (options.hooks) {
+ cf.hooks = TestHook.forClient(options.hooks.hooks);
+ }
+
+ cf.fetchGoals = false;
+
+ return cf;
+}
+
+function makeDefaultInitialContext() {
+ return { kind: 'user', key: 'key-not-specified' };
+}
+
+type FlagChangeListener = (...args: unknown[]) => void;
+
+export class ClientEntity {
+ private readonly _listeners = new Map();
+
+ constructor(
+ private readonly _client: LDVueClient,
+ 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);
+ 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;
+
+ case CommandType.RegisterFlagChangeListener: {
+ const pr = params.registerFlagChangeListener;
+ if (!pr) {
+ throw malformedCommand;
+ }
+ const existing = this._listeners.get(pr.listenerId);
+ if (existing) {
+ this._client.off('change', existing);
+ }
+ const handler: FlagChangeListener = (...args) => {
+ const flagKeys = Array.isArray(args[1]) ? (args[1] as string[]) : [];
+ flagKeys.forEach((flagKey) => {
+ fetch(pr.callbackUri, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ listenerId: pr.listenerId, flagKey }),
+ }).catch(() => {});
+ });
+ };
+ this._listeners.set(pr.listenerId, handler);
+ this._client.on('change', handler);
+ return undefined;
+ }
+
+ case CommandType.UnregisterListener: {
+ const pu = params.unregisterListener;
+ if (!pu) {
+ throw malformedCommand;
+ }
+ const handler = this._listeners.get(pu.listenerId);
+ if (handler) {
+ this._client.off('change', handler);
+ this._listeners.delete(pu.listenerId);
+ }
+ 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();
+
+ // Exercise the Vue SDK's client wrapper directly. start() resolves to the initialization result.
+ const client = createClient(
+ options.configuration.credential || 'unknown-env-id',
+ initialContext,
+ sdkConfig,
+ );
+
+ let failed = false;
+ try {
+ // Forward a harness-supplied secure mode hash as the `h` query parameter, only when
+ // the harness actually configured one.
+ const secureModeHash = options.configuration.clientSide?.hash;
+ const result = await client.start({
+ timeout: timeout / 1000,
+ ...(secureModeHash !== undefined && { identifyOptions: { hash: secureModeHash } }),
+ });
+ if (result.status !== 'complete') {
+ failed = true;
+ }
+ } catch (_) {
+ // start() is documented not to reject, but guard defensively.
+ 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/vue/contract-tests/src/TestHarnessWebSocket.ts b/packages/sdk/vue/contract-tests/src/TestHarnessWebSocket.ts
new file mode 100644
index 0000000000..bfd4242b6e
--- /dev/null
+++ b/packages/sdk/vue/contract-tests/src/TestHarnessWebSocket.ts
@@ -0,0 +1,101 @@
+import { makeLogger } from '@launchdarkly/js-contract-test-utils/client';
+import type { LDLogger } from '@launchdarkly/vue-client-sdk';
+
+import { ClientEntity, newSdkClientEntity } 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',
+ 'inline-context-all',
+ 'anonymous-redaction',
+ 'strongly-typed',
+ 'client-prereq-events',
+ 'client-per-context-summaries',
+ 'client-prereq-cycle-detection',
+ 'evaluation-hooks',
+ 'track-hooks',
+ 'secure-mode-hash',
+ 'flag-change-listeners',
+ ];
+
+ 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/vue/contract-tests/src/main.ts b/packages/sdk/vue/contract-tests/src/main.ts
new file mode 100644
index 0000000000..58f82d4acd
--- /dev/null
+++ b/packages/sdk/vue/contract-tests/src/main.ts
@@ -0,0 +1,14 @@
+import TestHarnessWebSocket from './TestHarnessWebSocket';
+
+function runContractTests() {
+ const ws = new TestHarnessWebSocket('ws://localhost:8001');
+ ws.connect();
+}
+
+runContractTests();
+
+document.querySelector('#app')!.innerHTML = `
+
+
Vue SDK contract test service
+
+`;
diff --git a/packages/sdk/vue/contract-tests/src/vite-env.d.ts b/packages/sdk/vue/contract-tests/src/vite-env.d.ts
new file mode 100644
index 0000000000..11f02fe2a0
--- /dev/null
+++ b/packages/sdk/vue/contract-tests/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/packages/sdk/vue/contract-tests/testharness-suppressions-fdv2.txt b/packages/sdk/vue/contract-tests/testharness-suppressions-fdv2.txt
new file mode 100644
index 0000000000..5ed59b91a8
--- /dev/null
+++ b/packages/sdk/vue/contract-tests/testharness-suppressions-fdv2.txt
@@ -0,0 +1,37 @@
+# Tests in this file will be skipped by the LaunchDarkly SDK test harness running
+# the FDv2-feature branch against the Vue client-side SDK. Add a path per line.
+# Lines beginning with '#' are comments.
+
+# The tags tests below cover edge cases where applicationId/applicationVersion are null, empty, or
+# contain only valid characters that happen to fill a max-length field. The browser SDK strips these
+# values from the X-LaunchDarkly-Tags header rather than sending an empty/underscore-only tag, which
+# diverges from what the harness expects. This is a pre-existing gap shared with the FDv1 suppressions.
+tags/stream requests/{"applicationId":null,"applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"}
+tags/stream requests/{"applicationId":null,"applicationVersion":"________________________________________________________________"}
+tags/stream requests/{"applicationId":"","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"}
+tags/stream requests/{"applicationId":"","applicationVersion":"________________________________________________________________"}
+tags/stream requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":null}
+tags/stream requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":""}
+tags/stream requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"}
+tags/stream requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":"________________________________________________________________"}
+tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":null}
+tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":""}
+tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"}
+tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":"________________________________________________________________"}
+
+# Same tag-stripping gap surfaced against poll requests by the FDv2 harness.
+tags/poll requests/{"applicationId":null,"applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"}
+tags/poll requests/{"applicationId":null,"applicationVersion":"________________________________________________________________"}
+tags/poll requests/{"applicationId":"","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"}
+tags/poll requests/{"applicationId":"","applicationVersion":"________________________________________________________________"}
+tags/poll requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":null}
+tags/poll requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":""}
+tags/poll requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"}
+tags/poll requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":"________________________________________________________________"}
+tags/poll requests/{"applicationId":"________________________________________________________________","applicationVersion":null}
+tags/poll requests/{"applicationId":"________________________________________________________________","applicationVersion":""}
+tags/poll requests/{"applicationId":"________________________________________________________________","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"}
+tags/poll requests/{"applicationId":"________________________________________________________________","applicationVersion":"________________________________________________________________"}
+
+# Disallowed characters in applicationId/applicationVersion: same underlying tag-validation gap.
+tags/disallowed characters
diff --git a/packages/sdk/vue/contract-tests/testharness-suppressions.txt b/packages/sdk/vue/contract-tests/testharness-suppressions.txt
new file mode 100644
index 0000000000..a4b3ab831b
--- /dev/null
+++ b/packages/sdk/vue/contract-tests/testharness-suppressions.txt
@@ -0,0 +1,18 @@
+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
+tags/stream requests/{"applicationId":null,"applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"}
+tags/stream requests/{"applicationId":null,"applicationVersion":"________________________________________________________________"}
+tags/stream requests/{"applicationId":"","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"}
+tags/stream requests/{"applicationId":"","applicationVersion":"________________________________________________________________"}
+tags/stream requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":null}
+tags/stream requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":""}
+tags/stream requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"}
+tags/stream requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":"________________________________________________________________"}
+tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":null}
+tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":""}
+tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"}
+tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":"________________________________________________________________"}
\ No newline at end of file
diff --git a/packages/sdk/vue/contract-tests/tsconfig.json b/packages/sdk/vue/contract-tests/tsconfig.json
new file mode 100644
index 0000000000..0511b9f0e0
--- /dev/null
+++ b/packages/sdk/vue/contract-tests/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/tooling/contract-test-utils/src/types/ConfigParams.ts b/packages/tooling/contract-test-utils/src/types/ConfigParams.ts
index 4d9e22891a..b313cc3ef1 100644
--- a/packages/tooling/contract-test-utils/src/types/ConfigParams.ts
+++ b/packages/tooling/contract-test-utils/src/types/ConfigParams.ts
@@ -100,6 +100,12 @@ export interface SDKConfigClientSideParams {
evaluationReasons?: boolean;
useReport?: boolean;
includeEnvironmentAttributes?: boolean;
+ /**
+ * A pre-computed secure mode hash supplied by the test harness. When present, the
+ * client-side SDK includes it as the `h` query parameter on streaming/polling requests.
+ * The SDK does not compute this value; it forwards it.
+ */
+ hash?: string;
}
export interface SDKConfigEvaluationHookData {