featurevisor 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +722 -0
- data/bin/cli.rb +142 -0
- data/bin/commands/assess_distribution.rb +236 -0
- data/bin/commands/benchmark.rb +274 -0
- data/bin/commands/test.rb +793 -0
- data/bin/commands.rb +10 -0
- data/bin/featurevisor +18 -0
- data/lib/featurevisor/bucketer.rb +95 -0
- data/lib/featurevisor/child_instance.rb +311 -0
- data/lib/featurevisor/compare_versions.rb +126 -0
- data/lib/featurevisor/conditions.rb +152 -0
- data/lib/featurevisor/datafile_reader.rb +350 -0
- data/lib/featurevisor/emitter.rb +60 -0
- data/lib/featurevisor/evaluate.rb +818 -0
- data/lib/featurevisor/events.rb +76 -0
- data/lib/featurevisor/hooks.rb +159 -0
- data/lib/featurevisor/instance.rb +463 -0
- data/lib/featurevisor/logger.rb +150 -0
- data/lib/featurevisor/murmurhash.rb +69 -0
- data/lib/featurevisor/version.rb +3 -0
- data/lib/featurevisor.rb +17 -0
- metadata +89 -0
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "find"
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module FeaturevisorCLI
|
|
6
|
+
module Commands
|
|
7
|
+
class Test
|
|
8
|
+
def self.run(options)
|
|
9
|
+
new(options).run
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(options)
|
|
13
|
+
@options = options
|
|
14
|
+
@project_path = options.project_directory_path
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run
|
|
18
|
+
puts "Running tests..."
|
|
19
|
+
|
|
20
|
+
# Get project configuration
|
|
21
|
+
config = get_config
|
|
22
|
+
environments = config["environments"] || []
|
|
23
|
+
segments_by_key = get_segments
|
|
24
|
+
|
|
25
|
+
# Use CLI schemaVersion option or fallback to config
|
|
26
|
+
schema_version = @options.schema_version
|
|
27
|
+
if schema_version.nil? || schema_version.empty?
|
|
28
|
+
schema_version = config["schemaVersion"]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Build datafiles for all environments
|
|
32
|
+
datafiles_by_environment = build_datafiles(environments, schema_version, @options.inflate)
|
|
33
|
+
|
|
34
|
+
puts ""
|
|
35
|
+
|
|
36
|
+
# Get log level
|
|
37
|
+
level = get_logger_level
|
|
38
|
+
tests = get_tests
|
|
39
|
+
|
|
40
|
+
if tests.empty?
|
|
41
|
+
puts "No tests found"
|
|
42
|
+
return
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Create SDK instances for each environment
|
|
46
|
+
sdk_instances_by_environment = create_sdk_instances(environments, datafiles_by_environment, level)
|
|
47
|
+
|
|
48
|
+
# Run tests
|
|
49
|
+
run_tests(tests, sdk_instances_by_environment, datafiles_by_environment, segments_by_key, level)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def get_config
|
|
55
|
+
puts "Getting config..."
|
|
56
|
+
command = "(cd #{@project_path} && npx featurevisor config --json)"
|
|
57
|
+
config_output = execute_command(command)
|
|
58
|
+
|
|
59
|
+
begin
|
|
60
|
+
JSON.parse(config_output)
|
|
61
|
+
rescue JSON::ParserError => e
|
|
62
|
+
puts "Error: Failed to parse config JSON: #{e.message}"
|
|
63
|
+
puts "Command output: #{config_output}"
|
|
64
|
+
exit 1
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def get_segments
|
|
69
|
+
puts "Getting segments..."
|
|
70
|
+
command = "(cd #{@project_path} && npx featurevisor list --segments --json)"
|
|
71
|
+
segments_output = execute_command(command)
|
|
72
|
+
|
|
73
|
+
begin
|
|
74
|
+
segments = JSON.parse(segments_output)
|
|
75
|
+
segments_by_key = {}
|
|
76
|
+
segments.each do |segment|
|
|
77
|
+
if segment["key"]
|
|
78
|
+
segments_by_key[segment["key"]] = segment
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
segments_by_key
|
|
82
|
+
rescue JSON::ParserError => e
|
|
83
|
+
puts "Error: Failed to parse segments JSON: #{e.message}"
|
|
84
|
+
puts "Command output: #{segments_output}"
|
|
85
|
+
exit 1
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def build_datafiles(environments, schema_version, inflate)
|
|
90
|
+
datafiles_by_environment = {}
|
|
91
|
+
|
|
92
|
+
environments.each do |environment|
|
|
93
|
+
puts "Building datafile for environment: #{environment}..."
|
|
94
|
+
|
|
95
|
+
command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--json"]
|
|
96
|
+
|
|
97
|
+
if schema_version && !schema_version.empty?
|
|
98
|
+
command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--schemaVersion=#{schema_version}", "--json"]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
if inflate && inflate > 0
|
|
102
|
+
if schema_version && !schema_version.empty?
|
|
103
|
+
command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--schemaVersion=#{schema_version}", "--inflate=#{inflate}", "--json"]
|
|
104
|
+
else
|
|
105
|
+
command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--inflate=#{inflate}", "--json"]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
command = command_parts.join(" ")
|
|
110
|
+
datafile_output = execute_command(command)
|
|
111
|
+
|
|
112
|
+
begin
|
|
113
|
+
datafile = JSON.parse(datafile_output)
|
|
114
|
+
datafiles_by_environment[environment] = datafile
|
|
115
|
+
rescue JSON::ParserError => e
|
|
116
|
+
puts "Error: Failed to parse datafile JSON for #{environment}: #{e.message}"
|
|
117
|
+
puts "Command output: #{datafile_output}"
|
|
118
|
+
exit 1
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
datafiles_by_environment
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def get_logger_level
|
|
126
|
+
if @options.verbose
|
|
127
|
+
"debug"
|
|
128
|
+
elsif @options.quiet
|
|
129
|
+
"error"
|
|
130
|
+
else
|
|
131
|
+
"warn"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def get_tests
|
|
136
|
+
command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "list", "--tests", "--applyMatrix", "--json"]
|
|
137
|
+
|
|
138
|
+
if @options.key_pattern && !@options.key_pattern.empty?
|
|
139
|
+
command_parts << "--keyPattern=#{@options.key_pattern}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
if @options.assertion_pattern && !@options.assertion_pattern.empty?
|
|
143
|
+
command_parts << "--assertionPattern=#{@options.assertion_pattern}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
command = command_parts.join(" ")
|
|
147
|
+
tests_output = execute_command(command)
|
|
148
|
+
|
|
149
|
+
begin
|
|
150
|
+
JSON.parse(tests_output)
|
|
151
|
+
rescue JSON::ParserError => e
|
|
152
|
+
puts "Error: Failed to parse tests JSON: #{e.message}"
|
|
153
|
+
puts "Command output: #{tests_output}"
|
|
154
|
+
exit 1
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def create_sdk_instances(environments, datafiles_by_environment, level)
|
|
159
|
+
sdk_instances_by_environment = {}
|
|
160
|
+
|
|
161
|
+
environments.each do |environment|
|
|
162
|
+
datafile = datafiles_by_environment[environment]
|
|
163
|
+
|
|
164
|
+
# Convert string keys to symbols for the SDK
|
|
165
|
+
symbolized_datafile = symbolize_keys(datafile)
|
|
166
|
+
|
|
167
|
+
# Create SDK instance
|
|
168
|
+
instance = Featurevisor.create_instance(
|
|
169
|
+
datafile: symbolized_datafile,
|
|
170
|
+
log_level: level,
|
|
171
|
+
hooks: [
|
|
172
|
+
{
|
|
173
|
+
name: "tester-hook",
|
|
174
|
+
bucket_value: ->(options) { options.bucket_value }
|
|
175
|
+
}
|
|
176
|
+
]
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
sdk_instances_by_environment[environment] = instance
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
sdk_instances_by_environment
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def run_tests(tests, sdk_instances_by_environment, datafiles_by_environment, segments_by_key, level)
|
|
186
|
+
passed_tests_count = 0
|
|
187
|
+
failed_tests_count = 0
|
|
188
|
+
passed_assertions_count = 0
|
|
189
|
+
failed_assertions_count = 0
|
|
190
|
+
|
|
191
|
+
tests.each do |test|
|
|
192
|
+
test_key = test["key"]
|
|
193
|
+
assertions = test["assertions"] || []
|
|
194
|
+
results = ""
|
|
195
|
+
test_has_error = false
|
|
196
|
+
test_duration = 0.0
|
|
197
|
+
|
|
198
|
+
assertions.each do |assertion|
|
|
199
|
+
if assertion.is_a?(Hash)
|
|
200
|
+
test_result = nil
|
|
201
|
+
|
|
202
|
+
if test["feature"]
|
|
203
|
+
environment = assertion["environment"]
|
|
204
|
+
instance = sdk_instances_by_environment[environment]
|
|
205
|
+
|
|
206
|
+
# Show datafile if requested
|
|
207
|
+
if @options.show_datafile
|
|
208
|
+
datafile = datafiles_by_environment[environment]
|
|
209
|
+
puts ""
|
|
210
|
+
puts JSON.pretty_generate(datafile)
|
|
211
|
+
puts ""
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# If "at" parameter is provided, create a new instance with the specific hook
|
|
215
|
+
if assertion["at"]
|
|
216
|
+
datafile = datafiles_by_environment[environment]
|
|
217
|
+
symbolized_datafile = symbolize_keys(datafile)
|
|
218
|
+
|
|
219
|
+
instance = Featurevisor.create_instance(
|
|
220
|
+
datafile: symbolized_datafile,
|
|
221
|
+
log_level: level,
|
|
222
|
+
hooks: [
|
|
223
|
+
{
|
|
224
|
+
name: "tester-hook",
|
|
225
|
+
bucket_value: ->(options) do
|
|
226
|
+
# Match JavaScript implementation: assertion.at * (MAX_BUCKETED_NUMBER / 100)
|
|
227
|
+
# MAX_BUCKETED_NUMBER is 100000, so this becomes assertion.at * 1000
|
|
228
|
+
at = assertion["at"]
|
|
229
|
+
if at.is_a?(Numeric)
|
|
230
|
+
(at * 1000).to_i
|
|
231
|
+
else
|
|
232
|
+
options.bucket_value
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
}
|
|
236
|
+
]
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
test_result = run_test_feature(assertion, test["feature"], instance, level)
|
|
241
|
+
elsif test["segment"]
|
|
242
|
+
segment_key = test["segment"]
|
|
243
|
+
segment = segments_by_key[segment_key]
|
|
244
|
+
if segment.is_a?(Hash)
|
|
245
|
+
test_result = run_test_segment(assertion, segment, level)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
if test_result
|
|
250
|
+
test_duration += test_result[:duration]
|
|
251
|
+
|
|
252
|
+
if test_result[:has_error]
|
|
253
|
+
results += " ✘ #{assertion['description']} (#{(test_result[:duration] * 1000).round(2)}ms)\n"
|
|
254
|
+
results += test_result[:errors]
|
|
255
|
+
test_has_error = true
|
|
256
|
+
failed_assertions_count += 1
|
|
257
|
+
else
|
|
258
|
+
results += " ✔ #{assertion['description']} (#{(test_result[:duration] * 1000).round(2)}ms)\n"
|
|
259
|
+
passed_assertions_count += 1
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
if !@options.only_failures || (@options.only_failures && test_has_error)
|
|
266
|
+
puts "\nTesting: #{test_key} (#{(test_duration * 1000).round(2)}ms)"
|
|
267
|
+
print results
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
if test_has_error
|
|
271
|
+
failed_tests_count += 1
|
|
272
|
+
else
|
|
273
|
+
passed_tests_count += 1
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
puts ""
|
|
278
|
+
puts "Test specs: #{passed_tests_count} passed, #{failed_tests_count} failed"
|
|
279
|
+
puts "Assertions: #{passed_assertions_count} passed, #{failed_assertions_count} failed"
|
|
280
|
+
puts ""
|
|
281
|
+
|
|
282
|
+
if failed_tests_count > 0
|
|
283
|
+
exit 1
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def run_test_feature(assertion, feature_key, instance, level)
|
|
288
|
+
context = parse_context(assertion["context"])
|
|
289
|
+
sticky = parse_sticky(assertion["sticky"])
|
|
290
|
+
|
|
291
|
+
# Set context and sticky for this assertion
|
|
292
|
+
instance.set_context(context, false)
|
|
293
|
+
if sticky && !sticky.empty?
|
|
294
|
+
instance.set_sticky(sticky, false)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Create override options
|
|
298
|
+
override_options = create_override_options(assertion)
|
|
299
|
+
|
|
300
|
+
has_error = false
|
|
301
|
+
errors = ""
|
|
302
|
+
start_time = Time.now
|
|
303
|
+
|
|
304
|
+
# Test expectedToBeEnabled
|
|
305
|
+
if assertion.key?("expectedToBeEnabled")
|
|
306
|
+
expected_to_be_enabled = assertion["expectedToBeEnabled"]
|
|
307
|
+
is_enabled = instance.is_enabled(feature_key, context, override_options)
|
|
308
|
+
|
|
309
|
+
if is_enabled != expected_to_be_enabled
|
|
310
|
+
has_error = true
|
|
311
|
+
errors += " ✘ expectedToBeEnabled: expected #{expected_to_be_enabled} but received #{is_enabled}\n"
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Test expectedVariation
|
|
316
|
+
if assertion.key?("expectedVariation")
|
|
317
|
+
expected_variation = assertion["expectedVariation"]
|
|
318
|
+
variation = instance.get_variation(feature_key, context, override_options)
|
|
319
|
+
|
|
320
|
+
variation_value = variation.nil? ? nil : variation
|
|
321
|
+
if !compare_values(variation_value, expected_variation)
|
|
322
|
+
has_error = true
|
|
323
|
+
errors += " ✘ expectedVariation: expected #{expected_variation} but received #{variation_value}\n"
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Test expectedVariables
|
|
328
|
+
if assertion["expectedVariables"]
|
|
329
|
+
expected_variables = assertion["expectedVariables"]
|
|
330
|
+
expected_variables.each do |variable_key, expected_value|
|
|
331
|
+
# Set default variable value for this specific variable
|
|
332
|
+
if assertion["defaultVariableValues"] && assertion["defaultVariableValues"][variable_key]
|
|
333
|
+
override_options[:default_variable_value] = assertion["defaultVariableValues"][variable_key]
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
actual_value = instance.get_variable(feature_key, variable_key, context, override_options)
|
|
337
|
+
|
|
338
|
+
# Check if this is a JSON-type variable
|
|
339
|
+
passed = false
|
|
340
|
+
if expected_value.is_a?(String) && !expected_value.empty? && (expected_value[0] == '{' || expected_value[0] == '[')
|
|
341
|
+
begin
|
|
342
|
+
parsed_expected_value = JSON.parse(expected_value)
|
|
343
|
+
|
|
344
|
+
if actual_value.is_a?(Hash)
|
|
345
|
+
passed = compare_maps(parsed_expected_value, actual_value)
|
|
346
|
+
elsif actual_value.is_a?(Array)
|
|
347
|
+
passed = compare_arrays(parsed_expected_value, actual_value)
|
|
348
|
+
else
|
|
349
|
+
passed = compare_values(actual_value, parsed_expected_value)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
if !passed
|
|
353
|
+
has_error = true
|
|
354
|
+
actual_json = actual_value.to_json
|
|
355
|
+
errors += " ✘ expectedVariables.#{variable_key}: expected #{expected_value} but received #{actual_json}\n"
|
|
356
|
+
end
|
|
357
|
+
next
|
|
358
|
+
rescue JSON::ParserError
|
|
359
|
+
# Fall through to regular comparison
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Regular comparison for non-JSON strings or when JSON parsing fails
|
|
364
|
+
if !compare_values(actual_value, expected_value)
|
|
365
|
+
has_error = true
|
|
366
|
+
errors += " ✘ expectedVariables.#{variable_key}: expected #{expected_value} but received #{actual_value}\n"
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Test expectedEvaluations
|
|
372
|
+
if assertion["expectedEvaluations"]
|
|
373
|
+
expected_evaluations = assertion["expectedEvaluations"]
|
|
374
|
+
|
|
375
|
+
# Test flag evaluations
|
|
376
|
+
if expected_evaluations["flag"]
|
|
377
|
+
evaluation = instance.evaluate_flag(feature_key, context, override_options)
|
|
378
|
+
expected_evaluations["flag"].each do |key, expected_value|
|
|
379
|
+
actual_value = get_evaluation_value(evaluation, key)
|
|
380
|
+
if !compare_values(actual_value, expected_value)
|
|
381
|
+
has_error = true
|
|
382
|
+
errors += " ✘ expectedEvaluations.flag.#{key}: expected #{expected_value} but received #{actual_value}\n"
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Test variation evaluations
|
|
388
|
+
if expected_evaluations["variation"]
|
|
389
|
+
evaluation = instance.evaluate_variation(feature_key, context, override_options)
|
|
390
|
+
expected_evaluations["variation"].each do |key, expected_value|
|
|
391
|
+
actual_value = get_evaluation_value(evaluation, key)
|
|
392
|
+
if !compare_values(actual_value, expected_value)
|
|
393
|
+
has_error = true
|
|
394
|
+
errors += " ✘ expectedEvaluations.variation.#{key}: expected #{expected_value} but received #{actual_value}\n"
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Test variable evaluations
|
|
400
|
+
if expected_evaluations["variables"]
|
|
401
|
+
expected_evaluations["variables"].each do |variable_key, expected_eval|
|
|
402
|
+
if expected_eval.is_a?(Hash)
|
|
403
|
+
evaluation = instance.evaluate_variable(feature_key, variable_key, context, override_options)
|
|
404
|
+
expected_eval.each do |key, expected_value|
|
|
405
|
+
actual_value = get_evaluation_value(evaluation, key)
|
|
406
|
+
if !compare_values(actual_value, expected_value)
|
|
407
|
+
has_error = true
|
|
408
|
+
errors += " ✘ expectedEvaluations.variables.#{variable_key}.#{key}: expected #{expected_value} but received #{actual_value}\n"
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Test children
|
|
417
|
+
if assertion["children"]
|
|
418
|
+
assertion["children"].each do |child|
|
|
419
|
+
if child.is_a?(Hash)
|
|
420
|
+
child_context = parse_context(child["context"])
|
|
421
|
+
|
|
422
|
+
# Create override options for child with sticky values
|
|
423
|
+
child_override_options = create_override_options(child)
|
|
424
|
+
|
|
425
|
+
# Pass sticky values to child instance
|
|
426
|
+
child_instance = instance.spawn(child_context, child_override_options)
|
|
427
|
+
|
|
428
|
+
# Set sticky values for child if they exist
|
|
429
|
+
# Create a local copy to ensure it's never nil
|
|
430
|
+
child_sticky = sticky || {}
|
|
431
|
+
if !child_sticky.empty?
|
|
432
|
+
child_instance.set_sticky(child_sticky, false)
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
child_result = run_test_feature_child(child, feature_key, child_instance, level)
|
|
436
|
+
|
|
437
|
+
if child_result[:has_error]
|
|
438
|
+
has_error = true
|
|
439
|
+
errors += child_result[:errors]
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
duration = Time.now - start_time
|
|
446
|
+
|
|
447
|
+
{
|
|
448
|
+
has_error: has_error,
|
|
449
|
+
errors: errors,
|
|
450
|
+
duration: duration
|
|
451
|
+
}
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def run_test_feature_child(assertion, feature_key, instance, level)
|
|
455
|
+
context = parse_context(assertion["context"])
|
|
456
|
+
override_options = create_override_options(assertion)
|
|
457
|
+
|
|
458
|
+
has_error = false
|
|
459
|
+
errors = ""
|
|
460
|
+
start_time = Time.now
|
|
461
|
+
|
|
462
|
+
# Test expectedToBeEnabled
|
|
463
|
+
if assertion.key?("expectedToBeEnabled")
|
|
464
|
+
expected_to_be_enabled = assertion["expectedToBeEnabled"]
|
|
465
|
+
is_enabled = instance.is_enabled(feature_key, context, override_options)
|
|
466
|
+
|
|
467
|
+
if is_enabled != expected_to_be_enabled
|
|
468
|
+
has_error = true
|
|
469
|
+
errors += " ✘ expectedToBeEnabled: expected #{expected_to_be_enabled} but received #{is_enabled}\n"
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# Test expectedVariation
|
|
474
|
+
if assertion.key?("expectedVariation")
|
|
475
|
+
expected_variation = assertion["expectedVariation"]
|
|
476
|
+
variation = instance.get_variation(feature_key, context, override_options)
|
|
477
|
+
|
|
478
|
+
variation_value = variation.nil? ? nil : variation
|
|
479
|
+
if !compare_values(variation_value, expected_variation)
|
|
480
|
+
has_error = true
|
|
481
|
+
errors += " ✘ expectedVariation: expected #{expected_variation} but received #{variation_value}\n"
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Test expectedVariables
|
|
486
|
+
if assertion["expectedVariables"]
|
|
487
|
+
expected_variables = assertion["expectedVariables"]
|
|
488
|
+
expected_variables.each do |variable_key, expected_value|
|
|
489
|
+
# Set default variable value for this specific variable
|
|
490
|
+
if assertion["defaultVariableValues"] && assertion["defaultVariableValues"][variable_key]
|
|
491
|
+
override_options[:default_variable_value] = assertion["defaultVariableValues"][variable_key]
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
actual_value = instance.get_variable(feature_key, variable_key, context, override_options)
|
|
495
|
+
|
|
496
|
+
# Check if this is a JSON-type variable
|
|
497
|
+
passed = false
|
|
498
|
+
if expected_value.is_a?(String) && !expected_value.empty? && (expected_value[0] == '{' || expected_value[0] == '[')
|
|
499
|
+
begin
|
|
500
|
+
parsed_expected_value = JSON.parse(expected_value)
|
|
501
|
+
|
|
502
|
+
if actual_value.is_a?(Hash)
|
|
503
|
+
passed = compare_maps(parsed_expected_value, actual_value)
|
|
504
|
+
elsif actual_value.is_a?(Array)
|
|
505
|
+
passed = compare_arrays(parsed_expected_value, actual_value)
|
|
506
|
+
else
|
|
507
|
+
passed = compare_values(actual_value, parsed_expected_value)
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
if !passed
|
|
511
|
+
has_error = true
|
|
512
|
+
actual_json = actual_value.to_json
|
|
513
|
+
errors += " ✘ expectedVariables.#{variable_key}: expected #{expected_value} but received #{actual_json}\n"
|
|
514
|
+
end
|
|
515
|
+
next
|
|
516
|
+
rescue JSON::ParserError
|
|
517
|
+
# Fall through to regular comparison
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Regular comparison for non-JSON strings or when JSON parsing fails
|
|
522
|
+
if !compare_values(actual_value, expected_value)
|
|
523
|
+
has_error = true
|
|
524
|
+
errors += " ✘ expectedVariables.#{variable_key}: expected #{expected_value} but received #{actual_value}\n"
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
duration = Time.now - start_time
|
|
530
|
+
|
|
531
|
+
{
|
|
532
|
+
has_error: has_error,
|
|
533
|
+
errors: errors,
|
|
534
|
+
duration: duration
|
|
535
|
+
}
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def run_test_segment(assertion, segment, level)
|
|
539
|
+
context = parse_context(assertion["context"])
|
|
540
|
+
conditions = segment["conditions"]
|
|
541
|
+
|
|
542
|
+
# Create a minimal datafile for segment testing
|
|
543
|
+
datafile = {
|
|
544
|
+
schemaVersion: "2",
|
|
545
|
+
revision: "tester",
|
|
546
|
+
features: {},
|
|
547
|
+
segments: {}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
# Create SDK instance for segment testing
|
|
551
|
+
instance = Featurevisor.create_instance(
|
|
552
|
+
datafile: symbolize_keys(datafile),
|
|
553
|
+
log_level: level
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
has_error = false
|
|
557
|
+
errors = ""
|
|
558
|
+
start_time = Time.now
|
|
559
|
+
|
|
560
|
+
if assertion.key?("expectedToMatch")
|
|
561
|
+
expected_to_match = assertion["expectedToMatch"]
|
|
562
|
+
actual = instance.instance_variable_get(:@datafile_reader).all_conditions_are_matched(conditions, context)
|
|
563
|
+
|
|
564
|
+
if actual != expected_to_match
|
|
565
|
+
has_error = true
|
|
566
|
+
errors += " ✘ expectedToMatch: expected #{expected_to_match} but received #{actual}\n"
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
duration = Time.now - start_time
|
|
571
|
+
|
|
572
|
+
{
|
|
573
|
+
has_error: has_error,
|
|
574
|
+
errors: errors,
|
|
575
|
+
duration: duration
|
|
576
|
+
}
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def parse_context(context_data)
|
|
580
|
+
if context_data && context_data.is_a?(Hash)
|
|
581
|
+
# Convert string keys to symbols for the SDK
|
|
582
|
+
context_data.transform_keys(&:to_sym)
|
|
583
|
+
else
|
|
584
|
+
{}
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def parse_sticky(sticky_data)
|
|
589
|
+
if sticky_data && sticky_data.is_a?(Hash)
|
|
590
|
+
sticky_features = {}
|
|
591
|
+
|
|
592
|
+
sticky_data.each do |key, value|
|
|
593
|
+
if value.is_a?(Hash)
|
|
594
|
+
evaluated_feature = {}
|
|
595
|
+
|
|
596
|
+
if value.key?("enabled")
|
|
597
|
+
evaluated_feature[:enabled] = value["enabled"]
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
if value.key?("variation")
|
|
601
|
+
evaluated_feature[:variation] = value["variation"]
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
if value["variables"] && value["variables"].is_a?(Hash)
|
|
605
|
+
evaluated_feature[:variables] = value["variables"].transform_keys(&:to_sym)
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
sticky_features[key.to_sym] = evaluated_feature
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
sticky_features
|
|
613
|
+
else
|
|
614
|
+
{}
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def create_override_options(assertion)
|
|
619
|
+
options = {}
|
|
620
|
+
|
|
621
|
+
if assertion["defaultVariationValue"]
|
|
622
|
+
options[:default_variation_value] = assertion["defaultVariationValue"]
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
options
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def get_evaluation_value(evaluation, key)
|
|
629
|
+
case key
|
|
630
|
+
when "type"
|
|
631
|
+
evaluation[:type]
|
|
632
|
+
when "featureKey"
|
|
633
|
+
evaluation[:feature_key]
|
|
634
|
+
when "reason"
|
|
635
|
+
evaluation[:reason]
|
|
636
|
+
when "bucketKey"
|
|
637
|
+
evaluation[:bucket_key]
|
|
638
|
+
when "bucketValue"
|
|
639
|
+
evaluation[:bucket_value]
|
|
640
|
+
when "ruleKey"
|
|
641
|
+
evaluation[:rule_key]
|
|
642
|
+
when "error"
|
|
643
|
+
evaluation[:error]
|
|
644
|
+
when "enabled"
|
|
645
|
+
evaluation[:enabled]
|
|
646
|
+
when "traffic"
|
|
647
|
+
evaluation[:traffic]
|
|
648
|
+
when "forceIndex"
|
|
649
|
+
evaluation[:force_index]
|
|
650
|
+
when "force"
|
|
651
|
+
evaluation[:force]
|
|
652
|
+
when "required"
|
|
653
|
+
evaluation[:required]
|
|
654
|
+
when "sticky"
|
|
655
|
+
evaluation[:sticky]
|
|
656
|
+
when "variation"
|
|
657
|
+
evaluation[:variation]
|
|
658
|
+
when "variationValue"
|
|
659
|
+
evaluation[:variation_value]
|
|
660
|
+
when "variableKey"
|
|
661
|
+
evaluation[:variable_key]
|
|
662
|
+
when "variableValue"
|
|
663
|
+
evaluation[:variable_value]
|
|
664
|
+
when "variableSchema"
|
|
665
|
+
evaluation[:variable_schema]
|
|
666
|
+
else
|
|
667
|
+
nil
|
|
668
|
+
end
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
def compare_values(actual, expected)
|
|
672
|
+
# Handle nil cases
|
|
673
|
+
if actual.nil? && expected.nil?
|
|
674
|
+
return true
|
|
675
|
+
end
|
|
676
|
+
if actual.nil? || expected.nil?
|
|
677
|
+
return false
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
# Handle empty string vs nil for variation values
|
|
681
|
+
if actual.is_a?(String) && actual.empty? && expected.nil?
|
|
682
|
+
return true
|
|
683
|
+
end
|
|
684
|
+
if expected.is_a?(String) && expected.empty? && actual.nil?
|
|
685
|
+
return true
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
# Handle numeric type conversions
|
|
689
|
+
if actual.is_a?(Integer) && expected.is_a?(Float)
|
|
690
|
+
return actual.to_f == expected
|
|
691
|
+
end
|
|
692
|
+
if actual.is_a?(Float) && expected.is_a?(Integer)
|
|
693
|
+
return actual == expected.to_f
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
# Handle JSON string comparison
|
|
697
|
+
if expected.is_a?(String) && actual.is_a?(Hash)
|
|
698
|
+
if !expected.empty? && (expected[0] == '{' || expected[0] == '[')
|
|
699
|
+
begin
|
|
700
|
+
actual_json = actual.to_json
|
|
701
|
+
expected_normalized = expected.gsub(/\s/, '')
|
|
702
|
+
actual_normalized = actual_json.gsub(/\s/, '')
|
|
703
|
+
return expected_normalized == actual_normalized
|
|
704
|
+
rescue
|
|
705
|
+
# Fall through to regular comparison
|
|
706
|
+
end
|
|
707
|
+
end
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
# Handle hash comparison with key normalization
|
|
711
|
+
if actual.is_a?(Hash) && expected.is_a?(Hash)
|
|
712
|
+
return compare_maps(actual, expected)
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
# Handle array comparison
|
|
716
|
+
if actual.is_a?(Array) && expected.is_a?(Array)
|
|
717
|
+
return compare_arrays(actual, expected)
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
# For other types, use direct comparison
|
|
721
|
+
if [String, TrueClass, FalseClass, Integer, Float].any? { |type| actual.is_a?(type) }
|
|
722
|
+
return actual == expected
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
# For uncomparable types, return false
|
|
726
|
+
false
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
def compare_arrays(a, b)
|
|
730
|
+
return false if a.length != b.length
|
|
731
|
+
|
|
732
|
+
a.each_with_index do |v, i|
|
|
733
|
+
return false unless compare_values(v, b[i])
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
true
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
def compare_maps(a, b)
|
|
740
|
+
return false if a.length != b.length
|
|
741
|
+
|
|
742
|
+
# Normalize keys for comparison (convert symbols to strings)
|
|
743
|
+
a_normalized = normalize_hash_keys(a)
|
|
744
|
+
b_normalized = normalize_hash_keys(b)
|
|
745
|
+
|
|
746
|
+
a_normalized.each do |k, v|
|
|
747
|
+
return false unless b_normalized.key?(k) && compare_values(v, b_normalized[k])
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
true
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
def normalize_hash_keys(obj)
|
|
754
|
+
case obj
|
|
755
|
+
when Hash
|
|
756
|
+
normalized = {}
|
|
757
|
+
obj.each do |k, v|
|
|
758
|
+
normalized[k.to_s] = normalize_hash_keys(v)
|
|
759
|
+
end
|
|
760
|
+
normalized
|
|
761
|
+
when Array
|
|
762
|
+
obj.map { |v| normalize_hash_keys(v) }
|
|
763
|
+
else
|
|
764
|
+
obj
|
|
765
|
+
end
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
def symbolize_keys(obj)
|
|
769
|
+
case obj
|
|
770
|
+
when Hash
|
|
771
|
+
obj.transform_keys(&:to_sym).transform_values { |v| symbolize_keys(v) }
|
|
772
|
+
when Array
|
|
773
|
+
obj.map { |v| symbolize_keys(v) }
|
|
774
|
+
else
|
|
775
|
+
obj
|
|
776
|
+
end
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
def execute_command(command)
|
|
780
|
+
stdout, stderr, status = Open3.capture3(command)
|
|
781
|
+
|
|
782
|
+
unless status.success?
|
|
783
|
+
puts "Error: Command failed with exit code #{status.exitstatus}"
|
|
784
|
+
puts "Command: #{command}"
|
|
785
|
+
puts "Stderr: #{stderr}" unless stderr.empty?
|
|
786
|
+
exit 1
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
stdout
|
|
790
|
+
end
|
|
791
|
+
end
|
|
792
|
+
end
|
|
793
|
+
end
|