Skip to content

Commit d4fd0cf

Browse files
committed
test(openfeature): integrate canonical fixture coverage
Bring the canonical evaluation fixture branch onto current master as the base for regex conformance coverage. Environment: Datadog workspace
2 parents cba3155 + 80676d9 commit d4fd0cf

6 files changed

Lines changed: 178 additions & 0 deletions

File tree

.github/dependabot.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,3 +402,8 @@ updates:
402402
applies-to: security-updates
403403
patterns:
404404
- "*"
405+
406+
- package-ecosystem: "gitsubmodule"
407+
directory: "/"
408+
schedule:
409+
interval: "weekly"

.github/workflows/openfeature.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ jobs:
2323
id-token: write
2424
steps:
2525
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
26+
with:
27+
submodules: true
2628
- uses: ./.github/actions/node
2729
with:
2830
version: ${{ matrix.version }}

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[submodule "packages/dd-trace/test/openfeature/ffe-system-test-data"]
2+
path = packages/dd-trace/test/openfeature/ffe-system-test-data
3+
url = https://github.com/DataDog/ffe-system-test-data.git

packages/dd-trace/src/openfeature/flagging_provider.js

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ class FlaggingProvider extends DatadogNodeServerProvider {
2020
/** @type {{ start: Function, stop: Function } | undefined} */
2121
#configurationSource
2222

23+
/** @type {import('@datadog/openfeature-node-server').UniversalFlagConfigurationV1 | undefined} */
24+
#ffeConfig
25+
2326
/**
2427
* @param {import('../tracer')} tracer - Datadog tracer instance
2528
* @param {import('../config/config-base')} config - Tracer configuration object
@@ -48,6 +51,115 @@ class FlaggingProvider extends DatadogNodeServerProvider {
4851
this.#configurationSource?.start()
4952
}
5053

54+
/**
55+
* Stores the current configuration and updates the base provider.
56+
*
57+
* @param {import('@datadog/openfeature-node-server').UniversalFlagConfigurationV1 | undefined} configuration
58+
* @returns {void}
59+
*/
60+
setConfiguration (configuration) {
61+
this.#ffeConfig = configuration
62+
super.setConfiguration(configuration)
63+
}
64+
65+
/**
66+
* Resolves a boolean flag and normalizes its canonical result.
67+
*
68+
* @param {string} flagKey
69+
* @param {boolean} defaultValue
70+
* @param {import('@openfeature/server-sdk').EvaluationContext} context
71+
* @param {import('@openfeature/server-sdk').Logger} logger
72+
* @returns {Promise<import('@openfeature/server-sdk').ResolutionDetails<boolean>>}
73+
*/
74+
resolveBooleanEvaluation (flagKey, defaultValue, context, logger) {
75+
return super.resolveBooleanEvaluation(flagKey, defaultValue, context, logger)
76+
.then(result => this.#normalizeResolution(flagKey, result))
77+
}
78+
79+
/**
80+
* Resolves a string flag and normalizes its canonical result.
81+
*
82+
* @param {string} flagKey
83+
* @param {string} defaultValue
84+
* @param {import('@openfeature/server-sdk').EvaluationContext} context
85+
* @param {import('@openfeature/server-sdk').Logger} logger
86+
* @returns {Promise<import('@openfeature/server-sdk').ResolutionDetails<string>>}
87+
*/
88+
resolveStringEvaluation (flagKey, defaultValue, context, logger) {
89+
return super.resolveStringEvaluation(flagKey, defaultValue, context, logger)
90+
.then(result => this.#normalizeResolution(flagKey, result))
91+
}
92+
93+
/**
94+
* Resolves a number flag and normalizes its canonical result.
95+
*
96+
* @param {string} flagKey
97+
* @param {number} defaultValue
98+
* @param {import('@openfeature/server-sdk').EvaluationContext} context
99+
* @param {import('@openfeature/server-sdk').Logger} logger
100+
* @returns {Promise<import('@openfeature/server-sdk').ResolutionDetails<number>>}
101+
*/
102+
resolveNumberEvaluation (flagKey, defaultValue, context, logger) {
103+
return super.resolveNumberEvaluation(flagKey, defaultValue, context, logger)
104+
.then(result => this.#normalizeResolution(flagKey, result))
105+
}
106+
107+
/**
108+
* Resolves an object flag and normalizes its canonical result.
109+
*
110+
* @template {import('@openfeature/server-sdk').JsonValue} T
111+
* @param {string} flagKey
112+
* @param {T} defaultValue
113+
* @param {import('@openfeature/server-sdk').EvaluationContext} context
114+
* @param {import('@openfeature/server-sdk').Logger} logger
115+
* @returns {Promise<import('@openfeature/server-sdk').ResolutionDetails<T>>}
116+
*/
117+
resolveObjectEvaluation (flagKey, defaultValue, context, logger) {
118+
return super.resolveObjectEvaluation(flagKey, defaultValue, context, logger)
119+
.then(result => this.#normalizeResolution(flagKey, result))
120+
}
121+
122+
/**
123+
* Converts provider results to the canonical FFE reason contract.
124+
*
125+
* @template {import('@openfeature/server-sdk').FlagValue} T
126+
* @param {string} flagKey
127+
* @param {import('@openfeature/server-sdk').ResolutionDetails<T>} result
128+
* @returns {import('@openfeature/server-sdk').ResolutionDetails<T>}
129+
*/
130+
#normalizeResolution (flagKey, result) {
131+
if (result?.reason !== 'TARGETING_MATCH' && result?.reason !== 'DEFAULT') {
132+
return result
133+
}
134+
135+
const allocations = this.#ffeConfig?.flags?.[flagKey]?.allocations
136+
if (!Array.isArray(allocations)) {
137+
return result
138+
}
139+
140+
const allocation = allocations.find(item => item.key === result.flagMetadata?.allocationKey)
141+
if (!allocation || allocation.rules?.length || !Array.isArray(allocation.splits)) {
142+
return result
143+
}
144+
145+
const flag = this.#ffeConfig.flags[flagKey]
146+
const selectedSplit = allocation.splits.find(split => {
147+
const variant = flag.variations?.[split.variationKey]
148+
return variant?.key === result.variant || split.variationKey === result.variant
149+
})
150+
if (!selectedSplit) {
151+
return result
152+
}
153+
154+
const hasTimeBounds = allocation.startAt !== undefined || allocation.endAt !== undefined
155+
if (hasTimeBounds && allocation.splits.length === 1 && !selectedSplit.shards?.length) {
156+
return { ...result, reason: 'DEFAULT' }
157+
}
158+
159+
const reason = selectedSplit?.shards?.length ? 'SPLIT' : 'STATIC'
160+
return { ...result, reason }
161+
}
162+
51163
/**
52164
* Called when the provider is shut down.
53165
* Cleans up resources including channel subscriptions.
Submodule ffe-system-test-data added at f3da9ae

packages/dd-trace/test/openfeature/flagging_provider.spec.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
const assert = require('node:assert/strict')
44
const fs = require('node:fs')
5+
const path = require('node:path')
56

67
const { describe, it, beforeEach, afterEach } = require('mocha')
78
const sinon = require('sinon')
@@ -10,6 +11,9 @@ const proxyquire = require('proxyquire')
1011
require('../setup/core')
1112

1213
describe('FlaggingProvider', () => {
14+
const fixtureRoot = path.join(__dirname, 'ffe-system-test-data')
15+
const fixtureCaseDir = path.join(fixtureRoot, 'evaluation-cases')
16+
1317
let FlaggingProvider
1418
let mockTracer
1519
let mockConfig
@@ -286,4 +290,55 @@ describe('FlaggingProvider', () => {
286290
)
287291
})
288292
})
293+
294+
describe('canonical FFE fixtures', () => {
295+
const fixtureCases = loadFixtureCases()
296+
297+
for (const { fileName, index, testCase } of fixtureCases) {
298+
it(`should evaluate ${fileName}[${index}]`, async () => {
299+
const provider = new FlaggingProvider(mockTracer, mockConfig)
300+
provider.setConfiguration(loadUfc())
301+
302+
const details = await evaluateDetails(provider, testCase)
303+
304+
assert.deepStrictEqual(details.value, testCase.result.value)
305+
assert.strictEqual(details.reason, testCase.result.reason)
306+
if ('variant' in testCase.result) {
307+
assert.strictEqual(details.variant, testCase.result.variant)
308+
}
309+
})
310+
}
311+
})
312+
313+
function loadUfc () {
314+
return JSON.parse(fs.readFileSync(path.join(fixtureRoot, 'ufc-config.json'), 'utf8'))
315+
}
316+
317+
function loadFixtureCases () {
318+
const fixtureFiles = fs.readdirSync(fixtureCaseDir).filter(file => file.endsWith('.json')).sort()
319+
assert.ok(fixtureFiles.length > 0, 'FFE fixture submodule is missing or empty')
320+
return fixtureFiles.flatMap(fileName => {
321+
const testCases = JSON.parse(fs.readFileSync(path.join(fixtureCaseDir, fileName), 'utf8'))
322+
return testCases.map((testCase, index) => ({ fileName, index, testCase }))
323+
})
324+
}
325+
326+
async function evaluateDetails (provider, testCase) {
327+
const context = { targetingKey: testCase.targetingKey, ...testCase.attributes }
328+
const logger = { error () {}, warn () {}, info () {}, debug () {} }
329+
330+
if (testCase.variationType === 'BOOLEAN') {
331+
return provider.resolveBooleanEvaluation(testCase.flag, testCase.defaultValue, context, logger)
332+
}
333+
if (testCase.variationType === 'STRING') {
334+
return provider.resolveStringEvaluation(testCase.flag, testCase.defaultValue, context, logger)
335+
}
336+
if (testCase.variationType === 'INTEGER' || testCase.variationType === 'NUMERIC') {
337+
return provider.resolveNumberEvaluation(testCase.flag, testCase.defaultValue, context, logger)
338+
}
339+
if (testCase.variationType === 'JSON') {
340+
return provider.resolveObjectEvaluation(testCase.flag, testCase.defaultValue, context, logger)
341+
}
342+
throw new Error(`Unsupported variation type: ${testCase.variationType}`)
343+
}
289344
})

0 commit comments

Comments
 (0)