featurevisor 3.1.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 +300 -182
- 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 +735 -260
- 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,29 +213,46 @@ 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?
|
|
@@ -228,15 +262,18 @@ module FeaturevisorCLI
|
|
|
228
262
|
datafiles_by_key[base_datafile_key(environment)]
|
|
229
263
|
end
|
|
230
264
|
|
|
231
|
-
def create_tester_instance(datafile, level, assertion)
|
|
232
|
-
sticky = parse_sticky(assertion[:stickyFeatures]
|
|
265
|
+
def create_tester_instance(datafile, level, assertion, set = @active_set)
|
|
266
|
+
sticky = parse_sticky(assertion[:stickyFeatures])
|
|
233
267
|
sticky_variables = assertion[:stickyVariables].is_a?(Hash) ? assertion[:stickyVariables] : {}
|
|
234
268
|
|
|
235
|
-
|
|
269
|
+
diagnostics = []
|
|
270
|
+
check_level = assertion.key?(:checks) || !Array(assertion[:children]).empty? ? "error" : level
|
|
271
|
+
instance = Featurevisor.create_featurevisor(
|
|
236
272
|
datafile: datafile,
|
|
237
273
|
sticky_features: sticky,
|
|
238
274
|
sticky_variables: sticky_variables,
|
|
239
|
-
log_level:
|
|
275
|
+
log_level: check_level,
|
|
276
|
+
on_diagnostic: ->(diagnostic) { diagnostics << diagnostic },
|
|
240
277
|
modules: [
|
|
241
278
|
{
|
|
242
279
|
name: "test-module",
|
|
@@ -247,132 +284,156 @@ module FeaturevisorCLI
|
|
|
247
284
|
else
|
|
248
285
|
options[:bucket_value]
|
|
249
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
|
|
250
291
|
end
|
|
251
292
|
}
|
|
252
293
|
]
|
|
253
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]
|
|
254
299
|
end
|
|
255
300
|
|
|
256
|
-
def run_tests(tests, datafiles_by_key, segments_by_key, level,
|
|
257
|
-
|
|
258
|
-
failed_tests_count = 0
|
|
259
|
-
passed_assertions_count = 0
|
|
260
|
-
failed_assertions_count = 0
|
|
301
|
+
def run_tests(tests, datafiles_by_key, segments_by_key, level, _config, set)
|
|
302
|
+
report = empty_report
|
|
261
303
|
|
|
262
304
|
tests.each do |test|
|
|
263
|
-
|
|
305
|
+
discriminators = %i[feature variable segment scenario].select { |key| test.key?(key) }
|
|
306
|
+
type = discriminators.first
|
|
307
|
+
invalid_discriminator = discriminators.length != 1
|
|
264
308
|
assertions = test[:assertions] || []
|
|
265
|
-
if
|
|
266
|
-
assertions =
|
|
267
|
-
|
|
268
|
-
|
|
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]) }
|
|
269
316
|
next if assertions.empty?
|
|
270
317
|
end
|
|
271
|
-
results = ""
|
|
272
|
-
test_has_error = false
|
|
273
|
-
test_duration = 0.0
|
|
274
|
-
|
|
275
|
-
assertions.each do |assertion|
|
|
276
|
-
if assertion.is_a?(Hash)
|
|
277
|
-
test_result = nil
|
|
278
|
-
|
|
279
|
-
if test[:feature]
|
|
280
|
-
datafile = resolve_datafile_for_assertion(assertion, datafiles_by_key)
|
|
281
|
-
if datafile.nil?
|
|
282
|
-
test_result = {
|
|
283
|
-
has_error: true,
|
|
284
|
-
errors: " ✘ no datafile found for assertion target/environment combination\n",
|
|
285
|
-
duration: 0
|
|
286
|
-
}
|
|
287
|
-
end
|
|
288
318
|
|
|
289
|
-
|
|
290
|
-
|
|
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
|
+
}
|
|
291
327
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
298
347
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
ensure
|
|
302
|
-
instance.close
|
|
303
|
-
end
|
|
304
|
-
end
|
|
305
|
-
elsif test[:variable]
|
|
306
|
-
datafile = resolve_datafile_for_assertion(assertion, datafiles_by_key)
|
|
307
|
-
if datafile
|
|
308
|
-
if @options.show_datafile
|
|
309
|
-
puts ""
|
|
310
|
-
puts JSON.pretty_generate(datafile)
|
|
311
|
-
puts ""
|
|
312
|
-
end
|
|
313
|
-
instance = create_tester_instance(datafile, level, assertion)
|
|
314
|
-
begin
|
|
315
|
-
test_result = run_test_variable(assertion, test[:variable], instance)
|
|
316
|
-
ensure
|
|
317
|
-
instance.close
|
|
318
|
-
end
|
|
319
|
-
else
|
|
320
|
-
test_result = { has_error: true, errors: " ✘ no datafile found for assertion target/environment combination\n", duration: 0 }
|
|
321
|
-
end
|
|
322
|
-
elsif test[:segment]
|
|
323
|
-
segment_key = test[:segment]
|
|
324
|
-
segment = segments_by_key[segment_key]
|
|
325
|
-
if segment.is_a?(Hash)
|
|
326
|
-
test_result = run_test_segment(assertion, segment, level)
|
|
327
|
-
end
|
|
328
|
-
end
|
|
348
|
+
report[:results] << result
|
|
349
|
+
end
|
|
329
350
|
|
|
330
|
-
|
|
331
|
-
|
|
351
|
+
finalize_report!(report)
|
|
352
|
+
report
|
|
353
|
+
end
|
|
332
354
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
failed_assertions_count += 1
|
|
338
|
-
else
|
|
339
|
-
results += " ✔ #{assertion[:description]} (#{(test_result[:duration] * 1000).round(2)}ms)\n"
|
|
340
|
-
passed_assertions_count += 1
|
|
341
|
-
end
|
|
342
|
-
end
|
|
343
|
-
end
|
|
344
|
-
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
|
|
345
359
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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 }
|
|
349
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
|
|
350
369
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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 }
|
|
356
379
|
end
|
|
357
380
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
|
362
392
|
|
|
363
|
-
|
|
364
|
-
|
|
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
|
|
365
417
|
end
|
|
366
418
|
end
|
|
367
419
|
|
|
368
|
-
def run_test_variable(assertion, variable_key, instance)
|
|
369
|
-
|
|
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)
|
|
370
424
|
options = {}
|
|
371
425
|
options[:default_variable_value] = assertion[:defaultVariableValue] if assertion.key?(:defaultVariableValue)
|
|
372
426
|
started = Time.now
|
|
373
|
-
errors =
|
|
427
|
+
errors = []
|
|
374
428
|
|
|
375
|
-
errors
|
|
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
|
|
376
437
|
|
|
377
438
|
Array(assertion[:children]).each_with_index do |child_assertion, child_index|
|
|
378
439
|
child = instance.spawn(
|
|
@@ -385,38 +446,103 @@ module FeaturevisorCLI
|
|
|
385
446
|
if child_assertion.key?(:defaultVariableValue)
|
|
386
447
|
child_options[:default_variable_value] = child_assertion[:defaultVariableValue]
|
|
387
448
|
end
|
|
388
|
-
errors
|
|
389
|
-
child_assertion,
|
|
390
|
-
|
|
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
|
|
468
|
+
end
|
|
469
|
+
end
|
|
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(
|
|
391
499
|
child,
|
|
392
|
-
|
|
393
|
-
|
|
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)
|
|
394
508
|
)
|
|
509
|
+
errors.concat(check_result[:errors])
|
|
395
510
|
ensure
|
|
396
511
|
child.close
|
|
397
512
|
end
|
|
398
513
|
end
|
|
399
|
-
|
|
400
514
|
{ has_error: !errors.empty?, errors: errors, duration: Time.now - started }
|
|
401
515
|
end
|
|
402
516
|
|
|
403
|
-
def test_variable_expectation(assertion, variable_key, evaluator, options,
|
|
517
|
+
def test_variable_expectation(assertion, variable_key, evaluator, options, child_index = nil)
|
|
404
518
|
evaluation = evaluator.evaluate_variable(variable_key, {}, options)
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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])
|
|
408
525
|
end
|
|
409
526
|
if assertion[:expectedEvaluation].is_a?(Hash)
|
|
410
527
|
assertion[:expectedEvaluation].each do |key, expected|
|
|
411
528
|
actual = get_evaluation_value(evaluation, key)
|
|
412
529
|
unless compare_values(actual, expected)
|
|
413
|
-
errors
|
|
530
|
+
errors << expectation_error(
|
|
531
|
+
"evaluation",
|
|
532
|
+
expected,
|
|
533
|
+
actual,
|
|
534
|
+
details.merge(evaluationType: "variable", evaluationKey: key)
|
|
535
|
+
)
|
|
414
536
|
end
|
|
415
537
|
end
|
|
416
538
|
end
|
|
417
539
|
errors
|
|
418
540
|
end
|
|
419
541
|
|
|
542
|
+
def expectation_error(type, expected, actual, details = {})
|
|
543
|
+
{ type: type, expected: expected, actual: actual, details: details }
|
|
544
|
+
end
|
|
545
|
+
|
|
420
546
|
def format_test_value(value)
|
|
421
547
|
JSON.generate(value)
|
|
422
548
|
rescue JSON::GeneratorError
|
|
@@ -425,7 +551,7 @@ module FeaturevisorCLI
|
|
|
425
551
|
|
|
426
552
|
def run_test_feature(assertion, feature_key, instance, level)
|
|
427
553
|
context = parse_context(assertion[:context])
|
|
428
|
-
sticky = parse_sticky(assertion[:
|
|
554
|
+
sticky = parse_sticky(assertion[:stickyFeatures])
|
|
429
555
|
|
|
430
556
|
# Set context and sticky for this assertion
|
|
431
557
|
instance.set_context(context, false)
|
|
@@ -437,7 +563,7 @@ module FeaturevisorCLI
|
|
|
437
563
|
override_options = create_override_options(assertion)
|
|
438
564
|
|
|
439
565
|
has_error = false
|
|
440
|
-
errors =
|
|
566
|
+
errors = []
|
|
441
567
|
start_time = Time.now
|
|
442
568
|
|
|
443
569
|
# Test expectedToBeEnabled
|
|
@@ -447,7 +573,7 @@ module FeaturevisorCLI
|
|
|
447
573
|
|
|
448
574
|
if is_enabled != expected_to_be_enabled
|
|
449
575
|
has_error = true
|
|
450
|
-
errors
|
|
576
|
+
errors << expectation_error("flag", expected_to_be_enabled, is_enabled)
|
|
451
577
|
end
|
|
452
578
|
end
|
|
453
579
|
|
|
@@ -459,7 +585,7 @@ module FeaturevisorCLI
|
|
|
459
585
|
variation_value = variation.nil? ? nil : variation
|
|
460
586
|
if !compare_values(variation_value, expected_variation)
|
|
461
587
|
has_error = true
|
|
462
|
-
errors
|
|
588
|
+
errors << expectation_error("variation", expected_variation, variation_value)
|
|
463
589
|
end
|
|
464
590
|
end
|
|
465
591
|
|
|
@@ -490,8 +616,7 @@ module FeaturevisorCLI
|
|
|
490
616
|
|
|
491
617
|
if !passed
|
|
492
618
|
has_error = true
|
|
493
|
-
|
|
494
|
-
errors += " ✘ expectedVariables.#{variable_key}: expected #{expected_value} but received #{actual_json}\n"
|
|
619
|
+
errors << expectation_error("variable", expected_value, actual_value, variableKey: variable_key)
|
|
495
620
|
end
|
|
496
621
|
next
|
|
497
622
|
rescue JSON::ParserError
|
|
@@ -502,7 +627,7 @@ module FeaturevisorCLI
|
|
|
502
627
|
# Regular comparison for non-JSON strings or when JSON parsing fails
|
|
503
628
|
if !compare_values(actual_value, expected_value)
|
|
504
629
|
has_error = true
|
|
505
|
-
errors
|
|
630
|
+
errors << expectation_error("variable", expected_value, actual_value, variableKey: variable_key)
|
|
506
631
|
end
|
|
507
632
|
end
|
|
508
633
|
end
|
|
@@ -518,7 +643,10 @@ module FeaturevisorCLI
|
|
|
518
643
|
actual_value = get_evaluation_value(evaluation, key)
|
|
519
644
|
if !compare_values(actual_value, expected_value)
|
|
520
645
|
has_error = true
|
|
521
|
-
errors
|
|
646
|
+
errors << expectation_error(
|
|
647
|
+
"evaluation", expected_value, actual_value,
|
|
648
|
+
evaluationType: "flag", evaluationKey: key
|
|
649
|
+
)
|
|
522
650
|
end
|
|
523
651
|
end
|
|
524
652
|
end
|
|
@@ -530,7 +658,10 @@ module FeaturevisorCLI
|
|
|
530
658
|
actual_value = get_evaluation_value(evaluation, key)
|
|
531
659
|
if !compare_values(actual_value, expected_value)
|
|
532
660
|
has_error = true
|
|
533
|
-
errors
|
|
661
|
+
errors << expectation_error(
|
|
662
|
+
"evaluation", expected_value, actual_value,
|
|
663
|
+
evaluationType: "variation", evaluationKey: key
|
|
664
|
+
)
|
|
534
665
|
end
|
|
535
666
|
end
|
|
536
667
|
end
|
|
@@ -544,7 +675,10 @@ module FeaturevisorCLI
|
|
|
544
675
|
actual_value = get_evaluation_value(evaluation, key)
|
|
545
676
|
if !compare_values(actual_value, expected_value)
|
|
546
677
|
has_error = true
|
|
547
|
-
errors
|
|
678
|
+
errors << expectation_error(
|
|
679
|
+
"evaluation", expected_value, actual_value,
|
|
680
|
+
variableKey: variable_key, evaluationKey: key
|
|
681
|
+
)
|
|
548
682
|
end
|
|
549
683
|
end
|
|
550
684
|
end
|
|
@@ -554,24 +688,25 @@ module FeaturevisorCLI
|
|
|
554
688
|
|
|
555
689
|
# Test children
|
|
556
690
|
if assertion[:children]
|
|
557
|
-
assertion[:children].
|
|
691
|
+
assertion[:children].each_with_index do |child, child_index|
|
|
558
692
|
if child.is_a?(Hash)
|
|
559
693
|
child_context = parse_context(child[:context])
|
|
560
694
|
|
|
561
695
|
# Create override options for child with sticky values
|
|
562
696
|
child_instance = instance.spawn(
|
|
563
697
|
child_context,
|
|
564
|
-
sticky_features:
|
|
698
|
+
sticky_features: parse_sticky(child[:stickyFeatures]) || {},
|
|
699
|
+
sticky_variables: child[:stickyVariables].is_a?(Hash) ? child[:stickyVariables] : {}
|
|
565
700
|
)
|
|
566
701
|
begin
|
|
567
|
-
child_result = run_test_feature_child(child, feature_key, child_instance, level)
|
|
702
|
+
child_result = run_test_feature_child(child, feature_key, child_instance, level, child_index)
|
|
568
703
|
ensure
|
|
569
704
|
child_instance.close
|
|
570
705
|
end
|
|
571
706
|
|
|
572
707
|
if child_result[:has_error]
|
|
573
708
|
has_error = true
|
|
574
|
-
errors
|
|
709
|
+
errors.concat(child_result[:errors])
|
|
575
710
|
end
|
|
576
711
|
end
|
|
577
712
|
end
|
|
@@ -586,12 +721,13 @@ module FeaturevisorCLI
|
|
|
586
721
|
}
|
|
587
722
|
end
|
|
588
723
|
|
|
589
|
-
def run_test_feature_child(assertion, feature_key, instance, level)
|
|
724
|
+
def run_test_feature_child(assertion, feature_key, instance, level, child_index = nil)
|
|
590
725
|
context = parse_context(assertion[:context])
|
|
591
726
|
override_options = create_override_options(assertion)
|
|
592
727
|
|
|
593
728
|
has_error = false
|
|
594
|
-
errors =
|
|
729
|
+
errors = []
|
|
730
|
+
child_details = child_index.nil? ? {} : { childIndex: child_index }
|
|
595
731
|
start_time = Time.now
|
|
596
732
|
|
|
597
733
|
# Test expectedToBeEnabled
|
|
@@ -601,7 +737,7 @@ module FeaturevisorCLI
|
|
|
601
737
|
|
|
602
738
|
if is_enabled != expected_to_be_enabled
|
|
603
739
|
has_error = true
|
|
604
|
-
errors
|
|
740
|
+
errors << expectation_error("flag", expected_to_be_enabled, is_enabled, child_details)
|
|
605
741
|
end
|
|
606
742
|
end
|
|
607
743
|
|
|
@@ -613,7 +749,7 @@ module FeaturevisorCLI
|
|
|
613
749
|
variation_value = variation.nil? ? nil : variation
|
|
614
750
|
if !compare_values(variation_value, expected_variation)
|
|
615
751
|
has_error = true
|
|
616
|
-
errors
|
|
752
|
+
errors << expectation_error("variation", expected_variation, variation_value, child_details)
|
|
617
753
|
end
|
|
618
754
|
end
|
|
619
755
|
|
|
@@ -644,8 +780,10 @@ module FeaturevisorCLI
|
|
|
644
780
|
|
|
645
781
|
if !passed
|
|
646
782
|
has_error = true
|
|
647
|
-
|
|
648
|
-
|
|
783
|
+
errors << expectation_error(
|
|
784
|
+
"variable", expected_value, actual_value,
|
|
785
|
+
child_details.merge(variableKey: variable_key)
|
|
786
|
+
)
|
|
649
787
|
end
|
|
650
788
|
next
|
|
651
789
|
rescue JSON::ParserError
|
|
@@ -656,7 +794,10 @@ module FeaturevisorCLI
|
|
|
656
794
|
# Regular comparison for non-JSON strings or when JSON parsing fails
|
|
657
795
|
if !compare_values(actual_value, expected_value)
|
|
658
796
|
has_error = true
|
|
659
|
-
errors
|
|
797
|
+
errors << expectation_error(
|
|
798
|
+
"variable", expected_value, actual_value,
|
|
799
|
+
child_details.merge(variableKey: variable_key)
|
|
800
|
+
)
|
|
660
801
|
end
|
|
661
802
|
end
|
|
662
803
|
end
|
|
@@ -671,7 +812,10 @@ module FeaturevisorCLI
|
|
|
671
812
|
actual_value = get_evaluation_value(evaluation, key)
|
|
672
813
|
if !compare_values(actual_value, expected_value)
|
|
673
814
|
has_error = true
|
|
674
|
-
errors
|
|
815
|
+
errors << expectation_error(
|
|
816
|
+
"evaluation", expected_value, actual_value,
|
|
817
|
+
child_details.merge(evaluationType: "flag", evaluationKey: key)
|
|
818
|
+
)
|
|
675
819
|
end
|
|
676
820
|
end
|
|
677
821
|
end
|
|
@@ -682,7 +826,10 @@ module FeaturevisorCLI
|
|
|
682
826
|
actual_value = get_evaluation_value(evaluation, key)
|
|
683
827
|
if !compare_values(actual_value, expected_value)
|
|
684
828
|
has_error = true
|
|
685
|
-
errors
|
|
829
|
+
errors << expectation_error(
|
|
830
|
+
"evaluation", expected_value, actual_value,
|
|
831
|
+
child_details.merge(evaluationType: "variation", evaluationKey: key)
|
|
832
|
+
)
|
|
686
833
|
end
|
|
687
834
|
end
|
|
688
835
|
end
|
|
@@ -702,7 +849,10 @@ module FeaturevisorCLI
|
|
|
702
849
|
actual_value = get_evaluation_value(evaluation, key)
|
|
703
850
|
if !compare_values(actual_value, expected_value)
|
|
704
851
|
has_error = true
|
|
705
|
-
errors
|
|
852
|
+
errors << expectation_error(
|
|
853
|
+
"evaluation", expected_value, actual_value,
|
|
854
|
+
child_details.merge(variableKey: variable_key, evaluationKey: key)
|
|
855
|
+
)
|
|
706
856
|
end
|
|
707
857
|
end
|
|
708
858
|
end
|
|
@@ -719,35 +869,27 @@ module FeaturevisorCLI
|
|
|
719
869
|
}
|
|
720
870
|
end
|
|
721
871
|
|
|
722
|
-
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)
|
|
723
874
|
context = parse_context(assertion[:context])
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
datafile =
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
features: {},
|
|
731
|
-
segments: {}
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
# Create SDK instance for segment testing
|
|
735
|
-
instance = Featurevisor.create_featurevisor(
|
|
736
|
-
datafile: datafile,
|
|
737
|
-
log_level: level
|
|
738
|
-
)
|
|
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])
|
|
739
881
|
|
|
740
882
|
has_error = false
|
|
741
|
-
errors =
|
|
883
|
+
errors = []
|
|
742
884
|
start_time = Time.now
|
|
743
885
|
|
|
744
886
|
if assertion.key?(:expectedToMatch)
|
|
745
887
|
expected_to_match = assertion[:expectedToMatch]
|
|
746
|
-
actual = instance
|
|
888
|
+
actual = segment_matcher(instance).call(segment_key, context)
|
|
747
889
|
|
|
748
890
|
if actual != expected_to_match
|
|
749
891
|
has_error = true
|
|
750
|
-
errors
|
|
892
|
+
errors << expectation_error("segment", expected_to_match, actual)
|
|
751
893
|
end
|
|
752
894
|
end
|
|
753
895
|
|
|
@@ -758,6 +900,29 @@ module FeaturevisorCLI
|
|
|
758
900
|
errors: errors,
|
|
759
901
|
duration: duration
|
|
760
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
|
|
761
926
|
end
|
|
762
927
|
|
|
763
928
|
def parse_context(context_data)
|
|
@@ -865,12 +1030,10 @@ module FeaturevisorCLI
|
|
|
865
1030
|
evaluation[:force_index]
|
|
866
1031
|
when :force
|
|
867
1032
|
evaluation[:force]
|
|
868
|
-
when :
|
|
869
|
-
evaluation[:
|
|
870
|
-
when :
|
|
871
|
-
evaluation[:
|
|
872
|
-
when :sticky
|
|
873
|
-
evaluation[:sticky]
|
|
1033
|
+
when :conditions
|
|
1034
|
+
evaluation[:conditions]
|
|
1035
|
+
when :stickyFeature
|
|
1036
|
+
evaluation[:sticky_feature]
|
|
874
1037
|
when :variation
|
|
875
1038
|
evaluation[:variation]
|
|
876
1039
|
when :variationValue
|
|
@@ -887,6 +1050,8 @@ module FeaturevisorCLI
|
|
|
887
1050
|
evaluation[:variable_override_key]
|
|
888
1051
|
when :variableOverridePath
|
|
889
1052
|
evaluation[:variable_override_path]
|
|
1053
|
+
when :variableOverridePaths
|
|
1054
|
+
evaluation[:variable_override_paths]
|
|
890
1055
|
else
|
|
891
1056
|
nil
|
|
892
1057
|
end
|
|
@@ -989,16 +1154,326 @@ module FeaturevisorCLI
|
|
|
989
1154
|
end
|
|
990
1155
|
end
|
|
991
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
|
|
992
1174
|
|
|
1175
|
+
def merge_report!(destination, source)
|
|
1176
|
+
destination[:results].concat(source[:results])
|
|
1177
|
+
destination[:errors].concat(source[:errors])
|
|
1178
|
+
end
|
|
993
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
|
|
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.
|
|
994
1456
|
def execute_command(command)
|
|
995
|
-
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)
|
|
996
1461
|
|
|
997
1462
|
unless status.success?
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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}"
|
|
1002
1477
|
end
|
|
1003
1478
|
|
|
1004
1479
|
stdout
|