featurevisor 3.0.0 → 4.0.0
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 +4 -4
- data/README.md +307 -193
- data/bin/cli.rb +84 -17
- data/bin/commands/assess_distribution.rb +10 -52
- data/bin/commands/benchmark.rb +10 -48
- data/bin/commands/node_cli.rb +20 -0
- data/bin/commands/runtime_project.rb +63 -0
- data/bin/commands/test.rb +771 -259
- data/bin/commands.rb +1 -0
- data/bin/segment_tests.rb +89 -0
- data/bin/test_checks.rb +767 -0
- data/lib/featurevisor/child_instance.rb +15 -2
- data/lib/featurevisor/compare_versions.rb +18 -36
- data/lib/featurevisor/conditions.rb +91 -9
- data/lib/featurevisor/dependencies.rb +150 -0
- data/lib/featurevisor/emitter.rb +4 -0
- data/lib/featurevisor/evaluate.rb +64 -115
- data/lib/featurevisor/evaluation_data_provider.rb +100 -51
- data/lib/featurevisor/evaluation_session.rb +191 -0
- data/lib/featurevisor/events.rb +39 -100
- data/lib/featurevisor/instance.rb +258 -59
- data/lib/featurevisor/modules.rb +6 -46
- data/lib/featurevisor/mutations.rb +213 -0
- data/lib/featurevisor/normalize.rb +105 -0
- data/lib/featurevisor/unified_conditions.rb +79 -0
- data/lib/featurevisor/version.rb +1 -1
- data/lib/featurevisor.rb +7 -0
- metadata +10 -1
data/bin/commands/test.rb
CHANGED
|
@@ -2,10 +2,21 @@ require "json"
|
|
|
2
2
|
require "find"
|
|
3
3
|
require "open3"
|
|
4
4
|
require "shellwords"
|
|
5
|
+
require_relative "../test_checks"
|
|
6
|
+
require_relative "../segment_tests"
|
|
5
7
|
|
|
6
8
|
module FeaturevisorCLI
|
|
7
9
|
module Commands
|
|
8
10
|
class Test
|
|
11
|
+
class InfrastructureError < StandardError
|
|
12
|
+
attr_reader :code
|
|
13
|
+
|
|
14
|
+
def initialize(message, code: nil)
|
|
15
|
+
super(message)
|
|
16
|
+
@code = code
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
9
20
|
def self.run(options)
|
|
10
21
|
new(options).run
|
|
11
22
|
end
|
|
@@ -13,61 +24,74 @@ module FeaturevisorCLI
|
|
|
13
24
|
def initialize(options)
|
|
14
25
|
@options = options
|
|
15
26
|
@project_path = options.project_directory_path
|
|
27
|
+
@coverage_selections = []
|
|
16
28
|
end
|
|
17
29
|
|
|
18
30
|
def run
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
progress "Running tests..."
|
|
32
|
+
root_config = get_config(nil)
|
|
33
|
+
sets = selected_sets(root_config)
|
|
34
|
+
report = empty_report
|
|
35
|
+
report[:uncovered] = [] if @options.uncovered
|
|
36
|
+
matched_case = @options.case_id.nil?
|
|
37
|
+
|
|
38
|
+
sets.each do |set|
|
|
39
|
+
@active_set = set
|
|
40
|
+
config = get_config(set)
|
|
41
|
+
report[:uncovered] << get_uncovered(set) if @options.uncovered
|
|
42
|
+
begin
|
|
43
|
+
tests = get_tests(set)
|
|
44
|
+
rescue InfrastructureError => e
|
|
45
|
+
if @options.case_id && !@options.set && e.code == "test_case_not_found"
|
|
46
|
+
next
|
|
47
|
+
end
|
|
48
|
+
raise
|
|
49
|
+
end
|
|
50
|
+
matched_case = true
|
|
51
|
+
environments = get_environments(config)
|
|
52
|
+
available_targets = get_targets(set)
|
|
53
|
+
unknown_target = @options.targets.find { |target| !available_targets.include?(target) }
|
|
54
|
+
if unknown_target
|
|
55
|
+
raise InfrastructureError,
|
|
56
|
+
"Unknown target #{unknown_target.inspect}. Available targets: #{available_targets.empty? ? 'none' : available_targets.join(', ')}."
|
|
57
|
+
end
|
|
58
|
+
targets = @options.targets.empty? ? available_targets : @options.targets
|
|
59
|
+
segments_by_key = get_segments(set)
|
|
60
|
+
datafiles_by_key = build_datafiles(environments, targets, @options.inflate, set)
|
|
61
|
+
merge_report!(report, run_tests(tests, datafiles_by_key, segments_by_key, get_logger_level, config, set))
|
|
29
62
|
end
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
# Build base and Target datafiles for all environments.
|
|
34
|
-
datafiles_by_key = build_datafiles(environments, targets, @options.inflate)
|
|
35
|
-
|
|
36
|
-
puts ""
|
|
37
|
-
|
|
38
|
-
# Get log level
|
|
39
|
-
level = get_logger_level
|
|
40
|
-
tests = get_tests
|
|
41
|
-
|
|
42
|
-
if tests.empty?
|
|
43
|
-
puts "No tests found"
|
|
44
|
-
return
|
|
63
|
+
if @options.case_id && !matched_case
|
|
64
|
+
raise InfrastructureError, "No test case matches --case and the selected filters"
|
|
45
65
|
end
|
|
46
66
|
|
|
47
|
-
|
|
48
|
-
|
|
67
|
+
report[:coverage] = coverage_report if @options.coverage
|
|
68
|
+
finalize_report!(report)
|
|
69
|
+
print_report(report)
|
|
70
|
+
exit 1 unless report[:passed]
|
|
71
|
+
rescue InfrastructureError => e
|
|
72
|
+
report ||= empty_report
|
|
73
|
+
report[:errors] << { message: e.message }
|
|
74
|
+
finalize_report!(report)
|
|
75
|
+
print_report(report)
|
|
76
|
+
exit 1
|
|
49
77
|
end
|
|
50
78
|
|
|
51
79
|
private
|
|
52
80
|
|
|
53
|
-
def get_config
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
config_output = execute_command(command)
|
|
81
|
+
def get_config(set = @active_set)
|
|
82
|
+
progress "Getting config#{set ? " for set #{set.inspect}" : ''}..."
|
|
83
|
+
config_output = execute_command(node_command("config", "--json"))
|
|
57
84
|
|
|
58
85
|
begin
|
|
59
86
|
JSON.parse(config_output, symbolize_names: true)
|
|
60
87
|
rescue JSON::ParserError => e
|
|
61
|
-
|
|
62
|
-
puts "Command output: #{config_output}"
|
|
63
|
-
exit 1
|
|
88
|
+
raise InfrastructureError, "Failed to parse config JSON: #{e.message}"
|
|
64
89
|
end
|
|
65
90
|
end
|
|
66
91
|
|
|
67
|
-
def get_segments
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
segments_output = execute_command(command)
|
|
92
|
+
def get_segments(set = @active_set)
|
|
93
|
+
progress "Getting segments..."
|
|
94
|
+
segments_output = execute_command(node_command("list", "--segments", "--json", *set_arguments(set)))
|
|
71
95
|
|
|
72
96
|
begin
|
|
73
97
|
segments = JSON.parse(segments_output, symbolize_names: true)
|
|
@@ -79,16 +103,13 @@ module FeaturevisorCLI
|
|
|
79
103
|
end
|
|
80
104
|
segments_by_key
|
|
81
105
|
rescue JSON::ParserError => e
|
|
82
|
-
|
|
83
|
-
puts "Command output: #{segments_output}"
|
|
84
|
-
exit 1
|
|
106
|
+
raise InfrastructureError, "Failed to parse segments JSON: #{e.message}"
|
|
85
107
|
end
|
|
86
108
|
end
|
|
87
109
|
|
|
88
|
-
def get_targets
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
targets_output = execute_command(command)
|
|
110
|
+
def get_targets(set = @active_set)
|
|
111
|
+
progress "Getting targets..."
|
|
112
|
+
targets_output = execute_command(node_command("list", "--targets", "--json", *set_arguments(set)))
|
|
92
113
|
|
|
93
114
|
begin
|
|
94
115
|
targets = JSON.parse(targets_output, symbolize_names: true)
|
|
@@ -107,12 +128,23 @@ module FeaturevisorCLI
|
|
|
107
128
|
[]
|
|
108
129
|
end
|
|
109
130
|
rescue JSON::ParserError => e
|
|
110
|
-
|
|
111
|
-
puts "Command output: #{targets_output}"
|
|
112
|
-
exit 1
|
|
131
|
+
raise InfrastructureError, "Failed to parse targets JSON: #{e.message}"
|
|
113
132
|
end
|
|
114
133
|
end
|
|
115
134
|
|
|
135
|
+
def selected_sets(config)
|
|
136
|
+
return [@options.set] if @options.set
|
|
137
|
+
return [nil] unless config[:sets]
|
|
138
|
+
|
|
139
|
+
directory = config[:setsDirectoryPath] || "sets"
|
|
140
|
+
path = File.expand_path(directory, @project_path)
|
|
141
|
+
sets = Dir.children(path).select { |entry| File.directory?(File.join(path, entry)) }.sort
|
|
142
|
+
raise InfrastructureError, "No sets found in #{directory}." if sets.empty?
|
|
143
|
+
sets
|
|
144
|
+
rescue SystemCallError => e
|
|
145
|
+
raise InfrastructureError, "Failed to list project sets in #{directory}: #{e.message}"
|
|
146
|
+
end
|
|
147
|
+
|
|
116
148
|
def get_environments(config)
|
|
117
149
|
environments = config[:environments]
|
|
118
150
|
|
|
@@ -123,35 +155,33 @@ module FeaturevisorCLI
|
|
|
123
155
|
end
|
|
124
156
|
|
|
125
157
|
def base_datafile_key(environment)
|
|
126
|
-
environment == false ?
|
|
158
|
+
[environment == false ? nil : environment, nil].freeze
|
|
127
159
|
end
|
|
128
160
|
|
|
129
161
|
def target_datafile_key(environment, target)
|
|
130
|
-
|
|
131
|
-
"false-target-#{target}"
|
|
132
|
-
else
|
|
133
|
-
"#{environment}-target-#{target}"
|
|
134
|
-
end
|
|
162
|
+
[environment == false ? nil : environment, target].freeze
|
|
135
163
|
end
|
|
136
164
|
|
|
137
|
-
def build_datafiles(environments, targets, inflate)
|
|
165
|
+
def build_datafiles(environments, targets, inflate, set = @active_set)
|
|
138
166
|
datafiles_by_key = {}
|
|
139
167
|
|
|
140
168
|
environments.each do |environment|
|
|
141
169
|
environment_label = environment == false ? "default (no environment)" : environment
|
|
142
|
-
|
|
170
|
+
progress "Building datafile for environment: #{environment_label}..."
|
|
143
171
|
|
|
144
172
|
datafiles_by_key[base_datafile_key(environment)] = build_single_datafile(
|
|
145
173
|
environment: environment,
|
|
146
|
-
inflate: inflate
|
|
174
|
+
inflate: inflate,
|
|
175
|
+
set: set
|
|
147
176
|
)
|
|
148
177
|
|
|
149
178
|
targets.each do |target|
|
|
150
|
-
|
|
179
|
+
progress "Building datafile for target: #{target}..."
|
|
151
180
|
datafiles_by_key[target_datafile_key(environment, target)] = build_single_datafile(
|
|
152
181
|
environment: environment,
|
|
153
182
|
inflate: inflate,
|
|
154
|
-
target: target
|
|
183
|
+
target: target,
|
|
184
|
+
set: set
|
|
155
185
|
)
|
|
156
186
|
end
|
|
157
187
|
end
|
|
@@ -159,30 +189,17 @@ module FeaturevisorCLI
|
|
|
159
189
|
datafiles_by_key
|
|
160
190
|
end
|
|
161
191
|
|
|
162
|
-
def build_single_datafile(environment:, inflate:, target: nil)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if inflate && inflate > 0
|
|
170
|
-
command_parts << "--inflate=#{inflate}"
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
if target
|
|
174
|
-
command_parts << "--target=#{Shellwords.escape(target.to_s)}"
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
command = "(cd #{Shellwords.escape(@project_path)} && #{command_parts.join(' ')})"
|
|
178
|
-
datafile_output = execute_command(command)
|
|
192
|
+
def build_single_datafile(environment:, inflate:, target: nil, set: @active_set)
|
|
193
|
+
arguments = ["build", "--json"]
|
|
194
|
+
arguments << "--environment=#{environment}" if environment != false && !environment.nil?
|
|
195
|
+
arguments << "--inflate=#{inflate}" if inflate && inflate > 0
|
|
196
|
+
arguments << "--target=#{target}" if target
|
|
197
|
+
datafile_output = execute_command(node_command(*arguments, *set_arguments(set)))
|
|
179
198
|
|
|
180
199
|
begin
|
|
181
200
|
JSON.parse(datafile_output, symbolize_names: true)
|
|
182
201
|
rescue JSON::ParserError => e
|
|
183
|
-
|
|
184
|
-
puts "Command output: #{datafile_output}"
|
|
185
|
-
exit 1
|
|
202
|
+
raise InfrastructureError, "Failed to parse datafile JSON: #{e.message}"
|
|
186
203
|
end
|
|
187
204
|
end
|
|
188
205
|
|
|
@@ -196,52 +213,67 @@ module FeaturevisorCLI
|
|
|
196
213
|
end
|
|
197
214
|
end
|
|
198
215
|
|
|
199
|
-
def get_tests
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if @options.key_pattern && !@options.key_pattern.empty?
|
|
203
|
-
command_parts << "--keyPattern=#{Shellwords.escape(@options.key_pattern)}"
|
|
204
|
-
end
|
|
205
|
-
|
|
216
|
+
def get_tests(set = @active_set)
|
|
217
|
+
arguments = ["list", "--tests", "--execution-protocol", "--json"]
|
|
218
|
+
arguments << "--keyPattern=#{@options.key_pattern}" if @options.key_pattern && !@options.key_pattern.empty?
|
|
206
219
|
if @options.assertion_pattern && !@options.assertion_pattern.empty?
|
|
207
|
-
|
|
220
|
+
arguments << "--assertionPattern=#{@options.assertion_pattern}"
|
|
208
221
|
end
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
tests_output = execute_command(
|
|
222
|
+
arguments << "--entityType=#{@options.entity_type}" if @options.entity_type && !@options.entity_type.empty?
|
|
223
|
+
arguments << "--case=#{@options.case_id}" if @options.case_id
|
|
224
|
+
tests_output = execute_command(node_command(*arguments, *set_arguments(set)))
|
|
212
225
|
|
|
213
226
|
begin
|
|
214
|
-
JSON.parse(tests_output, symbolize_names: true)
|
|
227
|
+
payload = JSON.parse(tests_output, symbolize_names: true)
|
|
228
|
+
unless payload.is_a?(Hash)
|
|
229
|
+
raise InfrastructureError, "Invalid test execution protocol: expected an envelope object"
|
|
230
|
+
end
|
|
231
|
+
unless payload[:kind] == "featurevisor.test-execution"
|
|
232
|
+
raise InfrastructureError, 'Invalid test execution protocol: kind must be "featurevisor.test-execution"'
|
|
233
|
+
end
|
|
234
|
+
version = payload[:schemaVersion]
|
|
235
|
+
unless version.is_a?(Numeric) && version == 1
|
|
236
|
+
raise InfrastructureError, "Unsupported test execution protocol schemaVersion; expected numeric 1"
|
|
237
|
+
end
|
|
238
|
+
tests = payload[:tests]
|
|
239
|
+
unless tests.is_a?(Array) && tests.all? { |test| test.is_a?(Hash) }
|
|
240
|
+
raise InfrastructureError, "Invalid test execution protocol: tests must be an array of objects"
|
|
241
|
+
end
|
|
242
|
+
tests
|
|
215
243
|
rescue JSON::ParserError => e
|
|
216
|
-
|
|
217
|
-
puts "Command output: #{tests_output}"
|
|
218
|
-
exit 1
|
|
244
|
+
raise InfrastructureError, "Failed to parse tests JSON: #{e.message}"
|
|
219
245
|
end
|
|
220
246
|
end
|
|
221
247
|
|
|
248
|
+
def set_arguments(set = @active_set)
|
|
249
|
+
set ? ["--set=#{set}"] : []
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def node_command(*arguments)
|
|
253
|
+
[*FeaturevisorCLI::NodeCli.command(@project_path), *arguments]
|
|
254
|
+
end
|
|
255
|
+
|
|
222
256
|
def resolve_datafile_for_assertion(assertion, datafiles_by_key)
|
|
223
257
|
environment = assertion.key?(:environment) ? assertion[:environment] : false
|
|
224
258
|
environment = false if environment.nil?
|
|
225
259
|
|
|
226
|
-
|
|
227
|
-
base_key = base_datafile_key(environment)
|
|
260
|
+
return datafiles_by_key[target_datafile_key(environment, assertion[:target])] if assertion[:target]
|
|
228
261
|
|
|
229
|
-
|
|
230
|
-
return datafiles_by_key[target_key]
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
datafiles_by_key[base_key]
|
|
262
|
+
datafiles_by_key[base_datafile_key(environment)]
|
|
234
263
|
end
|
|
235
264
|
|
|
236
|
-
def create_tester_instance(datafile, level, assertion)
|
|
237
|
-
sticky = parse_sticky(assertion[:
|
|
265
|
+
def create_tester_instance(datafile, level, assertion, set = @active_set)
|
|
266
|
+
sticky = parse_sticky(assertion[:stickyFeatures])
|
|
238
267
|
sticky_variables = assertion[:stickyVariables].is_a?(Hash) ? assertion[:stickyVariables] : {}
|
|
239
268
|
|
|
240
|
-
|
|
269
|
+
diagnostics = []
|
|
270
|
+
check_level = assertion.key?(:checks) || !Array(assertion[:children]).empty? ? "error" : level
|
|
271
|
+
instance = Featurevisor.create_featurevisor(
|
|
241
272
|
datafile: datafile,
|
|
242
273
|
sticky_features: sticky,
|
|
243
274
|
sticky_variables: sticky_variables,
|
|
244
|
-
log_level:
|
|
275
|
+
log_level: check_level,
|
|
276
|
+
on_diagnostic: ->(diagnostic) { diagnostics << diagnostic },
|
|
245
277
|
modules: [
|
|
246
278
|
{
|
|
247
279
|
name: "test-module",
|
|
@@ -252,139 +284,274 @@ module FeaturevisorCLI
|
|
|
252
284
|
else
|
|
253
285
|
options[:bucket_value]
|
|
254
286
|
end
|
|
287
|
+
end,
|
|
288
|
+
after_evaluation: ->(evaluation, _evaluation_options) do
|
|
289
|
+
record_coverage_selection(evaluation, assertion, set) if @options.coverage && !(datafile[:_segment_test_keys] || {}).value?(evaluation[:feature_key].to_s)
|
|
290
|
+
evaluation
|
|
255
291
|
end
|
|
256
292
|
}
|
|
257
293
|
]
|
|
258
294
|
)
|
|
295
|
+
instance.instance_variable_set(:@__segment_test_keys, datafile[:_segment_test_keys] || {})
|
|
296
|
+
instance.instance_variable_set(:@__segment_test_diagnostics, diagnostics)
|
|
297
|
+
instance.on("error", ->(event) { diagnostics << event[:diagnostic] if event[:diagnostic] })
|
|
298
|
+
[instance, diagnostics]
|
|
259
299
|
end
|
|
260
300
|
|
|
261
|
-
def run_tests(tests, datafiles_by_key, segments_by_key, level,
|
|
262
|
-
|
|
263
|
-
failed_tests_count = 0
|
|
264
|
-
passed_assertions_count = 0
|
|
265
|
-
failed_assertions_count = 0
|
|
301
|
+
def run_tests(tests, datafiles_by_key, segments_by_key, level, _config, set)
|
|
302
|
+
report = empty_report
|
|
266
303
|
|
|
267
304
|
tests.each do |test|
|
|
268
|
-
|
|
305
|
+
discriminators = %i[feature variable segment scenario].select { |key| test.key?(key) }
|
|
306
|
+
type = discriminators.first
|
|
307
|
+
invalid_discriminator = discriminators.length != 1
|
|
269
308
|
assertions = test[:assertions] || []
|
|
270
|
-
if
|
|
271
|
-
assertions =
|
|
272
|
-
|
|
273
|
-
|
|
309
|
+
if invalid_discriminator
|
|
310
|
+
assertions = [{ description: "malformed test spec", __invalid_discriminator: true }]
|
|
311
|
+
elsif type == :scenario && assertions.empty?
|
|
312
|
+
assertions = [{ description: "malformed scenario spec", __missing_assertions: true }]
|
|
313
|
+
end
|
|
314
|
+
if !invalid_discriminator && %i[feature variable scenario].include?(type) && !@options.targets.empty?
|
|
315
|
+
assertions = assertions.select { |assertion| assertion[:target].nil? || @options.targets.include?(assertion[:target]) }
|
|
274
316
|
next if assertions.empty?
|
|
275
317
|
end
|
|
276
|
-
results = ""
|
|
277
|
-
test_has_error = false
|
|
278
|
-
test_duration = 0.0
|
|
279
|
-
|
|
280
|
-
assertions.each do |assertion|
|
|
281
|
-
if assertion.is_a?(Hash)
|
|
282
|
-
test_result = nil
|
|
283
|
-
|
|
284
|
-
if test[:feature]
|
|
285
|
-
datafile = resolve_datafile_for_assertion(assertion, datafiles_by_key)
|
|
286
|
-
if datafile.nil?
|
|
287
|
-
test_result = {
|
|
288
|
-
has_error: true,
|
|
289
|
-
errors: " ✘ no datafile found for assertion target/environment combination\n",
|
|
290
|
-
duration: 0
|
|
291
|
-
}
|
|
292
|
-
end
|
|
293
318
|
|
|
294
|
-
|
|
295
|
-
|
|
319
|
+
result = {
|
|
320
|
+
type: (type || :unknown).to_s,
|
|
321
|
+
key: type ? test[type] : test[:key],
|
|
322
|
+
source: test.dig(:_test, :source) || test[:key],
|
|
323
|
+
set: test.dig(:_test, :set) || set,
|
|
324
|
+
passed: true,
|
|
325
|
+
cases: []
|
|
326
|
+
}
|
|
296
327
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
328
|
+
assertions.each do |assertion|
|
|
329
|
+
next unless assertion.is_a?(Hash)
|
|
330
|
+
test_result = execute_assertion(test, type, assertion, datafiles_by_key, segments_by_key, level, set)
|
|
331
|
+
test_result ||= { has_error: true, errors: " ✘ test assertion could not be evaluated\n", duration: 0 }
|
|
332
|
+
errors = error_objects(test_result[:errors], type: type, assertion: assertion)
|
|
333
|
+
case_result = {
|
|
334
|
+
id: assertion.dig(:_case, :id),
|
|
335
|
+
description: assertion[:description],
|
|
336
|
+
environment: normalized_environment(assertion[:environment]),
|
|
337
|
+
target: assertion[:target],
|
|
338
|
+
context: assertion[:context].is_a?(Hash) ? assertion[:context] : {},
|
|
339
|
+
matrixValues: assertion.dig(:_case, :matrixValues) || {},
|
|
340
|
+
passed: !test_result[:has_error],
|
|
341
|
+
errors: errors,
|
|
342
|
+
rerun: rerun_instruction(assertion, result[:set])
|
|
343
|
+
}
|
|
344
|
+
result[:cases] << case_result
|
|
345
|
+
result[:passed] = false unless case_result[:passed]
|
|
346
|
+
end
|
|
303
347
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
elsif test[:variable]
|
|
307
|
-
datafile = resolve_datafile_for_assertion(assertion, datafiles_by_key)
|
|
308
|
-
if datafile
|
|
309
|
-
instance = create_tester_instance(datafile, level, assertion)
|
|
310
|
-
test_result = run_test_variable(assertion, test[:variable], instance)
|
|
311
|
-
else
|
|
312
|
-
test_result = { has_error: true, errors: " ✘ no datafile found for assertion target/environment combination\n", duration: 0 }
|
|
313
|
-
end
|
|
314
|
-
elsif test[:segment]
|
|
315
|
-
segment_key = test[:segment]
|
|
316
|
-
segment = segments_by_key[segment_key]
|
|
317
|
-
if segment.is_a?(Hash)
|
|
318
|
-
test_result = run_test_segment(assertion, segment, level)
|
|
319
|
-
end
|
|
320
|
-
end
|
|
348
|
+
report[:results] << result
|
|
349
|
+
end
|
|
321
350
|
|
|
322
|
-
|
|
323
|
-
|
|
351
|
+
finalize_report!(report)
|
|
352
|
+
report
|
|
353
|
+
end
|
|
324
354
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
failed_assertions_count += 1
|
|
330
|
-
else
|
|
331
|
-
results += " ✔ #{assertion[:description]} (#{(test_result[:duration] * 1000).round(2)}ms)\n"
|
|
332
|
-
passed_assertions_count += 1
|
|
333
|
-
end
|
|
334
|
-
end
|
|
335
|
-
end
|
|
336
|
-
end
|
|
355
|
+
def execute_assertion(test, type, assertion, datafiles_by_key, segments_by_key, level, set)
|
|
356
|
+
if assertion[:__invalid_discriminator]
|
|
357
|
+
return { has_error: true, errors: " ✘ test spec requires exactly one of feature, variable, segment or scenario\n", duration: 0 }
|
|
358
|
+
end
|
|
337
359
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
360
|
+
datafile = resolve_datafile_for_assertion(assertion, datafiles_by_key)
|
|
361
|
+
if type == :segment
|
|
362
|
+
if !datafile && ((assertion[:environment].is_a?(String) && !assertion[:environment].empty?) || assertion[:target])
|
|
363
|
+
return { has_error: true, errors: " ✘ no datafile found for assertion target/environment combination\n", duration: 0 }
|
|
341
364
|
end
|
|
365
|
+
authored = test[:_segments].is_a?(Hash) ? test[:_segments] : segments_by_key
|
|
366
|
+
return run_test_segment(assertion, authored[test[:segment].to_sym] || authored[test[:segment]], level, datafile, authored, test[:segment], set)
|
|
367
|
+
end
|
|
368
|
+
return { has_error: true, errors: " ✘ no datafile found for assertion target/environment combination\n", duration: 0 } unless datafile
|
|
342
369
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
370
|
+
authored_segments = test[:_segments].is_a?(Hash) ? test[:_segments] : {}
|
|
371
|
+
preflight_details = {}
|
|
372
|
+
begin
|
|
373
|
+
preflight_assertion!(test, assertion, datafile, authored_segments, preflight_details)
|
|
374
|
+
rescue FeaturevisorCLI::TestChecks::CheckError => e
|
|
375
|
+
error = FeaturevisorCLI::TestChecks.comparison_error(
|
|
376
|
+
e.message, "valid checks", e.message, **preflight_details
|
|
377
|
+
)
|
|
378
|
+
return { has_error: true, errors: [error], duration: 0 }
|
|
348
379
|
end
|
|
349
380
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
381
|
+
datafile = SegmentTests.prepare(datafile, authored_segments)
|
|
382
|
+
progress JSON.pretty_generate(datafile) if @options.show_datafile
|
|
383
|
+
instance, diagnostics = create_tester_instance(datafile, level, assertion, set)
|
|
384
|
+
begin
|
|
385
|
+
return run_test_feature(assertion, test[:feature], instance, level) if type == :feature
|
|
386
|
+
return run_test_variable(assertion, test[:variable], instance, datafile, diagnostics, authored_segments) if type == :variable
|
|
387
|
+
return run_test_scenario(assertion, instance, datafile, diagnostics, authored_segments) if type == :scenario
|
|
388
|
+
ensure
|
|
389
|
+
instance.close
|
|
390
|
+
end
|
|
391
|
+
end
|
|
354
392
|
|
|
355
|
-
|
|
356
|
-
|
|
393
|
+
def preflight_assertion!(test, assertion, datafile, segments, details = {})
|
|
394
|
+
if test[:feature]
|
|
395
|
+
feature = FeaturevisorCLI::TestChecks.keyed(FeaturevisorCLI::TestChecks.keyed(datafile, :features), test[:feature])
|
|
396
|
+
variables = FeaturevisorCLI::TestChecks.keyed(feature, :variablesSchema)
|
|
397
|
+
[assertion, *Array(assertion[:children])].each do |current|
|
|
398
|
+
next unless current.is_a?(Hash)
|
|
399
|
+
(current[:expectedVariables] || {}).each_key do |variable|
|
|
400
|
+
unless FeaturevisorCLI::TestChecks.keyed(variables, variable)
|
|
401
|
+
raise FeaturevisorCLI::TestChecks::CheckError,
|
|
402
|
+
"variable #{variable.inspect} is absent from feature #{test[:feature].inspect} in the selected datafile"
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
owner = test[:variable]
|
|
408
|
+
FeaturevisorCLI::TestChecks.preflight!(datafile, assertion[:checks], owner_variable: owner, segments: segments) if assertion.key?(:checks)
|
|
409
|
+
children = assertion[:children] || []
|
|
410
|
+
raise FeaturevisorCLI::TestChecks::CheckError, "children must be an array" unless children.is_a?(Array)
|
|
411
|
+
children.each_with_index do |child, index|
|
|
412
|
+
raise FeaturevisorCLI::TestChecks::CheckError, "children[#{index}] must be an object" unless child.is_a?(Hash)
|
|
413
|
+
if child.key?(:checks) || test[:scenario]
|
|
414
|
+
details[:child_index] = index
|
|
415
|
+
FeaturevisorCLI::TestChecks.preflight!(datafile, child[:checks], owner_variable: owner, segments: segments)
|
|
416
|
+
end
|
|
357
417
|
end
|
|
358
418
|
end
|
|
359
419
|
|
|
360
|
-
def run_test_variable(assertion, variable_key, instance)
|
|
361
|
-
|
|
420
|
+
def run_test_variable(assertion, variable_key, instance, datafile, diagnostics, segments = {})
|
|
421
|
+
root_context = parse_context(assertion[:context])
|
|
422
|
+
matcher = segment_matcher(instance, segments)
|
|
423
|
+
instance.set_context(root_context, true)
|
|
362
424
|
options = {}
|
|
363
425
|
options[:default_variable_value] = assertion[:defaultVariableValue] if assertion.key?(:defaultVariableValue)
|
|
364
426
|
started = Time.now
|
|
365
|
-
errors =
|
|
427
|
+
errors = []
|
|
366
428
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
429
|
+
errors.concat(error_objects(test_variable_expectation(assertion, variable_key, instance, options)))
|
|
430
|
+
if assertion.key?(:checks)
|
|
431
|
+
check_result = FeaturevisorCLI::TestChecks.evaluate_result(
|
|
432
|
+
instance, datafile, assertion[:checks], owner_variable: variable_key, diagnostics: diagnostics,
|
|
433
|
+
segments: segments, context: root_context, segment_matcher: matcher
|
|
434
|
+
)
|
|
435
|
+
errors.concat(check_result[:errors])
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
Array(assertion[:children]).each_with_index do |child_assertion, child_index|
|
|
439
|
+
child = instance.spawn(
|
|
440
|
+
parse_context(child_assertion[:context]),
|
|
441
|
+
sticky_features: parse_sticky(child_assertion[:stickyFeatures]),
|
|
442
|
+
sticky_variables: child_assertion[:stickyVariables].is_a?(Hash) ? child_assertion[:stickyVariables] : {}
|
|
443
|
+
)
|
|
444
|
+
begin
|
|
445
|
+
child_options = {}
|
|
446
|
+
if child_assertion.key?(:defaultVariableValue)
|
|
447
|
+
child_options[:default_variable_value] = child_assertion[:defaultVariableValue]
|
|
448
|
+
end
|
|
449
|
+
errors.concat(error_objects(test_variable_expectation(
|
|
450
|
+
child_assertion, variable_key, child, child_options, child_index
|
|
451
|
+
)))
|
|
452
|
+
if child_assertion.key?(:checks)
|
|
453
|
+
check_result = FeaturevisorCLI::TestChecks.evaluate_result(
|
|
454
|
+
child,
|
|
455
|
+
datafile,
|
|
456
|
+
child_assertion[:checks],
|
|
457
|
+
owner_variable: variable_key,
|
|
458
|
+
diagnostics: diagnostics,
|
|
459
|
+
child_index: child_index,
|
|
460
|
+
segments: segments,
|
|
461
|
+
context: root_context.merge(parse_context(child_assertion[:context])),
|
|
462
|
+
segment_matcher: segment_matcher(child, segments, instance.instance_variable_get(:@__segment_test_keys), diagnostics)
|
|
463
|
+
)
|
|
464
|
+
errors.concat(check_result[:errors])
|
|
465
|
+
end
|
|
466
|
+
ensure
|
|
467
|
+
child.close
|
|
371
468
|
end
|
|
372
469
|
end
|
|
373
470
|
|
|
471
|
+
{ has_error: !errors.empty?, errors: errors, duration: Time.now - started }
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def run_test_scenario(assertion, instance, datafile, diagnostics, segments = {})
|
|
475
|
+
started = Time.now
|
|
476
|
+
errors = []
|
|
477
|
+
root_context = parse_context(assertion[:context])
|
|
478
|
+
matcher = segment_matcher(instance, segments)
|
|
479
|
+
instance.set_context(root_context, true)
|
|
480
|
+
if assertion[:__missing_assertions]
|
|
481
|
+
errors << { message: "scenario spec requires assertions" }
|
|
482
|
+
elsif assertion.key?(:checks)
|
|
483
|
+
check_result = FeaturevisorCLI::TestChecks.evaluate_result(
|
|
484
|
+
instance, datafile, assertion[:checks], owner_variable: nil, diagnostics: diagnostics,
|
|
485
|
+
segments: segments, context: root_context, segment_matcher: matcher
|
|
486
|
+
)
|
|
487
|
+
errors.concat(check_result[:errors])
|
|
488
|
+
elsif Array(assertion[:children]).empty?
|
|
489
|
+
errors << { message: "scenario assertion requires checks or children" }
|
|
490
|
+
end
|
|
491
|
+
Array(assertion[:children]).each_with_index do |child_assertion, child_index|
|
|
492
|
+
child = instance.spawn(
|
|
493
|
+
parse_context(child_assertion[:context]),
|
|
494
|
+
sticky_features: parse_sticky(child_assertion[:stickyFeatures]),
|
|
495
|
+
sticky_variables: child_assertion[:stickyVariables].is_a?(Hash) ? child_assertion[:stickyVariables] : {}
|
|
496
|
+
)
|
|
497
|
+
begin
|
|
498
|
+
check_result = FeaturevisorCLI::TestChecks.evaluate_result(
|
|
499
|
+
child,
|
|
500
|
+
datafile,
|
|
501
|
+
child_assertion[:checks],
|
|
502
|
+
owner_variable: nil,
|
|
503
|
+
diagnostics: diagnostics,
|
|
504
|
+
child_index: child_index,
|
|
505
|
+
segments: segments,
|
|
506
|
+
context: root_context.merge(parse_context(child_assertion[:context])),
|
|
507
|
+
segment_matcher: segment_matcher(child, segments, instance.instance_variable_get(:@__segment_test_keys), diagnostics)
|
|
508
|
+
)
|
|
509
|
+
errors.concat(check_result[:errors])
|
|
510
|
+
ensure
|
|
511
|
+
child.close
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
{ has_error: !errors.empty?, errors: errors, duration: Time.now - started }
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def test_variable_expectation(assertion, variable_key, evaluator, options, child_index = nil)
|
|
518
|
+
evaluation = evaluator.evaluate_variable(variable_key, {}, options)
|
|
519
|
+
details = { variableKey: variable_key }
|
|
520
|
+
details[:childIndex] = child_index unless child_index.nil?
|
|
521
|
+
errors = []
|
|
522
|
+
if assertion.key?(:expectedValue)
|
|
523
|
+
value = evaluator.get_variable(variable_key, {}, options)
|
|
524
|
+
errors << expectation_error("variable", assertion[:expectedValue], value, details) unless compare_values(value, assertion[:expectedValue])
|
|
525
|
+
end
|
|
374
526
|
if assertion[:expectedEvaluation].is_a?(Hash)
|
|
375
|
-
evaluation = instance.evaluate_variable(variable_key, context, options)
|
|
376
527
|
assertion[:expectedEvaluation].each do |key, expected|
|
|
377
528
|
actual = get_evaluation_value(evaluation, key)
|
|
378
|
-
|
|
529
|
+
unless compare_values(actual, expected)
|
|
530
|
+
errors << expectation_error(
|
|
531
|
+
"evaluation",
|
|
532
|
+
expected,
|
|
533
|
+
actual,
|
|
534
|
+
details.merge(evaluationType: "variable", evaluationKey: key)
|
|
535
|
+
)
|
|
536
|
+
end
|
|
379
537
|
end
|
|
380
538
|
end
|
|
539
|
+
errors
|
|
540
|
+
end
|
|
381
541
|
|
|
382
|
-
|
|
542
|
+
def expectation_error(type, expected, actual, details = {})
|
|
543
|
+
{ type: type, expected: expected, actual: actual, details: details }
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
def format_test_value(value)
|
|
547
|
+
JSON.generate(value)
|
|
548
|
+
rescue JSON::GeneratorError
|
|
549
|
+
value.inspect
|
|
383
550
|
end
|
|
384
551
|
|
|
385
552
|
def run_test_feature(assertion, feature_key, instance, level)
|
|
386
553
|
context = parse_context(assertion[:context])
|
|
387
|
-
sticky = parse_sticky(assertion[:
|
|
554
|
+
sticky = parse_sticky(assertion[:stickyFeatures])
|
|
388
555
|
|
|
389
556
|
# Set context and sticky for this assertion
|
|
390
557
|
instance.set_context(context, false)
|
|
@@ -396,7 +563,7 @@ module FeaturevisorCLI
|
|
|
396
563
|
override_options = create_override_options(assertion)
|
|
397
564
|
|
|
398
565
|
has_error = false
|
|
399
|
-
errors =
|
|
566
|
+
errors = []
|
|
400
567
|
start_time = Time.now
|
|
401
568
|
|
|
402
569
|
# Test expectedToBeEnabled
|
|
@@ -406,7 +573,7 @@ module FeaturevisorCLI
|
|
|
406
573
|
|
|
407
574
|
if is_enabled != expected_to_be_enabled
|
|
408
575
|
has_error = true
|
|
409
|
-
errors
|
|
576
|
+
errors << expectation_error("flag", expected_to_be_enabled, is_enabled)
|
|
410
577
|
end
|
|
411
578
|
end
|
|
412
579
|
|
|
@@ -418,7 +585,7 @@ module FeaturevisorCLI
|
|
|
418
585
|
variation_value = variation.nil? ? nil : variation
|
|
419
586
|
if !compare_values(variation_value, expected_variation)
|
|
420
587
|
has_error = true
|
|
421
|
-
errors
|
|
588
|
+
errors << expectation_error("variation", expected_variation, variation_value)
|
|
422
589
|
end
|
|
423
590
|
end
|
|
424
591
|
|
|
@@ -449,8 +616,7 @@ module FeaturevisorCLI
|
|
|
449
616
|
|
|
450
617
|
if !passed
|
|
451
618
|
has_error = true
|
|
452
|
-
|
|
453
|
-
errors += " ✘ expectedVariables.#{variable_key}: expected #{expected_value} but received #{actual_json}\n"
|
|
619
|
+
errors << expectation_error("variable", expected_value, actual_value, variableKey: variable_key)
|
|
454
620
|
end
|
|
455
621
|
next
|
|
456
622
|
rescue JSON::ParserError
|
|
@@ -461,7 +627,7 @@ module FeaturevisorCLI
|
|
|
461
627
|
# Regular comparison for non-JSON strings or when JSON parsing fails
|
|
462
628
|
if !compare_values(actual_value, expected_value)
|
|
463
629
|
has_error = true
|
|
464
|
-
errors
|
|
630
|
+
errors << expectation_error("variable", expected_value, actual_value, variableKey: variable_key)
|
|
465
631
|
end
|
|
466
632
|
end
|
|
467
633
|
end
|
|
@@ -477,7 +643,10 @@ module FeaturevisorCLI
|
|
|
477
643
|
actual_value = get_evaluation_value(evaluation, key)
|
|
478
644
|
if !compare_values(actual_value, expected_value)
|
|
479
645
|
has_error = true
|
|
480
|
-
errors
|
|
646
|
+
errors << expectation_error(
|
|
647
|
+
"evaluation", expected_value, actual_value,
|
|
648
|
+
evaluationType: "flag", evaluationKey: key
|
|
649
|
+
)
|
|
481
650
|
end
|
|
482
651
|
end
|
|
483
652
|
end
|
|
@@ -489,7 +658,10 @@ module FeaturevisorCLI
|
|
|
489
658
|
actual_value = get_evaluation_value(evaluation, key)
|
|
490
659
|
if !compare_values(actual_value, expected_value)
|
|
491
660
|
has_error = true
|
|
492
|
-
errors
|
|
661
|
+
errors << expectation_error(
|
|
662
|
+
"evaluation", expected_value, actual_value,
|
|
663
|
+
evaluationType: "variation", evaluationKey: key
|
|
664
|
+
)
|
|
493
665
|
end
|
|
494
666
|
end
|
|
495
667
|
end
|
|
@@ -503,7 +675,10 @@ module FeaturevisorCLI
|
|
|
503
675
|
actual_value = get_evaluation_value(evaluation, key)
|
|
504
676
|
if !compare_values(actual_value, expected_value)
|
|
505
677
|
has_error = true
|
|
506
|
-
errors
|
|
678
|
+
errors << expectation_error(
|
|
679
|
+
"evaluation", expected_value, actual_value,
|
|
680
|
+
variableKey: variable_key, evaluationKey: key
|
|
681
|
+
)
|
|
507
682
|
end
|
|
508
683
|
end
|
|
509
684
|
end
|
|
@@ -513,28 +688,25 @@ module FeaturevisorCLI
|
|
|
513
688
|
|
|
514
689
|
# Test children
|
|
515
690
|
if assertion[:children]
|
|
516
|
-
assertion[:children].
|
|
691
|
+
assertion[:children].each_with_index do |child, child_index|
|
|
517
692
|
if child.is_a?(Hash)
|
|
518
693
|
child_context = parse_context(child[:context])
|
|
519
694
|
|
|
520
695
|
# Create override options for child with sticky values
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
child_instance.set_sticky_features(child_sticky, false)
|
|
696
|
+
child_instance = instance.spawn(
|
|
697
|
+
child_context,
|
|
698
|
+
sticky_features: parse_sticky(child[:stickyFeatures]) || {},
|
|
699
|
+
sticky_variables: child[:stickyVariables].is_a?(Hash) ? child[:stickyVariables] : {}
|
|
700
|
+
)
|
|
701
|
+
begin
|
|
702
|
+
child_result = run_test_feature_child(child, feature_key, child_instance, level, child_index)
|
|
703
|
+
ensure
|
|
704
|
+
child_instance.close
|
|
531
705
|
end
|
|
532
706
|
|
|
533
|
-
child_result = run_test_feature_child(child, feature_key, child_instance, level)
|
|
534
|
-
|
|
535
707
|
if child_result[:has_error]
|
|
536
708
|
has_error = true
|
|
537
|
-
errors
|
|
709
|
+
errors.concat(child_result[:errors])
|
|
538
710
|
end
|
|
539
711
|
end
|
|
540
712
|
end
|
|
@@ -549,12 +721,13 @@ module FeaturevisorCLI
|
|
|
549
721
|
}
|
|
550
722
|
end
|
|
551
723
|
|
|
552
|
-
def run_test_feature_child(assertion, feature_key, instance, level)
|
|
724
|
+
def run_test_feature_child(assertion, feature_key, instance, level, child_index = nil)
|
|
553
725
|
context = parse_context(assertion[:context])
|
|
554
726
|
override_options = create_override_options(assertion)
|
|
555
727
|
|
|
556
728
|
has_error = false
|
|
557
|
-
errors =
|
|
729
|
+
errors = []
|
|
730
|
+
child_details = child_index.nil? ? {} : { childIndex: child_index }
|
|
558
731
|
start_time = Time.now
|
|
559
732
|
|
|
560
733
|
# Test expectedToBeEnabled
|
|
@@ -564,7 +737,7 @@ module FeaturevisorCLI
|
|
|
564
737
|
|
|
565
738
|
if is_enabled != expected_to_be_enabled
|
|
566
739
|
has_error = true
|
|
567
|
-
errors
|
|
740
|
+
errors << expectation_error("flag", expected_to_be_enabled, is_enabled, child_details)
|
|
568
741
|
end
|
|
569
742
|
end
|
|
570
743
|
|
|
@@ -576,7 +749,7 @@ module FeaturevisorCLI
|
|
|
576
749
|
variation_value = variation.nil? ? nil : variation
|
|
577
750
|
if !compare_values(variation_value, expected_variation)
|
|
578
751
|
has_error = true
|
|
579
|
-
errors
|
|
752
|
+
errors << expectation_error("variation", expected_variation, variation_value, child_details)
|
|
580
753
|
end
|
|
581
754
|
end
|
|
582
755
|
|
|
@@ -607,8 +780,10 @@ module FeaturevisorCLI
|
|
|
607
780
|
|
|
608
781
|
if !passed
|
|
609
782
|
has_error = true
|
|
610
|
-
|
|
611
|
-
|
|
783
|
+
errors << expectation_error(
|
|
784
|
+
"variable", expected_value, actual_value,
|
|
785
|
+
child_details.merge(variableKey: variable_key)
|
|
786
|
+
)
|
|
612
787
|
end
|
|
613
788
|
next
|
|
614
789
|
rescue JSON::ParserError
|
|
@@ -619,7 +794,10 @@ module FeaturevisorCLI
|
|
|
619
794
|
# Regular comparison for non-JSON strings or when JSON parsing fails
|
|
620
795
|
if !compare_values(actual_value, expected_value)
|
|
621
796
|
has_error = true
|
|
622
|
-
errors
|
|
797
|
+
errors << expectation_error(
|
|
798
|
+
"variable", expected_value, actual_value,
|
|
799
|
+
child_details.merge(variableKey: variable_key)
|
|
800
|
+
)
|
|
623
801
|
end
|
|
624
802
|
end
|
|
625
803
|
end
|
|
@@ -634,7 +812,10 @@ module FeaturevisorCLI
|
|
|
634
812
|
actual_value = get_evaluation_value(evaluation, key)
|
|
635
813
|
if !compare_values(actual_value, expected_value)
|
|
636
814
|
has_error = true
|
|
637
|
-
errors
|
|
815
|
+
errors << expectation_error(
|
|
816
|
+
"evaluation", expected_value, actual_value,
|
|
817
|
+
child_details.merge(evaluationType: "flag", evaluationKey: key)
|
|
818
|
+
)
|
|
638
819
|
end
|
|
639
820
|
end
|
|
640
821
|
end
|
|
@@ -645,7 +826,10 @@ module FeaturevisorCLI
|
|
|
645
826
|
actual_value = get_evaluation_value(evaluation, key)
|
|
646
827
|
if !compare_values(actual_value, expected_value)
|
|
647
828
|
has_error = true
|
|
648
|
-
errors
|
|
829
|
+
errors << expectation_error(
|
|
830
|
+
"evaluation", expected_value, actual_value,
|
|
831
|
+
child_details.merge(evaluationType: "variation", evaluationKey: key)
|
|
832
|
+
)
|
|
649
833
|
end
|
|
650
834
|
end
|
|
651
835
|
end
|
|
@@ -665,7 +849,10 @@ module FeaturevisorCLI
|
|
|
665
849
|
actual_value = get_evaluation_value(evaluation, key)
|
|
666
850
|
if !compare_values(actual_value, expected_value)
|
|
667
851
|
has_error = true
|
|
668
|
-
errors
|
|
852
|
+
errors << expectation_error(
|
|
853
|
+
"evaluation", expected_value, actual_value,
|
|
854
|
+
child_details.merge(variableKey: variable_key, evaluationKey: key)
|
|
855
|
+
)
|
|
669
856
|
end
|
|
670
857
|
end
|
|
671
858
|
end
|
|
@@ -682,35 +869,27 @@ module FeaturevisorCLI
|
|
|
682
869
|
}
|
|
683
870
|
end
|
|
684
871
|
|
|
685
|
-
def run_test_segment(assertion, segment, level)
|
|
872
|
+
def run_test_segment(assertion, segment, level, selected = nil, segments = {}, segment_key = "subject", set = @active_set)
|
|
873
|
+
return { has_error: true, errors: "segment was not found", duration: 0 } unless segment.is_a?(Hash)
|
|
686
874
|
context = parse_context(assertion[:context])
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
datafile =
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
features: {},
|
|
694
|
-
segments: {}
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
# Create SDK instance for segment testing
|
|
698
|
-
instance = Featurevisor.create_featurevisor(
|
|
699
|
-
datafile: datafile,
|
|
700
|
-
log_level: level
|
|
701
|
-
)
|
|
875
|
+
segments = segments.merge(segment_key.to_sym => segment)
|
|
876
|
+
datafile = selected || { schemaVersion: "4", revision: "tester", features: {}, segments: {}, variables: {} }
|
|
877
|
+
SegmentTests.references!(segment_key, datafile, segments)
|
|
878
|
+
datafile = SegmentTests.prepare(datafile, segments)
|
|
879
|
+
instance, = create_tester_instance(datafile, level, assertion, set)
|
|
880
|
+
instance.instance_variable_set(:@__segment_test_keys, datafile[:_segment_test_keys])
|
|
702
881
|
|
|
703
882
|
has_error = false
|
|
704
|
-
errors =
|
|
883
|
+
errors = []
|
|
705
884
|
start_time = Time.now
|
|
706
885
|
|
|
707
886
|
if assertion.key?(:expectedToMatch)
|
|
708
887
|
expected_to_match = assertion[:expectedToMatch]
|
|
709
|
-
actual = instance
|
|
888
|
+
actual = segment_matcher(instance).call(segment_key, context)
|
|
710
889
|
|
|
711
890
|
if actual != expected_to_match
|
|
712
891
|
has_error = true
|
|
713
|
-
errors
|
|
892
|
+
errors << expectation_error("segment", expected_to_match, actual)
|
|
714
893
|
end
|
|
715
894
|
end
|
|
716
895
|
|
|
@@ -721,6 +900,29 @@ module FeaturevisorCLI
|
|
|
721
900
|
errors: errors,
|
|
722
901
|
duration: duration
|
|
723
902
|
}
|
|
903
|
+
rescue FeaturevisorCLI::TestChecks::CheckError => error
|
|
904
|
+
{ has_error: true, errors: [expectation_error("segment", "valid references", error.message)], duration: 0 }
|
|
905
|
+
ensure
|
|
906
|
+
instance&.close
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
def segment_matcher(instance, segments = {}, keys = nil, diagnostics = nil)
|
|
910
|
+
keys ||= instance.instance_variable_get(:@__segment_test_keys)
|
|
911
|
+
keys = {} if !keys && segments.empty?
|
|
912
|
+
unless keys
|
|
913
|
+
datafile = SegmentTests.prepare(instance.instance_variable_get(:@datafile).get_datafile, segments)
|
|
914
|
+
instance.set_datafile(datafile, true)
|
|
915
|
+
keys = datafile[:_segment_test_keys]
|
|
916
|
+
instance.instance_variable_set(:@__segment_test_keys, keys)
|
|
917
|
+
end
|
|
918
|
+
diagnostics ||= instance.instance_variable_get(:@__segment_test_diagnostics) || []
|
|
919
|
+
lambda do |key, context|
|
|
920
|
+
diagnostics.clear
|
|
921
|
+
evaluation = instance.evaluate_flag(keys.fetch(key.to_s), context)
|
|
922
|
+
failed = diagnostics.any? { |item| %w[error fatal].include?(item[:level].to_s) }
|
|
923
|
+
raise TestChecks::CheckError, "segment #{key.inspect} evaluation failed" if evaluation[:reason] == "error" || failed
|
|
924
|
+
evaluation[:enabled] == true
|
|
925
|
+
end
|
|
724
926
|
end
|
|
725
927
|
|
|
726
928
|
def parse_context(context_data)
|
|
@@ -828,12 +1030,10 @@ module FeaturevisorCLI
|
|
|
828
1030
|
evaluation[:force_index]
|
|
829
1031
|
when :force
|
|
830
1032
|
evaluation[:force]
|
|
831
|
-
when :
|
|
832
|
-
evaluation[:
|
|
833
|
-
when :
|
|
834
|
-
evaluation[:
|
|
835
|
-
when :sticky
|
|
836
|
-
evaluation[:sticky]
|
|
1033
|
+
when :conditions
|
|
1034
|
+
evaluation[:conditions]
|
|
1035
|
+
when :stickyFeature
|
|
1036
|
+
evaluation[:sticky_feature]
|
|
837
1037
|
when :variation
|
|
838
1038
|
evaluation[:variation]
|
|
839
1039
|
when :variationValue
|
|
@@ -850,6 +1050,8 @@ module FeaturevisorCLI
|
|
|
850
1050
|
evaluation[:variable_override_key]
|
|
851
1051
|
when :variableOverridePath
|
|
852
1052
|
evaluation[:variable_override_path]
|
|
1053
|
+
when :variableOverridePaths
|
|
1054
|
+
evaluation[:variable_override_paths]
|
|
853
1055
|
else
|
|
854
1056
|
nil
|
|
855
1057
|
end
|
|
@@ -952,16 +1154,326 @@ module FeaturevisorCLI
|
|
|
952
1154
|
end
|
|
953
1155
|
end
|
|
954
1156
|
|
|
1157
|
+
def empty_report
|
|
1158
|
+
{
|
|
1159
|
+
schemaVersion: 1,
|
|
1160
|
+
runner: "ruby",
|
|
1161
|
+
passed: true,
|
|
1162
|
+
summary: {
|
|
1163
|
+
specs: { passed: 0, failed: 0 },
|
|
1164
|
+
assertions: { passed: 0, failed: 0 },
|
|
1165
|
+
entitySpecs: { passed: 0, failed: 0 },
|
|
1166
|
+
entityAssertions: { passed: 0, failed: 0 },
|
|
1167
|
+
scenarioSpecs: { passed: 0, failed: 0 },
|
|
1168
|
+
scenarioCases: { passed: 0, failed: 0 }
|
|
1169
|
+
},
|
|
1170
|
+
results: [],
|
|
1171
|
+
errors: []
|
|
1172
|
+
}
|
|
1173
|
+
end
|
|
955
1174
|
|
|
1175
|
+
def merge_report!(destination, source)
|
|
1176
|
+
destination[:results].concat(source[:results])
|
|
1177
|
+
destination[:errors].concat(source[:errors])
|
|
1178
|
+
end
|
|
1179
|
+
|
|
1180
|
+
def finalize_report!(report)
|
|
1181
|
+
summary = empty_report[:summary]
|
|
1182
|
+
report[:results].each do |result|
|
|
1183
|
+
spec_bucket = result[:type] == "scenario" ? :scenarioSpecs : :entitySpecs
|
|
1184
|
+
case_bucket = result[:type] == "scenario" ? :scenarioCases : :entityAssertions
|
|
1185
|
+
spec_state = result[:passed] ? :passed : :failed
|
|
1186
|
+
summary[:specs][spec_state] += 1
|
|
1187
|
+
summary[spec_bucket][spec_state] += 1
|
|
1188
|
+
result[:cases].each do |item|
|
|
1189
|
+
case_state = item[:passed] ? :passed : :failed
|
|
1190
|
+
summary[:assertions][case_state] += 1
|
|
1191
|
+
summary[case_bucket][case_state] += 1
|
|
1192
|
+
end
|
|
1193
|
+
end
|
|
1194
|
+
report[:summary] = summary
|
|
1195
|
+
report[:passed] = report[:errors].empty? && summary[:specs][:failed].zero? && summary[:assertions][:failed].zero?
|
|
1196
|
+
report[:uncovered] = report[:uncovered].sort_by { |item| item[:set].to_s } if report.key?(:uncovered)
|
|
1197
|
+
report
|
|
1198
|
+
end
|
|
1199
|
+
|
|
1200
|
+
def error_objects(value, type: nil, assertion: nil)
|
|
1201
|
+
errors = if value.is_a?(Array)
|
|
1202
|
+
value.map { |error| error.is_a?(Hash) ? deep_copy(error) : { message: error.to_s } }
|
|
1203
|
+
else
|
|
1204
|
+
value.to_s.lines.filter_map do |line|
|
|
1205
|
+
message = line.sub(/^\s*✘\s*/, "").strip
|
|
1206
|
+
next if message.empty?
|
|
1207
|
+
{ message: message }
|
|
1208
|
+
end
|
|
1209
|
+
end
|
|
1210
|
+
return errors unless type && assertion
|
|
1211
|
+
|
|
1212
|
+
errors.map do |error|
|
|
1213
|
+
if error[:type] == "check"
|
|
1214
|
+
decorate_check_error(error, type, assertion)
|
|
1215
|
+
else
|
|
1216
|
+
decorate_expectation_error(error, type, assertion)
|
|
1217
|
+
end
|
|
1218
|
+
end
|
|
1219
|
+
end
|
|
1220
|
+
|
|
1221
|
+
def decorate_check_error(error, type, assertion)
|
|
1222
|
+
return error unless error[:type] == "check"
|
|
1223
|
+
|
|
1224
|
+
details = error[:details].is_a?(Hash) ? error[:details] : {}
|
|
1225
|
+
assertion_index = assertion.dig(:_case, :assertionIndex)
|
|
1226
|
+
base = type == :scenario ? [] : ["assertions", assertion_index || 0]
|
|
1227
|
+
base += ["children", details[:childIndex]] if details.key?(:childIndex)
|
|
1228
|
+
collection = "checks"
|
|
1229
|
+
error[:path] = base + [collection]
|
|
1230
|
+
error[:path] << details[:checkIndex] if details.key?(:checkIndex)
|
|
1231
|
+
if details[:evaluated].is_a?(Array)
|
|
1232
|
+
details[:evaluated] = details[:evaluated].map do |leaf|
|
|
1233
|
+
leaf.merge(path: base + [collection] + Array(leaf[:path]))
|
|
1234
|
+
end
|
|
1235
|
+
end
|
|
1236
|
+
if error[:expected] == true && error[:actual] == false
|
|
1237
|
+
error[:message] = "Check did not match"
|
|
1238
|
+
end
|
|
1239
|
+
error
|
|
1240
|
+
end
|
|
1241
|
+
|
|
1242
|
+
def decorate_expectation_error(error, type, assertion)
|
|
1243
|
+
details = error[:details].is_a?(Hash) ? error[:details] : {}
|
|
1244
|
+
assertion_index = assertion.dig(:_case, :assertionIndex) || 0
|
|
1245
|
+
path = ["assertions", assertion_index]
|
|
1246
|
+
path += ["children", details[:childIndex]] if details.key?(:childIndex)
|
|
1247
|
+
suffix = case error[:type]
|
|
1248
|
+
when "flag" then ["expectedToBeEnabled"]
|
|
1249
|
+
when "segment" then ["expectedToMatch"]
|
|
1250
|
+
when "variation" then ["expectedVariation"]
|
|
1251
|
+
when "variable"
|
|
1252
|
+
type == :variable ? ["expectedValue"] : ["expectedVariables", details[:variableKey]]
|
|
1253
|
+
when "evaluation"
|
|
1254
|
+
if type == :variable
|
|
1255
|
+
["expectedEvaluation", details[:evaluationKey]]
|
|
1256
|
+
elsif details.key?(:variableKey)
|
|
1257
|
+
["expectedEvaluations", "variables", details[:variableKey], details[:evaluationKey]]
|
|
1258
|
+
else
|
|
1259
|
+
["expectedEvaluations", details[:evaluationType], details[:evaluationKey]]
|
|
1260
|
+
end
|
|
1261
|
+
else
|
|
1262
|
+
[]
|
|
1263
|
+
end
|
|
1264
|
+
error[:path] = path + suffix unless suffix.empty?
|
|
1265
|
+
error[:message] ||= "expected #{format_test_value(error[:expected])}, received #{format_test_value(error[:actual])}"
|
|
1266
|
+
error
|
|
1267
|
+
end
|
|
1268
|
+
|
|
1269
|
+
def deep_copy(value)
|
|
1270
|
+
Marshal.load(Marshal.dump(value))
|
|
1271
|
+
end
|
|
1272
|
+
|
|
1273
|
+
def normalized_environment(environment)
|
|
1274
|
+
environment == false ? nil : environment
|
|
1275
|
+
end
|
|
1276
|
+
|
|
1277
|
+
def rerun_instruction(assertion, set)
|
|
1278
|
+
args = ["test"]
|
|
1279
|
+
args << "--case=#{assertion.dig(:_case, :id)}" if assertion.dig(:_case, :id)
|
|
1280
|
+
if @options.project_directory_path_explicit
|
|
1281
|
+
args << "--projectDirectoryPath=#{@options.project_directory_path}"
|
|
1282
|
+
end
|
|
1283
|
+
args << "--set=#{set}" if set
|
|
1284
|
+
@options.targets.each { |target| args << "--target=#{target}" }
|
|
1285
|
+
{ command: "bundle exec featurevisor", args: args }
|
|
1286
|
+
end
|
|
1287
|
+
|
|
1288
|
+
def progress(message)
|
|
1289
|
+
stream = @options.json ? $stderr : $stdout
|
|
1290
|
+
stream.puts(message)
|
|
1291
|
+
end
|
|
956
1292
|
|
|
1293
|
+
def print_report(report)
|
|
1294
|
+
if @options.json
|
|
1295
|
+
puts(@options.pretty ? JSON.pretty_generate(report) : JSON.generate(report))
|
|
1296
|
+
return
|
|
1297
|
+
end
|
|
1298
|
+
|
|
1299
|
+
if report[:results].empty? && report[:errors].empty?
|
|
1300
|
+
puts "No tests found"
|
|
1301
|
+
end
|
|
1302
|
+
report[:results].each do |result|
|
|
1303
|
+
next if @options.only_failures && result[:passed]
|
|
1304
|
+
puts "\nTesting: #{result[:source]}"
|
|
1305
|
+
result[:cases].each_with_index do |item, index|
|
|
1306
|
+
next if @options.only_failures && item[:passed]
|
|
1307
|
+
puts " #{item[:passed] ? '✔' : '✘'} #{human_case_label(item, index)}"
|
|
1308
|
+
item[:errors].each do |error|
|
|
1309
|
+
human_error_lines(error).each { |line| puts " ✘ #{line}" }
|
|
1310
|
+
end
|
|
1311
|
+
next if item[:passed] || item.dig(:rerun, :args).nil?
|
|
1312
|
+
command = "#{item.dig(:rerun, :command)} #{item.dig(:rerun, :args).map { |arg| Shellwords.escape(arg) }.join(' ')}"
|
|
1313
|
+
puts " Rerun (POSIX shell): #{command}"
|
|
1314
|
+
end
|
|
1315
|
+
end
|
|
1316
|
+
report[:errors].each { |error| puts "Error: #{error[:message]}" }
|
|
1317
|
+
summary = report[:summary]
|
|
1318
|
+
puts ""
|
|
1319
|
+
puts "Entity specs: #{summary[:entitySpecs][:passed]} passed, #{summary[:entitySpecs][:failed]} failed"
|
|
1320
|
+
puts "Entity assertions: #{summary[:entityAssertions][:passed]} passed, #{summary[:entityAssertions][:failed]} failed"
|
|
1321
|
+
puts "Scenario specs: #{summary[:scenarioSpecs][:passed]} passed, #{summary[:scenarioSpecs][:failed]} failed"
|
|
1322
|
+
puts "Scenario cases: #{summary[:scenarioCases][:passed]} passed, #{summary[:scenarioCases][:failed]} failed"
|
|
1323
|
+
puts "Total cases: #{summary[:assertions][:passed]} passed, #{summary[:assertions][:failed]} failed"
|
|
1324
|
+
if report[:uncovered]
|
|
1325
|
+
puts "\nDefinitions without associated specs (not evaluation coverage):"
|
|
1326
|
+
report[:uncovered].each do |item|
|
|
1327
|
+
puts " Set: #{item[:set]}" if item[:set]
|
|
1328
|
+
puts " Features: #{item[:features].empty? ? 'none' : item[:features].join(', ')}"
|
|
1329
|
+
puts " Global variables: #{item[:variables].empty? ? 'none' : item[:variables].join(', ')}"
|
|
1330
|
+
end
|
|
1331
|
+
end
|
|
1332
|
+
if report[:coverage]
|
|
1333
|
+
puts "\nObserved rule and override selections (not branch coverage):"
|
|
1334
|
+
selections = report.dig(:coverage, :selections)
|
|
1335
|
+
selections.each { |selection| puts " #{human_coverage_selection(selection)}" }
|
|
1336
|
+
puts " No selections observed" if selections.empty?
|
|
1337
|
+
end
|
|
1338
|
+
puts ""
|
|
1339
|
+
end
|
|
1340
|
+
|
|
1341
|
+
def human_case_label(item, index)
|
|
1342
|
+
label = item[:description].to_s.empty? ? "assertion #{index + 1}" : item[:description]
|
|
1343
|
+
details = ["case #{index + 1}"]
|
|
1344
|
+
matrix = item[:matrixValues]
|
|
1345
|
+
if matrix.is_a?(Hash) && !matrix.empty?
|
|
1346
|
+
details << "matrix #{matrix.map { |key, value| "#{key}=#{JSON.generate(value)}" }.join(', ')}"
|
|
1347
|
+
end
|
|
1348
|
+
details << "target #{item[:target]}" if item[:target]
|
|
1349
|
+
"#{label} (#{details.join('; ')})"
|
|
1350
|
+
end
|
|
1351
|
+
|
|
1352
|
+
def human_coverage_selection(selection)
|
|
1353
|
+
owner = if selection[:featureKey]
|
|
1354
|
+
"feature #{selection[:featureKey]}#{selection[:variableKey] ? " variable #{selection[:variableKey]}" : ''}"
|
|
1355
|
+
else
|
|
1356
|
+
"global variable #{selection[:variableKey]}"
|
|
1357
|
+
end
|
|
1358
|
+
scope = %i[set environment target].filter_map { |key| "#{key} #{selection[key]}" if selection[key] }
|
|
1359
|
+
picks = []
|
|
1360
|
+
picks << "rule #{selection[:ruleKey]}" if selection[:ruleKey]
|
|
1361
|
+
picks << "variation #{JSON.generate(selection[:variation])}" if selection.key?(:variation)
|
|
1362
|
+
picks << "override #{Array(selection[:overridePath]).join(' > ')}" if selection[:overridePath]
|
|
1363
|
+
picks << "override #{selection[:overrideIndex] + 1}" if selection.key?(:overrideIndex)
|
|
1364
|
+
"#{owner}#{scope.empty? ? '' : " (#{scope.join(', ')})"}: #{picks.join(', ')}"
|
|
1365
|
+
end
|
|
1366
|
+
|
|
1367
|
+
def human_error_lines(error)
|
|
1368
|
+
details = error[:details]
|
|
1369
|
+
return [error[:message]] unless error[:type] == "check" && details.is_a?(Hash)
|
|
1370
|
+
|
|
1371
|
+
named = details[:checkKey] ? " #{details[:checkKey].inspect}" : ""
|
|
1372
|
+
description = details[:checkDescription] ? " (#{details[:checkDescription]})" : ""
|
|
1373
|
+
lines = ["#{human_check_message(error[:message])}#{named}#{description} at #{human_path(error[:path])}"]
|
|
1374
|
+
Array(details[:evaluated]).each do |leaf|
|
|
1375
|
+
reference = human_reference(leaf[:reference])
|
|
1376
|
+
comparison = leaf[:compareTo] ? human_reference(leaf[:compareTo]) : "literal"
|
|
1377
|
+
reference = "#{reference}.#{leaf[:property]}" if leaf[:property]
|
|
1378
|
+
lines << "#{human_path(leaf[:path])}: #{reference} #{leaf[:operator]} #{comparison} " \
|
|
1379
|
+
"(left=#{JSON.generate(leaf[:actual])}, right=#{JSON.generate(leaf[:expected])})"
|
|
1380
|
+
end
|
|
1381
|
+
lines
|
|
1382
|
+
end
|
|
1383
|
+
|
|
1384
|
+
def human_check_message(message)
|
|
1385
|
+
message.to_s.gsub(/\b(checks|children)\[(\d+)\]/) do
|
|
1386
|
+
"#{Regexp.last_match(1) == 'children' ? 'Child' : 'check'} #{Regexp.last_match(2).to_i + 1}"
|
|
1387
|
+
end
|
|
1388
|
+
end
|
|
1389
|
+
|
|
1390
|
+
def human_path(path)
|
|
1391
|
+
Array(path).each_with_index.map do |part, index|
|
|
1392
|
+
part.is_a?(Integer) ? "[#{part + 1}]" : index.zero? ? part.to_s : ".#{part}"
|
|
1393
|
+
end.join
|
|
1394
|
+
end
|
|
1395
|
+
|
|
1396
|
+
def human_reference(reference)
|
|
1397
|
+
return "<unknown>" unless reference.is_a?(Hash)
|
|
1398
|
+
variable = reference[:variable]
|
|
1399
|
+
root = reference[:feature] ? [reference[:feature], variable].compact.join(".") : variable
|
|
1400
|
+
reference.key?(:path) ? "#{root}:#{JSON.generate(reference[:path])}" : root.to_s
|
|
1401
|
+
end
|
|
1402
|
+
|
|
1403
|
+
def get_uncovered(set)
|
|
1404
|
+
{
|
|
1405
|
+
set: set,
|
|
1406
|
+
features: get_uncovered_entities("features", set),
|
|
1407
|
+
variables: get_uncovered_entities("variables", set)
|
|
1408
|
+
}
|
|
1409
|
+
end
|
|
1410
|
+
|
|
1411
|
+
def get_uncovered_entities(selector, set)
|
|
1412
|
+
command = node_command("list", "--#{selector}", "--without-tests", "--json", *set_arguments(set))
|
|
1413
|
+
output = JSON.parse(execute_command(command), symbolize_names: true)
|
|
1414
|
+
Array(output).filter_map { |item| item.is_a?(Hash) ? item[:key] : item }.map(&:to_s).sort
|
|
1415
|
+
rescue JSON::ParserError => e
|
|
1416
|
+
raise InfrastructureError, "Failed to parse uncovered #{selector} JSON: #{e.message}"
|
|
1417
|
+
end
|
|
1418
|
+
|
|
1419
|
+
def record_coverage_selection(evaluation, assertion, set)
|
|
1420
|
+
rule_key = evaluation[:rule_key]
|
|
1421
|
+
paths = Array(evaluation[:variable_override_paths])
|
|
1422
|
+
paths = [evaluation[:variable_override_path]] if paths.empty? && evaluation[:variable_override_path]
|
|
1423
|
+
paths = [evaluation[:variable_override_key]] if paths.empty? && evaluation[:variable_override_key]
|
|
1424
|
+
paths = [nil] if paths.empty? && evaluation.key?(:variable_override_index)
|
|
1425
|
+
paths = [nil] if paths.empty? && rule_key
|
|
1426
|
+
paths.each do |path|
|
|
1427
|
+
next unless rule_key || path || evaluation.key?(:variable_override_index)
|
|
1428
|
+
item = {
|
|
1429
|
+
set: set,
|
|
1430
|
+
environment: normalized_environment(assertion[:environment]),
|
|
1431
|
+
target: assertion[:target]
|
|
1432
|
+
}
|
|
1433
|
+
item[:featureKey] = evaluation[:feature_key] if evaluation[:feature_key]
|
|
1434
|
+
item[:variableKey] = evaluation[:variable_key] if evaluation[:variable_key]
|
|
1435
|
+
item[:ruleKey] = rule_key if rule_key
|
|
1436
|
+
item[:variation] = evaluation.dig(:variation, :value) if evaluation[:variation].is_a?(Hash) && evaluation[:variation].key?(:value)
|
|
1437
|
+
item[:overridePath] = path if path
|
|
1438
|
+
item[:overrideIndex] = evaluation[:variable_override_index] if !path && evaluation.key?(:variable_override_index)
|
|
1439
|
+
@coverage_selections << item
|
|
1440
|
+
end
|
|
1441
|
+
end
|
|
1442
|
+
|
|
1443
|
+
def coverage_report
|
|
1444
|
+
unique = @coverage_selections.each_with_object({}) do |selection, items|
|
|
1445
|
+
items[JSON.generate(selection)] = selection
|
|
1446
|
+
end.values
|
|
1447
|
+
{
|
|
1448
|
+
mode: "observed-selections",
|
|
1449
|
+
selections: unique.sort_by { |item| JSON.generate(item) }
|
|
1450
|
+
}
|
|
1451
|
+
end
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
# Node CLI calls pass an argument list run in the project directory.
|
|
957
1456
|
def execute_command(command)
|
|
958
|
-
stdout, stderr, status =
|
|
1457
|
+
stdout, stderr, status =
|
|
1458
|
+
command.is_a?(Array) ? Open3.capture3(*command, chdir: @project_path) : Open3.capture3(command)
|
|
1459
|
+
stdout.force_encoding(Encoding::UTF_8)
|
|
1460
|
+
stderr.force_encoding(Encoding::UTF_8)
|
|
959
1461
|
|
|
960
1462
|
unless status.success?
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
1463
|
+
begin
|
|
1464
|
+
payload = JSON.parse(stderr)
|
|
1465
|
+
error = payload["error"] || {}
|
|
1466
|
+
if error["message"] || error["code"]
|
|
1467
|
+
raise InfrastructureError.new(
|
|
1468
|
+
error["message"] || "Command failed with exit code #{status.exitstatus}",
|
|
1469
|
+
code: error["code"]
|
|
1470
|
+
)
|
|
1471
|
+
end
|
|
1472
|
+
rescue JSON::ParserError
|
|
1473
|
+
nil
|
|
1474
|
+
end
|
|
1475
|
+
details = stderr.empty? ? "" : ": #{stderr.strip}"
|
|
1476
|
+
raise InfrastructureError, "Command failed with exit code #{status.exitstatus}#{details}"
|
|
965
1477
|
end
|
|
966
1478
|
|
|
967
1479
|
stdout
|