featurevisor 0.1.1

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