From cf3cba46b354f2505c7dac08371ff21bfd816153 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Mon, 6 Apr 2026 10:41:51 -0400 Subject: [PATCH 1/3] chore: adds DataSystem and FDv2 contract test service functionality --- Makefile | 29 ++- .../launchdarkly/sdktest/Representations.java | 32 ++++ .../launchdarkly/sdktest/SdkClientEntity.java | 171 ++++++++++++++++-- .../sdk/android/FDv2ChangeSetTranslator.java | 15 +- testharness-suppressions-fdv2.txt | 15 ++ 5 files changed, 235 insertions(+), 27 deletions(-) create mode 100644 testharness-suppressions-fdv2.txt diff --git a/Makefile b/Makefile index a53cb921..a3ef4777 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,8 @@ -TEST_HARNESS_PARAMS= -skip "events/disabling" -status-timeout 60 -# can add temporary test skips etc. here -# Currently we are skipping the "events/disabling" tests because the Android SDK has no way to -# disable events. That wasn't an issue earlier because the "events/disabling" tests were getting -# automatically skipped by sdk-test-harness for a different reason: they rely on the -# ServiceEndpoints API, which the Android SDK didn't previously support. +SUPPRESSION_FILE=testharness-suppressions.txt +SUPPRESSION_FILE_FDV2=testharness-suppressions-fdv2.txt + +TEST_HARNESS_PARAMS_V2= -status-timeout 60 +TEST_HARNESS_PARAMS_V3= -status-timeout 60 build-contract-tests: @cd contract-tests && ../gradlew --no-daemon -s assembleDebug -PdisablePreDex @@ -14,10 +13,24 @@ start-emulator: start-contract-test-service: @scripts/start-test-service.sh +# Note that only the last version of the tests have the stop-service-at-end flag set, so the contract test service will be stopped after the tests are run. run-contract-tests: + @echo "Running SDK contract test v2..." @curl $${GITHUB_TOKEN:+ -H "Authorization: Token $${GITHUB_TOKEN}"} \ - -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/main/downloader/run.sh \ - | VERSION=v2 PARAMS="-url http://localhost:8001 -host 10.0.2.2 -skip-from testharness-suppressions.txt -debug $(TEST_HARNESS_PARAMS)" sh + -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/v2/downloader/run.sh \ + | VERSION=v2 PARAMS="-url http://localhost:8001 -host 10.0.2.2 -debug -stop-service-at-end -skip-from $(SUPPRESSION_FILE) $(TEST_HARNESS_PARAMS_V2)" sh + +# Uncomment this, update v3 version, and replace existing run-contract-tests once sdk-test-harness releases a version that includes FDv2 client contract tests. +# +# run-contract-tests: +# @echo "Running SDK contract test v2..." +# @curl $${GITHUB_TOKEN:+ -H "Authorization: Token $${GITHUB_TOKEN}"} \ +# -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/v2/downloader/run.sh \ +# | VERSION=v2 PARAMS="-url http://localhost:8001 -host 10.0.2.2 -debug -skip-from $(SUPPRESSION_FILE) $(TEST_HARNESS_PARAMS_V2)" sh +# @echo "Running SDK contract test v3..." +# @curl $${GITHUB_TOKEN:+ -H "Authorization: Token $${GITHUB_TOKEN}"} \ +# -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/v3.0.0-alpha.4/downloader/run.sh \ +# | VERSION=v3.0.0-alpha.4 PARAMS="-url http://localhost:8001 -host 10.0.2.2 -debug -stop-service-at-end -skip-from $(SUPPRESSION_FILE_FDV2) $(TEST_HARNESS_PARAMS_V3)" sh contract-tests: build-contract-tests start-emulator start-contract-test-service run-contract-tests diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java index f66cb4aa..038ac86b 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java @@ -39,6 +39,7 @@ public static class SdkConfigParams { SdkConfigClientSideParams clientSide; SdkConfigServiceEndpointParams serviceEndpoints; SdkConfigHookParams hooks; + SdkConfigDataSystemParams dataSystem; } public static class SdkConfigStreamParams { @@ -102,6 +103,37 @@ public static class HookErrors { String afterTrack; } + public static class SdkConfigDataSystemParams { + Boolean useDefaultDataSystem; + SdkConfigConnectionModeConfig connectionModeConfig; + /** + * FDv2 / data-system tests: pipelines when {@link #connectionModeConfig} does not define + * {@link SdkConfigConnectionModeConfig#customConnectionModes}. If both are present, custom + * connection modes take precedence and these lists are ignored. + */ + List initializers; + List synchronizers; + } + + public static class SdkConfigConnectionModeConfig { + String initialConnectionMode; + Map customConnectionModes; + } + + public static class SdkConfigModeDefinition { + List initializers; + List synchronizers; + } + + public static class SdkConfigDataInitializer { + SdkConfigPollParams polling; + } + + public static class SdkConfigDataSynchronizer { + SdkConfigStreamParams streaming; + SdkConfigPollParams polling; + } + public static class CommandParams { String command; EvaluateFlagParams evaluate; diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java index 87459246..2294f42d 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java @@ -9,16 +9,27 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.android.Components; import com.launchdarkly.sdk.android.ConfigHelper; +import com.launchdarkly.sdk.android.ConnectionMode; +import com.launchdarkly.sdk.android.DataSystemComponents; import com.launchdarkly.sdk.android.LaunchDarklyException; import com.launchdarkly.sdk.android.LDClient; import com.launchdarkly.sdk.android.LDConfig; import com.launchdarkly.sdk.android.integrations.ApplicationInfoBuilder; +import com.launchdarkly.sdk.android.integrations.AutomaticModeSwitchingConfig; +import com.launchdarkly.sdk.android.integrations.ConnectionModeBuilder; +import com.launchdarkly.sdk.android.integrations.DataSystemBuilder; import com.launchdarkly.sdk.android.integrations.EventProcessorBuilder; import com.launchdarkly.sdk.android.integrations.Hook; import com.launchdarkly.sdk.android.integrations.PollingDataSourceBuilder; +import com.launchdarkly.sdk.android.integrations.PollingInitializerBuilder; +import com.launchdarkly.sdk.android.integrations.PollingSynchronizerBuilder; import com.launchdarkly.sdk.android.integrations.StreamingDataSourceBuilder; +import com.launchdarkly.sdk.android.integrations.StreamingSynchronizerBuilder; import com.launchdarkly.sdk.android.integrations.ServiceEndpointsBuilder; +import com.launchdarkly.sdk.android.subsystems.DataSourceBuilder; +import com.launchdarkly.sdk.android.subsystems.Initializer; +import com.launchdarkly.sdk.android.subsystems.Synchronizer; import com.launchdarkly.sdk.json.JsonSerialization; import com.launchdarkly.sdktest.Representations.CommandParams; @@ -37,6 +48,11 @@ import com.launchdarkly.sdktest.Representations.HookErrors; import com.launchdarkly.sdktest.Representations.IdentifyEventParams; import com.launchdarkly.sdktest.Representations.SdkConfigParams; +import com.launchdarkly.sdktest.Representations.SdkConfigDataSystemParams; +import com.launchdarkly.sdktest.Representations.SdkConfigConnectionModeConfig; +import com.launchdarkly.sdktest.Representations.SdkConfigModeDefinition; +import com.launchdarkly.sdktest.Representations.SdkConfigDataInitializer; +import com.launchdarkly.sdktest.Representations.SdkConfigDataSynchronizer; import android.app.Application; @@ -268,26 +284,29 @@ private LDConfig buildSdkConfig(SdkConfigParams params, LDLogAdapter logAdapter, // to be affected by each other's cached flag values. ConfigHelper.configureIsolatedInMemoryPersistence(builder); - if (params.polling != null && params.polling.baseUri != null) { - // Note that this property can be set even if streaming is enabled - endpoints.polling(params.polling.baseUri); - } - - if (params.polling != null && params.streaming == null) { - PollingDataSourceBuilder pollingBuilder = Components.pollingDataSource(); - if (params.polling.pollIntervalMs != null) { - pollingBuilder.pollIntervalMillis(params.polling.pollIntervalMs.intValue()); - } - builder.dataSource(pollingBuilder); - } else if (params.streaming != null) { - if (params.streaming.baseUri != null) { - endpoints.streaming(params.streaming.baseUri); + if (params.dataSystem != null) { + configureDataSystem(builder, params.dataSystem); + } else { + if (params.polling != null && params.polling.baseUri != null) { + endpoints.polling(params.polling.baseUri); } - StreamingDataSourceBuilder streamingBuilder = Components.streamingDataSource(); - if (params.streaming.initialRetryDelayMs != null) { - streamingBuilder.initialReconnectDelayMillis(params.streaming.initialRetryDelayMs.intValue()); + + if (params.polling != null && params.streaming == null) { + PollingDataSourceBuilder pollingBuilder = Components.pollingDataSource(); + if (params.polling.pollIntervalMs != null) { + pollingBuilder.pollIntervalMillis(params.polling.pollIntervalMs.intValue()); + } + builder.dataSource(pollingBuilder); + } else if (params.streaming != null) { + if (params.streaming.baseUri != null) { + endpoints.streaming(params.streaming.baseUri); + } + StreamingDataSourceBuilder streamingBuilder = Components.streamingDataSource(); + if (params.streaming.initialRetryDelayMs != null) { + streamingBuilder.initialReconnectDelayMillis(params.streaming.initialRetryDelayMs.intValue()); + } + builder.dataSource(streamingBuilder); } - builder.dataSource(streamingBuilder); } if (params.events == null) { @@ -373,6 +392,122 @@ private LDConfig buildSdkConfig(SdkConfigParams params, LDLogAdapter logAdapter, return builder.build(); } + private void configureDataSystem(LDConfig.Builder builder, SdkConfigDataSystemParams dataSystem) { + if (Boolean.TRUE.equals(dataSystem.useDefaultDataSystem)) { + builder.dataSystem(Components.dataSystem()); + return; + } + + SdkConfigConnectionModeConfig connModeConfig = dataSystem.connectionModeConfig; + boolean hasTopLevelPipelines = hasTopLevelDataSystemPipelines(dataSystem); + + if (connModeConfig == null && !hasTopLevelPipelines) { + return; + } + + DataSystemBuilder dsBuilder = Components.dataSystem(); + + if (connModeConfig != null && connModeConfig.initialConnectionMode != null) { + dsBuilder.foregroundConnectionMode(connectionModeFromString(connModeConfig.initialConnectionMode)); + } + + // at the time of writing this, we did not have contract tests that could test platform state changes, + // disabling automatic mode simplifies the behavior being tested + dsBuilder.automaticModeSwitching(AutomaticModeSwitchingConfig.disabled()); + + // Prefer connectionModeConfig when the harness sends both that and top-level pipelines. + if (hasConnectionModeCustomPipelines(connModeConfig)) { + for (Map.Entry entry : connModeConfig.customConnectionModes.entrySet()) { + ConnectionMode mode = connectionModeFromString(entry.getKey()); + ConnectionModeBuilder modeBuilder = buildConnectionModeBuilder(entry.getValue()); + dsBuilder.customizeConnectionMode(mode, modeBuilder); + } + } else if (hasTopLevelPipelines) { + SdkConfigModeDefinition topLevel = new SdkConfigModeDefinition(); + topLevel.initializers = dataSystem.initializers; + topLevel.synchronizers = dataSystem.synchronizers; + dsBuilder.customizeConnectionMode(ConnectionMode.STREAMING, buildConnectionModeBuilder(topLevel)); + } + + builder.dataSystem(dsBuilder); + } + + private static boolean hasTopLevelDataSystemPipelines(SdkConfigDataSystemParams dataSystem) { + return (dataSystem.initializers != null && !dataSystem.initializers.isEmpty()) + || (dataSystem.synchronizers != null && !dataSystem.synchronizers.isEmpty()); + } + + /** True when {@code connectionModeConfig.customConnectionModes} defines at least one mode pipeline. */ + private static boolean hasConnectionModeCustomPipelines(SdkConfigConnectionModeConfig connModeConfig) { + return connModeConfig != null + && connModeConfig.customConnectionModes != null + && !connModeConfig.customConnectionModes.isEmpty(); + } + + private static ConnectionModeBuilder buildConnectionModeBuilder(SdkConfigModeDefinition modeDef) { + ConnectionModeBuilder modeBuilder = DataSystemComponents.customMode(); + + if (modeDef.initializers != null) { + List> initList = new ArrayList<>(); + for (SdkConfigDataInitializer init : modeDef.initializers) { + if (init.polling != null) { + PollingInitializerBuilder initBuilder = DataSystemComponents.pollingInitializer(); + if (init.polling.baseUri != null) { + initBuilder.serviceEndpointsOverride( + Components.serviceEndpoints().polling(init.polling.baseUri)); + } + initList.add(initBuilder); + } + } + @SuppressWarnings("unchecked") + DataSourceBuilder[] initArray = initList.toArray(new DataSourceBuilder[0]); + modeBuilder.initializers(initArray); + } + + if (modeDef.synchronizers != null) { + List> syncList = new ArrayList<>(); + for (SdkConfigDataSynchronizer sync : modeDef.synchronizers) { + if (sync.streaming != null) { + StreamingSynchronizerBuilder syncBuilder = DataSystemComponents.streamingSynchronizer(); + if (sync.streaming.initialRetryDelayMs != null) { + syncBuilder.initialReconnectDelayMillis(sync.streaming.initialRetryDelayMs.intValue()); + } + if (sync.streaming.baseUri != null) { + syncBuilder.serviceEndpointsOverride( + Components.serviceEndpoints().streaming(sync.streaming.baseUri)); + } + syncList.add(syncBuilder); + } else if (sync.polling != null) { + PollingSynchronizerBuilder syncBuilder = DataSystemComponents.pollingSynchronizer(); + if (sync.polling.pollIntervalMs != null) { + syncBuilder.pollIntervalMillis(sync.polling.pollIntervalMs.intValue()); + } + if (sync.polling.baseUri != null) { + syncBuilder.serviceEndpointsOverride( + Components.serviceEndpoints().polling(sync.polling.baseUri)); + } + syncList.add(syncBuilder); + } + } + @SuppressWarnings("unchecked") + DataSourceBuilder[] syncArray = syncList.toArray(new DataSourceBuilder[0]); + modeBuilder.synchronizers(syncArray); + } + + return modeBuilder; + } + + private static ConnectionMode connectionModeFromString(String name) { + switch (name) { + case "streaming": return ConnectionMode.STREAMING; + case "polling": return ConnectionMode.POLLING; + case "offline": return ConnectionMode.OFFLINE; + case "one-shot": return ConnectionMode.ONE_SHOT; + case "background": return ConnectionMode.BACKGROUND; + default: throw new IllegalArgumentException("Unknown connection mode: " + name); + } + } + public void close() { try { client.close(); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2ChangeSetTranslator.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2ChangeSetTranslator.java index 4acf3ac5..5a47e18e 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2ChangeSetTranslator.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2ChangeSetTranslator.java @@ -67,7 +67,20 @@ static ChangeSet> toChangeSet( logger.warn("FDv2 PUT for flag '{}' is missing object data; skipping", change.getKey()); continue; } - flag = Flag.fromJson(change.getObject().toString()); + Flag parsed = Flag.fromJson(change.getObject().toString()); + // Inner object JSON omits "key" (it appears on the envelope). Always use the envelope key. + flag = new Flag( + change.getKey(), + parsed.getValue(), + parsed.getVersion(), + parsed.getFlagVersion(), + parsed.getVariation(), + parsed.isTrackEvents(), + parsed.isTrackReason(), + parsed.getDebugEventsUntilDate(), + parsed.getReason(), + parsed.getPrerequisites() + ); } else { flag = Flag.deletedItemPlaceholder(change.getKey(), change.getVersion()); } diff --git a/testharness-suppressions-fdv2.txt b/testharness-suppressions-fdv2.txt new file mode 100644 index 00000000..741f1ebc --- /dev/null +++ b/testharness-suppressions-fdv2.txt @@ -0,0 +1,15 @@ +streaming/requests/URL path is computed correctly/no environment filter/base URI has no trailing slash/GET +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/GET +streaming/requests/URL path is computed correctly/no environment filter/base URI has a trailing slash/REPORT +streaming/requests/context properties/single kind minimal/GET +streaming/requests/context properties/single kind with all attributes/GET +streaming/requests/context properties/multi-kind/GET +polling/requests/URL path is computed correctly/no environment filter/base URI has no trailing slash/GET +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/GET +polling/requests/URL path is computed correctly/no environment filter/base URI has a trailing slash/REPORT +polling/requests/context properties/single kind minimal/GET +polling/requests/context properties/single kind with all attributes/GET +polling/requests/context properties/multi-kind/GET +streaming/fdv2/fallback to FDv1 handling \ No newline at end of file From bd911c0b05f05dca40d35c30a4966386d70d68f2 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Mon, 6 Apr 2026 17:26:19 -0400 Subject: [PATCH 2/3] removing more fdv2 suppressions --- testharness-suppressions-fdv2.txt | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/testharness-suppressions-fdv2.txt b/testharness-suppressions-fdv2.txt index 741f1ebc..5e53af75 100644 --- a/testharness-suppressions-fdv2.txt +++ b/testharness-suppressions-fdv2.txt @@ -1,15 +1 @@ -streaming/requests/URL path is computed correctly/no environment filter/base URI has no trailing slash/GET -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/GET -streaming/requests/URL path is computed correctly/no environment filter/base URI has a trailing slash/REPORT -streaming/requests/context properties/single kind minimal/GET -streaming/requests/context properties/single kind with all attributes/GET -streaming/requests/context properties/multi-kind/GET -polling/requests/URL path is computed correctly/no environment filter/base URI has no trailing slash/GET -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/GET -polling/requests/URL path is computed correctly/no environment filter/base URI has a trailing slash/REPORT -polling/requests/context properties/single kind minimal/GET -polling/requests/context properties/single kind with all attributes/GET -polling/requests/context properties/multi-kind/GET streaming/fdv2/fallback to FDv1 handling \ No newline at end of file From d954bbf19f90c2ed1af6689498bd16f964dce10d Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Thu, 9 Apr 2026 10:14:55 -0400 Subject: [PATCH 3/3] addressing review comments --- .../launchdarkly/sdktest/Representations.java | 5 ---- .../launchdarkly/sdktest/SdkClientEntity.java | 28 ++++++++----------- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java index 038ac86b..4b7aa650 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/Representations.java @@ -106,11 +106,6 @@ public static class HookErrors { public static class SdkConfigDataSystemParams { Boolean useDefaultDataSystem; SdkConfigConnectionModeConfig connectionModeConfig; - /** - * FDv2 / data-system tests: pipelines when {@link #connectionModeConfig} does not define - * {@link SdkConfigConnectionModeConfig#customConnectionModes}. If both are present, custom - * connection modes take precedence and these lists are ignored. - */ List initializers; List synchronizers; } diff --git a/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java b/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java index 2294f42d..4bcdffd7 100644 --- a/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java +++ b/contract-tests/src/main/java/com/launchdarkly/sdktest/SdkClientEntity.java @@ -399,33 +399,29 @@ private void configureDataSystem(LDConfig.Builder builder, SdkConfigDataSystemPa } SdkConfigConnectionModeConfig connModeConfig = dataSystem.connectionModeConfig; - boolean hasTopLevelPipelines = hasTopLevelDataSystemPipelines(dataSystem); - - if (connModeConfig == null && !hasTopLevelPipelines) { - return; - } DataSystemBuilder dsBuilder = Components.dataSystem(); - if (connModeConfig != null && connModeConfig.initialConnectionMode != null) { - dsBuilder.foregroundConnectionMode(connectionModeFromString(connModeConfig.initialConnectionMode)); - } - // at the time of writing this, we did not have contract tests that could test platform state changes, // disabling automatic mode simplifies the behavior being tested dsBuilder.automaticModeSwitching(AutomaticModeSwitchingConfig.disabled()); - // Prefer connectionModeConfig when the harness sends both that and top-level pipelines. - if (hasConnectionModeCustomPipelines(connModeConfig)) { - for (Map.Entry entry : connModeConfig.customConnectionModes.entrySet()) { - ConnectionMode mode = connectionModeFromString(entry.getKey()); - ConnectionModeBuilder modeBuilder = buildConnectionModeBuilder(entry.getValue()); - dsBuilder.customizeConnectionMode(mode, modeBuilder); + if (connModeConfig != null) { + if (connModeConfig.initialConnectionMode != null) { + dsBuilder.foregroundConnectionMode(connectionModeFromString(connModeConfig.initialConnectionMode)); + } + if (hasConnectionModeCustomPipelines(connModeConfig)) { + for (Map.Entry entry : connModeConfig.customConnectionModes.entrySet()) { + ConnectionMode mode = connectionModeFromString(entry.getKey()); + ConnectionModeBuilder modeBuilder = buildConnectionModeBuilder(entry.getValue()); + dsBuilder.customizeConnectionMode(mode, modeBuilder); + } } - } else if (hasTopLevelPipelines) { + } else if (hasTopLevelDataSystemPipelines(dataSystem)) { SdkConfigModeDefinition topLevel = new SdkConfigModeDefinition(); topLevel.initializers = dataSystem.initializers; topLevel.synchronizers = dataSystem.synchronizers; + dsBuilder.foregroundConnectionMode(ConnectionMode.STREAMING); dsBuilder.customizeConnectionMode(ConnectionMode.STREAMING, buildConnectionModeBuilder(topLevel)); }