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.
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,29 +213,46 @@ 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?
@@ -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] || assertion[:sticky])
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
- Featurevisor.create_featurevisor(
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: 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, config)
257
- passed_tests_count = 0
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
- 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
264
308
  assertions = test[:assertions] || []
265
- if (test[:feature] || test[:variable]) && !@options.targets.empty?
266
- assertions = assertions.select do |assertion|
267
- assertion[:target].nil? || @options.targets.include?(assertion[:target])
268
- 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]) }
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
- if datafile
290
- 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
+ }
291
327
 
292
- # Show datafile if requested
293
- if @options.show_datafile
294
- puts ""
295
- puts JSON.pretty_generate(datafile)
296
- puts ""
297
- 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
298
347
 
299
- begin
300
- test_result = run_test_feature(assertion, test[:feature], instance, level)
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
- if test_result
331
- test_duration += test_result[:duration]
351
+ finalize_report!(report)
352
+ report
353
+ end
332
354
 
333
- if test_result[:has_error]
334
- results += " ✘ #{assertion[:description]} (#{(test_result[:duration] * 1000).round(2)}ms)\n"
335
- results += test_result[:errors]
336
- test_has_error = true
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
- if !@options.only_failures || (@options.only_failures && test_has_error)
347
- puts "\nTesting: #{test_key} (#{(test_duration * 1000).round(2)}ms)"
348
- 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 }
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
- if test_has_error
352
- failed_tests_count += 1
353
- else
354
- passed_tests_count += 1
355
- 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 }
356
379
  end
357
380
 
358
- puts ""
359
- puts "Test specs: #{passed_tests_count} passed, #{failed_tests_count} failed"
360
- puts "Assertions: #{passed_assertions_count} passed, #{failed_assertions_count} failed"
361
- 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
362
392
 
363
- if failed_tests_count > 0
364
- 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
365
417
  end
366
418
  end
367
419
 
368
- def run_test_variable(assertion, variable_key, instance)
369
- instance.set_context(parse_context(assertion[:context]), true)
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 += test_variable_expectation(assertion, variable_key, instance, options)
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 += test_variable_expectation(
389
- child_assertion,
390
- variable_key,
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
- child_options,
393
- "children[#{child_index}]."
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, prefix = "")
517
+ def test_variable_expectation(assertion, variable_key, evaluator, options, child_index = nil)
404
518
  evaluation = evaluator.evaluate_variable(variable_key, {}, options)
405
- errors = ""
406
- if assertion.key?(:expectedValue) && !compare_values(evaluation[:variable_value], assertion[:expectedValue])
407
- errors += " ✘ #{prefix}expectedValue: expected #{format_test_value(assertion[:expectedValue])} but received #{format_test_value(evaluation[:variable_value])}\n"
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 += " ✘ #{prefix}expectedEvaluation.#{key}: expected #{format_test_value(expected)} but received #{format_test_value(actual)}\n"
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[:sticky])
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 += " ✘ expectedToBeEnabled: expected #{expected_to_be_enabled} but received #{is_enabled}\n"
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 += " ✘ expectedVariation: expected #{expected_variation} but received #{variation_value}\n"
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
- actual_json = actual_value.to_json
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 += " ✘ expectedVariables.#{variable_key}: expected #{expected_value} but received #{actual_value}\n"
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 += " ✘ 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
+ )
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 += " ✘ 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
+ )
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 += " ✘ 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
+ )
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].each do |child|
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: sticky || {}
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 += child_result[: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 += " ✘ expectedToBeEnabled: expected #{expected_to_be_enabled} but received #{is_enabled}\n"
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 += " ✘ expectedVariation: expected #{expected_variation} but received #{variation_value}\n"
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
- actual_json = actual_value.to_json
648
- 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
+ )
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 += " ✘ 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
+ )
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 += " ✘ 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
+ )
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 += " ✘ 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
+ )
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 += " ✘ 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
+ )
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
- conditions = segment[:conditions]
725
-
726
- # Create a minimal datafile for segment testing
727
- datafile = {
728
- schemaVersion: "2",
729
- revision: "tester",
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.instance_variable_get(:@datafile).all_conditions_are_matched(conditions, context)
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 += " ✘ expectedToMatch: expected #{expected_to_match} but received #{actual}\n"
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 :required
869
- evaluation[:required]
870
- when :requiredFeatures
871
- evaluation[:required_features]
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 = 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)
996
1461
 
997
1462
  unless status.success?
998
- puts "Error: Command failed with exit code #{status.exitstatus}"
999
- puts "Command: #{command}"
1000
- puts "Stderr: #{stderr}" unless stderr.empty?
1001
- 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}"
1002
1477
  end
1003
1478
 
1004
1479
  stdout