From 0b318ef7f4cf4581e0ab8da98c51f202085a8b2f Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 7 Jul 2026 12:01:30 -0400 Subject: [PATCH 1/3] ci(vue-sdk): add contract tests --- .github/workflows/vue.yml | 105 ++++- package.json | 3 +- packages/sdk/vue/contract-tests/index.html | 12 + .../sdk/vue/contract-tests/open-browser.mjs | 42 ++ packages/sdk/vue/contract-tests/package.json | 33 ++ .../vue/contract-tests/run-test-service.sh | 3 + .../vue/contract-tests/src/ClientEntity.ts | 398 ++++++++++++++++++ .../src/TestHarnessWebSocket.ts | 99 +++++ packages/sdk/vue/contract-tests/src/main.ts | 14 + .../sdk/vue/contract-tests/src/vite-env.d.ts | 1 + .../testharness-suppressions-fdv2.txt | 37 ++ .../testharness-suppressions.txt | 18 + packages/sdk/vue/contract-tests/tsconfig.json | 23 + 13 files changed, 786 insertions(+), 2 deletions(-) create mode 100644 packages/sdk/vue/contract-tests/index.html create mode 100644 packages/sdk/vue/contract-tests/open-browser.mjs create mode 100644 packages/sdk/vue/contract-tests/package.json create mode 100755 packages/sdk/vue/contract-tests/run-test-service.sh create mode 100644 packages/sdk/vue/contract-tests/src/ClientEntity.ts create mode 100644 packages/sdk/vue/contract-tests/src/TestHarnessWebSocket.ts create mode 100644 packages/sdk/vue/contract-tests/src/main.ts create mode 100644 packages/sdk/vue/contract-tests/src/vite-env.d.ts create mode 100644 packages/sdk/vue/contract-tests/testharness-suppressions-fdv2.txt create mode 100644 packages/sdk/vue/contract-tests/testharness-suppressions.txt create mode 100644 packages/sdk/vue/contract-tests/tsconfig.json 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..54139f3d27 --- /dev/null +++ b/packages/sdk/vue/contract-tests/src/ClientEntity.ts @@ -0,0 +1,398 @@ +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 { + const result = await client.start({ timeout: timeout / 1000 }); + 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..f46cb0bb6b --- /dev/null +++ b/packages/sdk/vue/contract-tests/src/TestHarnessWebSocket.ts @@ -0,0 +1,99 @@ +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', + 'evaluation-hooks', + 'track-hooks', + '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"] +} From d6bf95f858182a70a2b441ab689bbe732344b08d Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Wed, 22 Jul 2026 14:05:31 -0400 Subject: [PATCH 2/3] test: add more contract test capabilities --- packages/sdk/vue/contract-tests/src/TestHarnessWebSocket.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/sdk/vue/contract-tests/src/TestHarnessWebSocket.ts b/packages/sdk/vue/contract-tests/src/TestHarnessWebSocket.ts index f46cb0bb6b..bfd4242b6e 100644 --- a/packages/sdk/vue/contract-tests/src/TestHarnessWebSocket.ts +++ b/packages/sdk/vue/contract-tests/src/TestHarnessWebSocket.ts @@ -44,8 +44,10 @@ export default class TestHarnessWebSocket { 'strongly-typed', 'client-prereq-events', 'client-per-context-summaries', + 'client-prereq-cycle-detection', 'evaluation-hooks', 'track-hooks', + 'secure-mode-hash', 'flag-change-listeners', ]; From 7c1d47657467a3fff3b96865b21c21cde6136f73 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Thu, 23 Jul 2026 11:47:35 -0400 Subject: [PATCH 3/3] fix: secure hash contract tests --- packages/sdk/vue/contract-tests/src/ClientEntity.ts | 8 +++++++- .../tooling/contract-test-utils/src/types/ConfigParams.ts | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/sdk/vue/contract-tests/src/ClientEntity.ts b/packages/sdk/vue/contract-tests/src/ClientEntity.ts index 54139f3d27..13f6041630 100644 --- a/packages/sdk/vue/contract-tests/src/ClientEntity.ts +++ b/packages/sdk/vue/contract-tests/src/ClientEntity.ts @@ -381,7 +381,13 @@ export async function newSdkClientEntity(options: CreateInstanceParams) { let failed = false; try { - const result = await client.start({ timeout: timeout / 1000 }); + // 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; } 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 {