Skip to content

Commit 54cc74a

Browse files
authored
test: exercise FDv2 and FDv1 fallback via the v3 contract tests (#315)
Stacked on #314 (the Flutter FDv2 exposure). Wires the Flutter contract test service for the **v3 (FDv2) harness** and runs it in CI, so the data system — including FDv1 fallback — is exercised end-to-end. ## Changes - **Contract service:** advertise the `fdv1-fallback` capability; map the harness `dataSystem` configuration onto `LDConfig.dataSystem` (including the `fdv1Fallback` polling config); regenerate the service API model (`service_api.openapi.*`) for the `dataSystem` schema. - **CI (`.github/actions/ci/action.yml`):** add an **FDv2 contract-tests run** (the `launchdarkly/gh-actions/actions/contract-tests` action with `version: v3`, `branch: v3`) alongside the existing FDv1 run. Because the FDv1 run stops the test service at the end, the FDv2 step restarts it. Modeled on cpp-sdks' `contract-tests-fdv2` job. ## Validation The v3 harness passes end-to-end against this service: **816 total / 792 ran / 24 skipped, exit 0** (run locally against this branch on merged main), including the full FDv1 fallback suite. Contract service analyzes clean. ## Notes for review The CI YAML can't be run locally, so a couple of assumptions to sanity-check: - The pinned `contract-tests@contract-tests-v1.3.0` action supports `version: v3` / `branch: v3` (cpp uses the same action with v3). - FDv1 and FDv2 share `testharness-suppressions.txt` (the v3 run passes with it; cpp keeps a separate `*-fdv2.txt` — we can split if preferred). <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Changes are limited to CI and the contract-test adapter; production SDK behavior is exercised indirectly via harness mapping, with no auth or customer-facing API changes in this diff. > > **Overview** > Adds **FDv2 contract test coverage** in shared CI alongside the existing FDv1 run: the FDv1 step is labeled explicitly, then a second pass **restarts** the Flutter contract test service and invokes `contract-tests` with **`version`/`branch` v3** (same suppressions file). > > The **Flutter contract test service** is extended for the v3 harness: it advertises **`fdv1-fallback`** (and related) capabilities, accepts a **`dataSystem`** block on client creation (OpenAPI + codegen), and **`_mapDataSystem`** translates harness shapes (connection mode overrides, initializers/synchronizers, **`fdv1Fallback`**) into **`LDConfig.dataSystem`** and initial connection mode. Smaller harness fixes include omitting null evaluation **`value`** keys, stricter context casts, and **`Timeout.none`** on the long-running test server. > > **`pubspec.lock`** bumps pinned local SDK packages used by the contract app. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit db18961. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 2144c78 commit 54cc74a

6 files changed

Lines changed: 544 additions & 185 deletions

File tree

.github/actions/ci/action.yml

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ runs:
6363
cd packages/common
6464
dart test test/serialization/canonicalize_json_platform_test.dart -p chrome --test-randomize-ordering-seed=random
6565
66-
- name: Contract Tests
66+
- name: Contract Tests (FDv1)
6767
shell: bash
6868
run: |
6969
pushd apps/flutter_client_contract_test_service
@@ -75,6 +75,22 @@ runs:
7575
enable_persistence_tests: false
7676
extra_params: '-status-timeout 100 --skip-from=./apps/flutter_client_contract_test_service/testharness-suppressions.txt'
7777

78+
# FDv2 contract tests run against the v3 harness. The FDv1 run above
79+
# stops the test service when it finishes, so start it again here.
80+
- name: Contract Tests (FDv2)
81+
shell: bash
82+
run: |
83+
pushd apps/flutter_client_contract_test_service
84+
flutter test bin/contract_test_service.dart > test-service-fdv2.log 2>&1 & disown
85+
- uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1.3.0
86+
with:
87+
test_service_port: 8080
88+
token: ${{ inputs.github_token }}
89+
version: 'v3'
90+
branch: 'v3'
91+
enable_persistence_tests: false
92+
extra_params: '-status-timeout 100 --skip-from=./apps/flutter_client_contract_test_service/testharness-suppressions.txt'
93+
7894
- name: SSE Contract Tests Service
7995
shell: bash
8096
run: |

apps/flutter_client_contract_test_service/bin/contract_test_service.dart

Lines changed: 161 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ class TestApiImpl extends SdkTestApi {
2828
'client-per-context-summaries',
2929
'client-prereq-events',
3030
'auto-env-attributes',
31+
'client-event-source-http-errors',
32+
'fdv1-fallback',
3133
];
3234

3335
static const clientUrlPrefix = '/client/';
@@ -50,6 +52,8 @@ class TestApiImpl extends SdkTestApi {
5052
Future<PostResponse> Post(PostSchema body) async {
5153
final startWaitTimeMillis =
5254
body.configuration?.startWaitTimeMs?.toInt() ?? defaultWaitTimeMillis;
55+
final mappedDataSystem =
56+
_mapDataSystem(body.configuration?.dataSystem?.toJson());
5357
final config = LDConfig(
5458
body.configuration?.credential ?? '',
5559
AutoEnvAttributes.disabled,
@@ -64,9 +68,10 @@ class TestApiImpl extends SdkTestApi {
6468
streaming: body.configuration?.streaming?.baseUri,
6569
events: body.configuration?.events?.baseUri),
6670
dataSourceConfig: DataSourceConfig(
67-
initialConnectionMode: body.configuration?.streaming != null
68-
? ConnectionMode.streaming
69-
: ConnectionMode.polling,
71+
initialConnectionMode: mappedDataSystem?.initialConnectionMode ??
72+
(body.configuration?.streaming != null
73+
? ConnectionMode.streaming
74+
: ConnectionMode.polling),
7075
evaluationReasons: body.configuration?.clientSide?.evaluationReasons,
7176
useReport: body.configuration?.clientSide?.useReport),
7277
events: EventsConfig(
@@ -78,6 +83,7 @@ class TestApiImpl extends SdkTestApi {
7883
body.configuration?.events?.allAttributesPrivate ?? false,
7984
globalPrivateAttributes:
8085
body.configuration?.events?.globalPrivateAttributes,
86+
dataSystem: mappedDataSystem?.config,
8187
);
8288

8389
final configuration = body.configuration!;
@@ -289,6 +295,148 @@ class TestApiImpl extends SdkTestApi {
289295
return response;
290296
}
291297

298+
/// Translates the harness `dataSystem` configuration block into the
299+
/// SDK's [DataSystemConfig] and an initial connection mode.
300+
///
301+
/// Two shapes are supported, mirroring the harness:
302+
/// - `connectionModeConfig` with a map of mode name to mode definition
303+
/// plus `initialConnectionMode`. The SDK overrides built-in modes
304+
/// only, so a definition is applied when its name matches a built-in
305+
/// ([ConnectionModeId]); other names are ignored.
306+
/// - Top-level `initializers`/`synchronizers` lists, which override the
307+
/// built-in streaming mode.
308+
({DataSystemConfig config, ConnectionMode initialConnectionMode})?
309+
_mapDataSystem(Map<String, dynamic>? raw) {
310+
if (raw == null) {
311+
return null;
312+
}
313+
314+
// The FDv1 fallback is configured at the data-system level (a polling
315+
// config); it applies to whichever modes are built below.
316+
Fdv1FallbackConfig? parseFdv1Fallback() {
317+
if (raw['fdv1Fallback'] case final Map<String, dynamic> fallback) {
318+
return Fdv1FallbackConfig(
319+
pollInterval: fallback['pollIntervalMs'] != null
320+
? Duration(
321+
milliseconds: (fallback['pollIntervalMs'] as num).toInt())
322+
: null,
323+
endpoints: fallback['baseUri'] != null
324+
? EndpointConfig(
325+
pollingBaseUri: Uri.parse(fallback['baseUri'] as String))
326+
: null,
327+
);
328+
}
329+
return null;
330+
}
331+
332+
final fdv1Fallback = parseFdv1Fallback();
333+
334+
ModeDefinition translateMode(Map<String, dynamic> modeJson) {
335+
final initializers = <InitializerEntry>[];
336+
for (final entry in (modeJson['initializers'] as List<dynamic>? ?? [])) {
337+
final initializer = entry as Map<String, dynamic>;
338+
if (initializer['polling'] case final Map<String, dynamic> polling) {
339+
initializers.add(PollingInitializer(
340+
endpoints: EndpointConfig(
341+
pollingBaseUri: polling['baseUri'] != null
342+
? Uri.parse(polling['baseUri'] as String)
343+
: null)));
344+
}
345+
}
346+
347+
final synchronizers = <SynchronizerEntry>[];
348+
for (final entry in (modeJson['synchronizers'] as List<dynamic>? ?? [])) {
349+
final synchronizer = entry as Map<String, dynamic>;
350+
if (synchronizer['streaming']
351+
case final Map<String, dynamic> streaming) {
352+
synchronizers.add(StreamingSynchronizer(
353+
initialReconnectDelay: streaming['initialRetryDelayMs'] != null
354+
? Duration(
355+
milliseconds:
356+
(streaming['initialRetryDelayMs'] as num).toInt())
357+
: null,
358+
endpoints: EndpointConfig(
359+
streamingBaseUri: streaming['baseUri'] != null
360+
? Uri.parse(streaming['baseUri'] as String)
361+
: null)));
362+
} else if (synchronizer['polling']
363+
case final Map<String, dynamic> polling) {
364+
synchronizers.add(PollingSynchronizer(
365+
pollInterval: polling['pollIntervalMs'] != null
366+
? Duration(
367+
milliseconds: (polling['pollIntervalMs'] as num).toInt())
368+
: null,
369+
endpoints: EndpointConfig(
370+
pollingBaseUri: polling['baseUri'] != null
371+
? Uri.parse(polling['baseUri'] as String)
372+
: null)));
373+
}
374+
}
375+
376+
return ModeDefinition(
377+
initializers: initializers,
378+
synchronizers: synchronizers,
379+
fdv1Fallback: fdv1Fallback);
380+
}
381+
382+
ConnectionMode parseInitialMode(String? name) {
383+
return switch (name) {
384+
'polling' => ConnectionMode.polling,
385+
'offline' => ConnectionMode.offline,
386+
_ => ConnectionMode.streaming,
387+
};
388+
}
389+
390+
ConnectionModeId? builtInModeId(String name) {
391+
return switch (name) {
392+
'streaming' => ConnectionModeId.streaming,
393+
'polling' => ConnectionModeId.polling,
394+
'background' => ConnectionModeId.background,
395+
'offline' => ConnectionModeId.offline,
396+
_ => null,
397+
};
398+
}
399+
400+
if (raw['connectionModeConfig'] case final Map<String, dynamic> connMode) {
401+
final overrides = <ConnectionModeId, ModeDefinition>{};
402+
if (connMode['customConnectionModes']
403+
case final Map<String, dynamic> modes) {
404+
for (final entry in modes.entries) {
405+
if (builtInModeId(entry.key) case final id?) {
406+
overrides[id] = translateMode(entry.value as Map<String, dynamic>);
407+
}
408+
}
409+
}
410+
final initialModeName = connMode['initialConnectionMode'] as String?;
411+
return (
412+
config: DataSystemConfig(
413+
connectionModes: overrides,
414+
// Under the FDv2 data system the initial mode must be carried on
415+
// the data system config; the FDv1 initialConnectionMode is inert.
416+
initialConnectionMode:
417+
initialModeName == null ? null : builtInModeId(initialModeName),
418+
),
419+
initialConnectionMode: parseInitialMode(initialModeName),
420+
);
421+
}
422+
423+
if (raw['initializers'] != null || raw['synchronizers'] != null) {
424+
// Top-level source lists override the built-in streaming mode.
425+
return (
426+
config: DataSystemConfig(
427+
connectionModes: {ConnectionModeId.streaming: translateMode(raw)}),
428+
initialConnectionMode: ConnectionMode.streaming,
429+
);
430+
}
431+
432+
// Present but empty (or useDefaultDataSystem): FDv2 with built-in
433+
// modes.
434+
return (
435+
config: DataSystemConfig(),
436+
initialConnectionMode: ConnectionMode.streaming,
437+
);
438+
}
439+
292440
LDContext _contextFromSingleOrMulti(SingleOrMultiBuildContext input) {
293441
if (input.single != null) {
294442
return _flattenedListToContext(
@@ -315,8 +463,8 @@ class TestApiImpl extends SdkTestApi {
315463
final multi = input.multi!.toList();
316464
final builder = common.LDContextBuilder();
317465
for (var single in multi) {
318-
final kind = single['kind'];
319-
final key = single['key'];
466+
final kind = single['kind'] as String;
467+
final key = single['key'] as String?;
320468

321469
final singleAttributesBuilder = builder.kind(kind, key);
322470
_buildSingleAttributes(singleAttributesBuilder, single.toJson());
@@ -364,7 +512,12 @@ class TestApiImpl extends SdkTestApi {
364512

365513
Response _responseFromEvaluation(LDValue value) {
366514
final response = Response();
367-
response['value'] = common.LDValueSerialization.toJson(value);
515+
// A null evaluation result is omitted rather than set: the response
516+
// map rejects null values, and an absent key deserializes the same
517+
// as an explicit null on the harness side.
518+
if (common.LDValueSerialization.toJson(value) case final Object json) {
519+
response['value'] = json;
520+
}
368521
return response;
369522
}
370523

@@ -385,7 +538,7 @@ class TestApiImpl extends SdkTestApi {
385538
if (input['private'] != null) {
386539
retMap['_meta'] = {'privateAttributes': input['private']};
387540
}
388-
retMap.addAll(input['custom'] ?? {});
541+
retMap.addAll(input['custom'] as Map<String, dynamic>? ?? {});
389542
return retMap;
390543
}
391544

@@ -453,7 +606,7 @@ final class _WifiConnected extends ConnectivityPlatform {
453606
}
454607

455608
void main() async {
456-
test('Run contract tests', () async {
609+
test(timeout: Timeout.none, 'Run contract tests', () async {
457610
ConnectivityPlatform.instance = _WifiConnected();
458611
widgets.WidgetsFlutterBinding.ensureInitialized(); // needed before mocking
459612
// ignore: invalid_use_of_visible_for_testing_member

0 commit comments

Comments
 (0)