Skip to content

Commit 900511c

Browse files
authored
chore: adds FDv2 contract tests to CI (#884)
Adds running of FDv2 contract tests in addition to existing FDv1 contract tests.
1 parent 4e9ff6b commit 900511c

File tree

8 files changed

+150
-31
lines changed

8 files changed

+150
-31
lines changed

.github/workflows/server-node.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,19 @@ jobs:
3737
run: yarn contract-test-service-build
3838
- name: Launch the test service in the background
3939
run: yarn contract-test-service 2>&1 &
40+
- name: Clone and run contract tests from feat/fdv2 branch
41+
run: |
42+
mkdir -p /tmp/sdk-test-harness
43+
git clone https://github.com/launchdarkly/sdk-test-harness.git /tmp/sdk-test-harness
44+
cp ./contract-tests/testharness-suppressions-fdv2.txt /tmp/sdk-test-harness/testharness-suppressions-fdv2.txt
45+
cd /tmp/sdk-test-harness
46+
git checkout feat/fdv2
47+
go build -o test-harness .
48+
./test-harness -url http://localhost:8000 -debug --skip-from=testharness-suppressions-fdv2.txt
49+
env:
50+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4051
- uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1.0.2
4152
with:
4253
test_service_port: 8000
4354
token: ${{ secrets.GITHUB_TOKEN }}
44-
extra_params: '--skip-from=./contract-tests/testharness-suppressions.txt'
55+
extra_params: '--skip-from=./contract-tests/testharness-suppressions.txt -stop-service-at-end'

contract-tests/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"license": "Apache-2.0",
1313
"private": true,
1414
"dependencies": {
15-
"@launchdarkly/node-server-sdk": "9.8.0",
15+
"@launchdarkly/node-server-sdk": "*",
1616
"body-parser": "^1.19.0",
1717
"express": "^4.17.1",
1818
"got": "14.4.7"

contract-tests/src/sdkClientEntity.ts

Lines changed: 91 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import got from 'got';
22

33
import ld, {
44
createMigration,
5+
DataSourceOptions,
56
LDClient,
67
LDConcurrentExecution,
78
LDContext,
@@ -33,6 +34,11 @@ interface SdkConfigOptions {
3334
pollIntervalMs: number;
3435
filter?: string;
3536
};
37+
dataSystem?: {
38+
initializers?: SDKDataSystemInitializerParams[];
39+
synchronizers?: SDKDataSystemSynchronizerParams;
40+
payloadFilter?: string;
41+
};
3642
events?: {
3743
allAttributesPrivate?: boolean;
3844
baseUri: string;
@@ -67,6 +73,31 @@ interface SdkConfigOptions {
6773
};
6874
}
6975

76+
export interface SDKDataSystemSynchronizerParams {
77+
primary?: {
78+
streaming?: SDKDataSourceStreamingParams;
79+
polling?: SDKDataSourcePollingParams;
80+
};
81+
secondary?: {
82+
streaming?: SDKDataSourceStreamingParams;
83+
polling?: SDKDataSourcePollingParams;
84+
};
85+
}
86+
87+
export interface SDKDataSystemInitializerParams {
88+
polling?: SDKDataSourcePollingParams;
89+
}
90+
91+
export interface SDKDataSourceStreamingParams {
92+
baseUri?: string;
93+
initialRetryDelayMs?: number;
94+
}
95+
96+
export interface SDKDataSourcePollingParams {
97+
baseUri?: string;
98+
pollIntervalMs?: number;
99+
}
100+
70101
interface CommandParams {
71102
command: string;
72103
evaluate?: {
@@ -128,8 +159,7 @@ export function makeSdkConfig(options: SdkConfigOptions, tag: string): LDOptions
128159
cf.streamUri = options.streaming.baseUri;
129160
cf.streamInitialReconnectDelay = maybeTime(options.streaming.initialRetryDelayMs);
130161
if (options.streaming.filter) {
131-
cf.application = cf.application || {};
132-
cf.application.payloadFilterKey = options.streaming.filter;
162+
cf.payloadFilterKey = options.streaming.filter;
133163
}
134164
}
135165

@@ -138,8 +168,7 @@ export function makeSdkConfig(options: SdkConfigOptions, tag: string): LDOptions
138168
cf.baseUri = options.polling.baseUri;
139169
cf.pollInterval = options.polling.pollIntervalMs / 1000;
140170
if (options.polling.filter) {
141-
cf.application = cf.application || {};
142-
cf.application.payloadFilterKey = options.polling.filter;
171+
cf.payloadFilterKey = options.polling.filter;
143172
}
144173
}
145174

@@ -192,6 +221,64 @@ export function makeSdkConfig(options: SdkConfigOptions, tag: string): LDOptions
192221
}
193222
}
194223

224+
if (options.dataSystem) {
225+
const dataSourceStreamingOptions: SDKDataSourceStreamingParams | undefined =
226+
options.dataSystem.synchronizers?.primary?.streaming ??
227+
options.dataSystem.synchronizers?.secondary?.streaming;
228+
const dataSourcePollingOptions: SDKDataSourcePollingParams | undefined =
229+
options.dataSystem.initializers?.[0]?.polling ??
230+
options.dataSystem.synchronizers?.primary?.polling ??
231+
options.dataSystem.synchronizers?.secondary?.polling;
232+
233+
if (dataSourceStreamingOptions) {
234+
cf.streamUri = dataSourceStreamingOptions.baseUri;
235+
cf.streamInitialReconnectDelay = maybeTime(dataSourceStreamingOptions.initialRetryDelayMs);
236+
}
237+
if (dataSourcePollingOptions) {
238+
cf.stream = false;
239+
cf.baseUri = dataSourcePollingOptions.baseUri;
240+
cf.pollInterval = maybeTime(dataSourcePollingOptions.pollIntervalMs);
241+
}
242+
243+
let dataSourceOptions: DataSourceOptions | undefined;
244+
if (dataSourceStreamingOptions && dataSourcePollingOptions) {
245+
dataSourceOptions = {
246+
dataSourceOptionsType: 'standard',
247+
...(dataSourceStreamingOptions.initialRetryDelayMs != null && {
248+
streamInitialReconnectDelay: maybeTime(dataSourceStreamingOptions.initialRetryDelayMs),
249+
}),
250+
...(dataSourcePollingOptions.pollIntervalMs != null && {
251+
pollInterval: dataSourcePollingOptions.pollIntervalMs,
252+
}),
253+
};
254+
} else if (dataSourceStreamingOptions) {
255+
dataSourceOptions = {
256+
dataSourceOptionsType: 'streamingOnly',
257+
...(dataSourceStreamingOptions.initialRetryDelayMs != null && {
258+
streamInitialReconnectDelay: maybeTime(dataSourceStreamingOptions.initialRetryDelayMs),
259+
}),
260+
};
261+
} else if (dataSourcePollingOptions) {
262+
dataSourceOptions = {
263+
dataSourceOptionsType: 'pollingOnly',
264+
...(dataSourcePollingOptions.pollIntervalMs != null && {
265+
pollInterval: dataSourcePollingOptions.pollIntervalMs,
266+
}),
267+
};
268+
} else {
269+
// No data source options were specified
270+
dataSourceOptions = undefined;
271+
}
272+
273+
if (options.dataSystem.payloadFilter) {
274+
cf.payloadFilterKey = options.dataSystem.payloadFilter;
275+
}
276+
277+
cf.dataSystem = {
278+
dataSource: dataSourceOptions,
279+
};
280+
}
281+
195282
return cf;
196283
}
197284

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
streaming/validation/drop and reconnect if stream event has malformed JSON
2+
streaming/validation/drop and reconnect if stream event has well-formed JSON not matching schema
3+
streaming/requests/URL path is computed correctly/environment_filter_key="encoding_not_necessary"/base URI has no trailing slash/GET
4+
streaming/requests/URL path is computed correctly/environment_filter_key="encoding_not_necessary"/base URI has a trailing slash/GET
5+
polling/requests/URL path is computed correctly/environment_filter_key="encoding_not_necessary"/base URI has no trailing slash/GET
6+
polling/requests/URL path is computed correctly/environment_filter_key="encoding_not_necessary"/base URI has a trailing slash/GET
7+
8+
streaming/fdv2/reconnection state management/initializes from polling initializer
9+
streaming/fdv2/reconnection state management/initializes from 2 polling initializers
10+
streaming/fdv2/reconnection state management/saves previously known state
11+
streaming/fdv2/reconnection state management/replaces previously known state
12+
streaming/fdv2/reconnection state management/updates previously known state
13+
streaming/fdv2/ignores model version
14+
streaming/fdv2/can discard partial events on errors

