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.
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
- puts "Running tests..."
20
-
21
- # Get project configuration
22
- config = get_config
23
- environments = get_environments(config)
24
- available_targets = get_targets
25
- unknown_target = @options.targets.find { |target| !available_targets.include?(target) }
26
- if unknown_target
27
- puts "Unknown target \"#{unknown_target}\". Available targets: #{available_targets.empty? ? "none" : available_targets.join(", ")}."
28
- exit 1
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
- targets = @options.targets.empty? ? available_targets : @options.targets
31
- segments_by_key = get_segments
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
- # Run tests
48
- run_tests(tests, datafiles_by_key, segments_by_key, level, config)
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
- puts "Getting config..."
55
- command = "(cd #{Shellwords.escape(@project_path)} && npx featurevisor config --json)"
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
- puts "Error: Failed to parse config JSON: #{e.message}"
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
- puts "Getting segments..."
69
- command = "(cd #{Shellwords.escape(@project_path)} && npx featurevisor list --segments --json)"
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
- puts "Error: Failed to parse segments JSON: #{e.message}"
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
- puts "Getting targets..."
90
- command = "(cd #{Shellwords.escape(@project_path)} && npx featurevisor list --targets --json)"
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
- puts "Error: Failed to parse targets JSON: #{e.message}"
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 ? false : environment
158
+ [environment == false ? nil : environment, nil].freeze
127
159
  end
128
160
 
129
161
  def target_datafile_key(environment, target)
130
- if environment == false
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
- puts "Building datafile for environment: #{environment_label}..."
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
- puts "Building datafile for target: #{target}..."
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
- command_parts = ["npx", "featurevisor", "build", "--json"]
164
-
165
- if environment != false && !environment.nil?
166
- command_parts << "--environment=#{Shellwords.escape(environment.to_s)}"
167
- end
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
- puts "Error: Failed to parse datafile JSON: #{e.message}"
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
- command_parts = ["npx", "featurevisor", "list", "--tests", "--applyMatrix", "--json"]
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
- command_parts << "--assertionPattern=#{Shellwords.escape(@options.assertion_pattern)}"
220
+ arguments << "--assertionPattern=#{@options.assertion_pattern}"
208
221
  end
209
-
210
- command = "(cd #{Shellwords.escape(@project_path)} && #{command_parts.join(' ')})"
211
- tests_output = execute_command(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
- puts "Error: Failed to parse tests JSON: #{e.message}"
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
- target_key = assertion[:target] ? target_datafile_key(environment, assertion[:target]) : nil
227
- base_key = base_datafile_key(environment)
260
+ return datafiles_by_key[target_datafile_key(environment, assertion[:target])] if assertion[:target]
228
261
 
229
- if target_key && datafiles_by_key.key?(target_key)
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[:sticky])
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
- Featurevisor.create_featurevisor(
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: 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, config)
262
- passed_tests_count = 0
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
- test_key = test[:key]
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 (test[:feature] || test[:variable]) && !@options.targets.empty?
271
- assertions = assertions.select do |assertion|
272
- assertion[:target].nil? || @options.targets.include?(assertion[:target])
273
- end
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
- if datafile
295
- instance = create_tester_instance(datafile, level, assertion)
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
- # Show datafile if requested
298
- if @options.show_datafile
299
- puts ""
300
- puts JSON.pretty_generate(datafile)
301
- puts ""
302
- end
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
- test_result = run_test_feature(assertion, test[:feature], instance, level)
305
- end
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
- if test_result
323
- test_duration += test_result[:duration]
351
+ finalize_report!(report)
352
+ report
353
+ end
324
354
 