packages/shared/common/src/datasource/CompositeDataSource.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,8 @@ export class CompositeDataSource implements DataSource {
207207
currentDS?.stop();
208208

209209
if (transitionRequest.err && transitionRequest.transition !== 'stop') {
210-
// if the transition was due to an error, throttle the transition
211-
const delay = this._backoff.fail();
210+
// if the transition was due to an error we're not in the initializer phase, throttle the transition. Fallback between initializers is not throttled.
211+
const delay = this._initPhaseActive ? 0 : this._backoff.fail();
212212
const { promise, cancel: cancelDelay } = this._cancellableDelay(delay);
213213
this._cancelTokens.push(cancelDelay);
214214
const delayedTransition = promise.then(() => {

packages/shared/sdk-server/__tests__/data_sources/OneShotInitializerFDv2.test.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -60,18 +60,21 @@ describe('given a one shot initializer', () => {
6060
requestor.requestAllData = jest.fn((cb) => cb(undefined, jsonData));
6161
initializer.start(mockDataCallback, mockStatusCallback);
6262
expect(mockDataCallback).toHaveBeenNthCalledWith(1, true, {
63-
basis: true,
64-
id: `mockId`,
65-
state: `mockState`,
66-
updates: [
67-
{
68-
kind: `flag`,
69-
key: `flagA`,
70-
version: 123,
71-
object: { objectFieldA: 'objectValueA' },
72-
},
73-
],
74-
version: 1,
63+
initMetadata: undefined,
64+
payload: {
65+
basis: true,
66+
id: `mockId`,
67+
state: `mockState`,
68+
updates: [
69+
{
70+
kind: `flag`,
71+
key: `flagA`,
72+
version: 123,
73+
object: { objectFieldA: 'objectValueA' },
74+
},
75+
],
76+
version: 1,
77+
},
7578
});
7679
});
7780
});

packages/shared/sdk-server/src/LDClientImpl.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -324,14 +324,16 @@ function constructFDv2(
324324
// make the FDv2 composite datasource with initializers/synchronizers
325325
const initializers: subsystem.LDDataSourceFactory[] = [];
326326

327-
// use one shot initializer for performance and cost
328-
initializers.push(
329-
() =>
330-
new OneShotInitializerFDv2(
331-
new Requestor(config, platform.requests, baseHeaders, '/sdk/poll', config.logger),
332-
config.logger,
333-
),
334-
);
327+
// use one shot initializer for performance and cost if we can do a combination of polling and streaming
328+
if (isStandardOptions(dataSystem.dataSource)) {
329+
initializers.push(
330+
() =>
331+
new OneShotInitializerFDv2(
332+
new Requestor(config, platform.requests, baseHeaders, '/sdk/poll', config.logger),
333+
config.logger,
334+
),
335+
);
336+
}
335337

336338
const synchronizers: subsystem.LDDataSourceFactory[] = [];
337339
// if streaming is configured, add streaming synchronizer
@@ -368,7 +370,7 @@ function constructFDv2(
368370
const fdv1FallbackSynchronizers = [
369371
() =>
370372
new PollingProcessorFDv2(
371-
new Requestor(config, platform.requests, baseHeaders, '/sdk/poll', config.logger),
373+
new Requestor(config, platform.requests, baseHeaders, '/sdk/latest-all', config.logger),
372374
pollingInterval,
373375
config.logger,
374376
true,

packages/shared/sdk-server/src/data_sources/OneShotInitializerFDv2.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export default class OneShotInitializerFDv2 implements subsystemCommon.DataSourc
3030
statusCallback(subsystemCommon.DataSourceState.Initializing);
3131

3232
this._logger?.debug('Performing initialization request to LaunchDarkly for feature flag data.');
33-
this._requestor.requestAllData((err, body) => {
33+
this._requestor.requestAllData((err, body, headers) => {
3434
if (this._stopped) {
3535
return;
3636
}
@@ -57,6 +57,8 @@ export default class OneShotInitializerFDv2 implements subsystemCommon.DataSourc
5757
return;
5858
}
5959

60+
const initMetadata = internal.initMetadataFromHeaders(headers);
61+
6062
try {
6163
const parsed = JSON.parse(body) as internal.FDv2EventsCollection;
6264
const payloadProcessor = new internal.PayloadProcessor(
@@ -82,7 +84,7 @@ export default class OneShotInitializerFDv2 implements subsystemCommon.DataSourc
8284
statusCallback(subsystemCommon.DataSourceState.Valid);
8385

8486
payloadProcessor.addPayloadListener((payload) => {
85-
dataCallback(payload.basis, payload);
87+
dataCallback(payload.basis, { initMetadata, payload });
8688
});
8789

8890
payloadProcessor.processEvents(parsed.events);

0 commit comments

Comments
 (0)