325
- if test_result[:has_error]
326
- results += " ✘ #{assertion[:description]} (#{(test_result[:duration] * 1000).round(2)}ms)\n"
327
- results += test_result[:errors]
328
- test_has_error = true
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
- if !@options.only_failures || (@options.only_failures && test_has_error)
339
- puts "\nTesting: #{test_key} (#{(test_duration * 1000).round(2)}ms)"
340
- print results
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
- if test_has_error
344
- failed_tests_count += 1
345
- else
346
- passed_tests_count += 1
347
- end
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
- puts ""
351
- puts "Test specs: #{passed_tests_count} passed, #{failed_tests_count} failed"
352
- puts "Assertions: #{passed_assertions_count} passed, #{failed_assertions_count} failed"
353
- puts ""
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
- if failed_tests_count > 0
356
- exit 1
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
- context = parse_context(assertion[:context])
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
- if assertion.key?(:expectedValue)
368
- actual = instance.get_variable(variable_key, context, options)
369
- unless compare_values(actual, assertion[:expectedValue])
370
- errors += " ✘ expectedValue: expected #{assertion[:expectedValue].inspect} but received #{actual.inspect}\n"
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
- errors += " ✘ expectedEvaluation.#{key}: expected #{expected.inspect} but received #{actual.inspect}\n" unless compare_values(actual, expected)
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
- { has_error: !errors.empty?, errors: errors, duration: Time.now - started }
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[:sticky])
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 += " ✘ expectedToBeEnabled: expected #{expected_to_be_enabled} but received #{is_enabled}\n"
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 += " ✘ expectedVariation: expected #{expected_variation} but received #{variation_value}\n"
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
- actual_json = actual_value.to_json
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 += " ✘ expectedVariables.#{variable_key}: expected #{expected_value} but received #{actual_value}\n"
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 += " ✘ expectedEvaluations.flag.#{key}: expected #{expected_value} but received #{actual_value}\n"
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 += " ✘ expectedEvaluations.variation.#{key}: expected #{expected_value} but received #{actual_value}\n"
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 += " ✘ expectedEvaluations.variables.#{variable_key}.#{key}: expected #{expected_value} but received #{actual_value}\n"
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].each do |child|
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
- child_override_options = create_override_options(child)
522
-
523
- # Pass sticky values to child instance
524
- child_instance = instance.spawn(child_context, child_override_options)
525
-
526
- # Set sticky values for child if they exist
527
- # Create a local copy to ensure it's never nil
528
- child_sticky = sticky || {}
529
- if !child_sticky.empty?
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 += child_result[: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 += " ✘ expectedToBeEnabled: expected #{expected_to_be_enabled} but received #{is_enabled}\n"
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 += " ✘ expectedVariation: expected #{expected_variation} but received #{variation_value}\n"
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
- actual_json = actual_value.to_json
611
- errors += " ✘ expectedVariables.#{variable_key}: expected #{expected_value} but received #{actual_json}\n"
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 += " ✘ expectedVariables.#{variable_key}: expected #{expected_value} but received #{actual_value}\n"
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 += " ✘ expectedEvaluations.flag.#{key}: expected #{expected_value} but received #{actual_value}\n"
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 += " ✘ expectedEvaluations.variation.#{key}: expected #{expected_value} but received #{actual_value}\n"
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 += " ✘ expectedEvaluations.variables.#{variable_key}.#{key}: expected #{expected_value} but received #{actual_value}\n"
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
- conditions = segment[:conditions]
688
-
689
- # Create a minimal datafile for segment testing
690
- datafile = {
691
- schemaVersion: "2",
692
- revision: "tester",
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.instance_variable_get(:@datafile).all_conditions_are_matched(conditions, context)
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 += " ✘ expectedToMatch: expected #{expected_to_match} but received #{actual}\n"
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 :required
832
- evaluation[:required]
833
- when :requiredFeatures
834
- evaluation[:required_features]
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 = Open3.capture3(command)
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
- puts "Error: Command failed with exit code #{status.exitstatus}"
962
- puts "Command: #{command}"
963
- puts "Stderr: #{stderr}" unless stderr.empty?
964
- exit 1
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