rspec-enriched_json 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +31 -29
- data/lib/rspec/enriched_json/enriched_expectation_not_met_error.rb +3 -3
- data/lib/rspec/enriched_json/expectation_helper_wrapper.rb +111 -65
- data/lib/rspec/enriched_json/formatters/enriched_json_formatter.rb +97 -7
- data/lib/rspec/enriched_json/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ae7cf3c69f8919e4810f402f0f34fa88bb69affbadcf6f0f9ec0f1150a4bd184
|
4
|
+
data.tar.gz: 4102bf64a17bf2a5d925814f0cb63cb749c14a41a1c8df099a85c1c636b1737c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 389db8798122e41f7bceacd58fe4532b2856b3795b96f540915fbe40f2dab9e6892b1dd7b790c7b04254db450d0696188ef3522d6971309808cf15eaa7bcb63b
|
7
|
+
data.tar.gz: 2d1a5f679578f2621bc54e9a60199ba167e5a7e5bb03fb027aaefdb0183d420e10d9e14abc1879e7ab43f04215348a823f732043e48feeb8ae2f898a029a2336
|
data/README.md
CHANGED
@@ -4,35 +4,19 @@ A drop-in replacement for RSpec's built-in JSON formatter that enriches the outp
|
|
4
4
|
|
5
5
|
## Quick Demo
|
6
6
|
|
7
|
-
To see the difference between RSpec's built-in JSON formatter and this enriched formatter
|
7
|
+
To see the difference between RSpec's built-in JSON formatter and this enriched formatter:
|
8
8
|
|
9
9
|
```bash
|
10
|
-
|
11
|
-
# Built-in formatter (string-only failure messages):
|
12
|
-
bundle exec rspec demo_all_failures.rb --format json --no-profile 2>/dev/null | jq '.examples[4]'
|
13
|
-
|
14
|
-
# Enriched formatter (includes structured_data field):
|
15
|
-
bundle exec rspec demo_all_failures.rb --format RSpec::EnrichedJson::Formatters::EnrichedJsonFormatter --no-profile -r ./lib/rspec/enriched_json 2>/dev/null | jq '.examples[4]'
|
16
|
-
|
17
|
-
# For a cleaner comparison, look at just the structured data:
|
18
|
-
bundle exec rspec demo_all_failures.rb --format RSpec::EnrichedJson::Formatters::EnrichedJsonFormatter --no-profile -r ./lib/rspec/enriched_json 2>/dev/null | jq '.examples[] | select(.description == "fails with string comparison") | .structured_data'
|
10
|
+
ruby demo.rb
|
19
11
|
```
|
20
12
|
|
21
|
-
|
13
|
+
This interactive demo script runs the same failing tests with both formatters and shows you the difference side-by-side. No external dependencies, no file cleanup needed!
|
22
14
|
|
23
|
-
|
24
|
-
-
|
25
|
-
-
|
26
|
-
- Custom error messages
|
27
|
-
- Exception failures
|
28
|
-
- Complex object comparisons
|
29
|
-
- Various matcher types (include, respond_to, be_within, etc.)
|
30
|
-
|
31
|
-
Key differences you'll see:
|
32
|
-
- **Built-in formatter**: Failure information is embedded in string messages
|
33
|
-
- **Enriched formatter**: Adds a `structured_data` field with:
|
15
|
+
**What you'll see:**
|
16
|
+
- **Built-in formatter**: Failure information embedded in string messages
|
17
|
+
- **Enriched formatter**: Adds structured data with:
|
34
18
|
- `expected`: The expected value as a proper JSON object
|
35
|
-
- `actual`: The actual value as a proper JSON object
|
19
|
+
- `actual`: The actual value as a proper JSON object
|
36
20
|
- `matcher_name`: The RSpec matcher class used
|
37
21
|
- `original_message`: Preserved when custom messages are provided
|
38
22
|
|
@@ -99,11 +83,14 @@ With this gem, you get structured data alongside the original message:
|
|
99
83
|
"message": "\nexpected: \"Hello, Ruby!\"\n got: \"Hello, World!\"\n\n(compared using ==)\n",
|
100
84
|
"backtrace": ["./spec/example_spec.rb:5:in `block (2 levels) in <top (required)>'"]
|
101
85
|
},
|
102
|
-
"
|
86
|
+
"enriched_with": {
|
103
87
|
"expected": "Hello, Ruby!",
|
104
88
|
"actual": "Hello, World!",
|
105
89
|
"matcher_name": "RSpec::Matchers::BuiltIn::Eq",
|
106
|
-
"original_message": null
|
90
|
+
"original_message": null,
|
91
|
+
"diff_info": {
|
92
|
+
"diffable": true
|
93
|
+
}
|
107
94
|
}
|
108
95
|
}
|
109
96
|
```
|
@@ -115,26 +102,29 @@ With this gem, you get structured data alongside the original message:
|
|
115
102
|
- **Rich object support**: Arrays, hashes, and custom objects are properly serialized
|
116
103
|
- **Original message preservation**: When you override with a custom message, the original is preserved
|
117
104
|
- **Graceful degradation**: Regular exceptions (non-expectation failures) work normally
|
105
|
+
- **Enhanced metadata capture**: Test location, tags, hierarchy, and custom metadata
|
106
|
+
- **Robust error recovery**: Handles objects that fail to serialize without crashing
|
107
|
+
- **Diff information**: Includes `diff_info.diffable` to help tools determine if values can be meaningfully diffed
|
118
108
|
|
119
109
|
## Examples
|
120
110
|
|
121
111
|
### Simple Values
|
122
112
|
```ruby
|
123
113
|
expect(1 + 1).to eq(3)
|
124
|
-
#
|
114
|
+
# enriched_with: { "expected": 3, "actual": 2 }
|
125
115
|
```
|
126
116
|
|
127
117
|
### Collections
|
128
118
|
```ruby
|
129
119
|
expect([1, 2, 3]).to eq([1, 2, 4])
|
130
|
-
#
|
120
|
+
# enriched_with: { "expected": [1, 2, 4], "actual": [1, 2, 3] }
|
131
121
|
```
|
132
122
|
|
133
123
|
### Complex Objects
|
134
124
|
```ruby
|
135
125
|
Product = Struct.new(:name, :price)
|
136
126
|
expect(Product.new("Laptop", 999)).to eq(Product.new("Laptop", 899))
|
137
|
-
#
|
127
|
+
# enriched_with includes class info and struct values
|
138
128
|
```
|
139
129
|
|
140
130
|
### Custom Messages
|
@@ -142,7 +132,19 @@ expect(Product.new("Laptop", 999)).to eq(Product.new("Laptop", 899))
|
|
142
132
|
expect(balance).to be >= required,
|
143
133
|
"Insufficient funds: $#{balance} available, $#{required} required"
|
144
134
|
# exception.message: "Insufficient funds: $50 available, $100 required"
|
145
|
-
#
|
135
|
+
# enriched_with: { "original_message": "expected: >= 100\n got: 50" }
|
136
|
+
```
|
137
|
+
|
138
|
+
### Metadata Capture
|
139
|
+
```ruby
|
140
|
+
it "validates user input", :slow, :db, priority: :high do
|
141
|
+
expect(user).to be_valid
|
142
|
+
end
|
143
|
+
# metadata includes:
|
144
|
+
# - location: "./spec/models/user_spec.rb:42"
|
145
|
+
# - absolute_file_path: "/path/to/project/spec/models/user_spec.rb"
|
146
|
+
# - tags: { "slow": true, "db": true, "priority": "high" }
|
147
|
+
# - example_group_hierarchy: ["User", "validations", "email format"]
|
146
148
|
```
|
147
149
|
|
148
150
|
## Use Cases
|
@@ -6,11 +6,11 @@ module RSpec
|
|
6
6
|
module EnrichedJson
|
7
7
|
# Custom exception that carries structured data alongside the message
|
8
8
|
class EnrichedExpectationNotMetError < RSpec::Expectations::ExpectationNotMetError
|
9
|
-
attr_reader :
|
9
|
+
attr_reader :enriched_with
|
10
10
|
|
11
|
-
def initialize(message,
|
11
|
+
def initialize(message, enriched_with = {})
|
12
12
|
super(message)
|
13
|
-
@
|
13
|
+
@enriched_with = enriched_with
|
14
14
|
end
|
15
15
|
end
|
16
16
|
end
|
@@ -15,6 +15,82 @@ module RSpec
|
|
15
15
|
RSpec::Expectations::ExpectationHelper.singleton_class.prepend(self)
|
16
16
|
end
|
17
17
|
|
18
|
+
# Make serialize_value accessible for other components
|
19
|
+
module Serializer
|
20
|
+
extend self
|
21
|
+
|
22
|
+
MAX_SERIALIZATION_DEPTH = 5
|
23
|
+
MAX_ARRAY_SIZE = 100
|
24
|
+
MAX_HASH_SIZE = 100
|
25
|
+
MAX_STRING_LENGTH = 1000
|
26
|
+
|
27
|
+
def serialize_value(value, depth = 0)
|
28
|
+
return "[Max depth exceeded]" if depth > MAX_SERIALIZATION_DEPTH
|
29
|
+
|
30
|
+
case value
|
31
|
+
when nil, Numeric, TrueClass, FalseClass
|
32
|
+
value
|
33
|
+
when String
|
34
|
+
truncate_string(value)
|
35
|
+
when Symbol
|
36
|
+
value.to_s
|
37
|
+
when Array
|
38
|
+
return "[Large array: #{value.size} items]" if value.size > MAX_ARRAY_SIZE
|
39
|
+
value.map { |v| serialize_value(v, depth + 1) }
|
40
|
+
when Hash
|
41
|
+
return "[Large hash: #{value.size} keys]" if value.size > MAX_HASH_SIZE
|
42
|
+
value.transform_values { |v| serialize_value(v, depth + 1) }
|
43
|
+
else
|
44
|
+
serialize_object(value, depth)
|
45
|
+
end
|
46
|
+
rescue => e
|
47
|
+
{
|
48
|
+
"class" => value.class.name,
|
49
|
+
"serialization_error" => e.message
|
50
|
+
}
|
51
|
+
end
|
52
|
+
|
53
|
+
def serialize_object(obj, depth = 0)
|
54
|
+
result = {
|
55
|
+
"class" => obj.class.name,
|
56
|
+
"inspect" => safe_inspect(obj),
|
57
|
+
"to_s" => safe_to_s(obj)
|
58
|
+
}
|
59
|
+
|
60
|
+
# Handle Structs specially
|
61
|
+
if obj.is_a?(Struct)
|
62
|
+
result["struct_values"] = obj.to_h.transform_values { |v| serialize_value(v, depth + 1) }
|
63
|
+
end
|
64
|
+
|
65
|
+
# Include instance variables only for small objects
|
66
|
+
ivars = obj.instance_variables
|
67
|
+
if ivars.any? && ivars.length <= 10
|
68
|
+
result["instance_variables"] = ivars.each_with_object({}) do |ivar, hash|
|
69
|
+
hash[ivar.to_s] = serialize_value(obj.instance_variable_get(ivar), depth + 1)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
result
|
74
|
+
end
|
75
|
+
|
76
|
+
def truncate_string(str)
|
77
|
+
return str if str.length <= MAX_STRING_LENGTH
|
78
|
+
"#{str[0...MAX_STRING_LENGTH]}... (truncated)"
|
79
|
+
end
|
80
|
+
|
81
|
+
def safe_inspect(obj)
|
82
|
+
truncate_string(obj.inspect)
|
83
|
+
rescue => e
|
84
|
+
"[inspect failed: #{e.class}]"
|
85
|
+
end
|
86
|
+
|
87
|
+
def safe_to_s(obj)
|
88
|
+
truncate_string(obj.to_s)
|
89
|
+
rescue => e
|
90
|
+
"[to_s failed: #{e.class}]"
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
18
94
|
def handle_failure(matcher, message, failure_message_method)
|
19
95
|
# If a custom message is provided, capture the original message first
|
20
96
|
original_message = nil
|
@@ -25,16 +101,23 @@ module RSpec
|
|
25
101
|
# Call original handler with the original message
|
26
102
|
super
|
27
103
|
rescue RSpec::Expectations::ExpectationNotMetError => e
|
104
|
+
# Extract raw values for diff analysis
|
105
|
+
expected_raw = extract_value(matcher, :expected)
|
106
|
+
actual_raw = extract_value(matcher, :actual)
|
107
|
+
|
28
108
|
# Collect structured data
|
29
|
-
|
30
|
-
expected: serialize_value(
|
31
|
-
actual: serialize_value(
|
109
|
+
enriched_with = {
|
110
|
+
expected: Serializer.serialize_value(expected_raw),
|
111
|
+
actual: Serializer.serialize_value(actual_raw),
|
32
112
|
original_message: original_message, # Only populated when custom message overrides it
|
33
|
-
matcher_name: matcher.class.name
|
113
|
+
matcher_name: matcher.class.name,
|
114
|
+
diff_info: {
|
115
|
+
diffable: values_diffable?(expected_raw, actual_raw, matcher)
|
116
|
+
}
|
34
117
|
}
|
35
118
|
|
36
119
|
# Raise new exception with data attached
|
37
|
-
raise EnrichedExpectationNotMetError.new(e.message,
|
120
|
+
raise EnrichedExpectationNotMetError.new(e.message, enriched_with)
|
38
121
|
end
|
39
122
|
|
40
123
|
private
|
@@ -43,75 +126,38 @@ module RSpec
|
|
43
126
|
return nil unless matcher.respond_to?(method_name)
|
44
127
|
|
45
128
|
value = matcher.send(method_name)
|
46
|
-
|
129
|
+
# Don't return nil if the value itself is nil
|
130
|
+
# Only return nil if the value is the matcher itself (self-referential)
|
131
|
+
(value == matcher && !value.nil?) ? nil : value
|
47
132
|
rescue
|
48
133
|
nil
|
49
134
|
end
|
50
135
|
|
51
|
-
def
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
when nil, Numeric, TrueClass, FalseClass
|
56
|
-
value
|
57
|
-
when String
|
58
|
-
truncate_string(value)
|
59
|
-
when Symbol
|
60
|
-
value.to_s
|
61
|
-
when Array
|
62
|
-
return "[Large array: #{value.size} items]" if value.size > MAX_ARRAY_SIZE
|
63
|
-
value.map { |v| serialize_value(v, depth + 1) }
|
64
|
-
when Hash
|
65
|
-
return "[Large hash: #{value.size} keys]" if value.size > MAX_HASH_SIZE
|
66
|
-
value.transform_values { |v| serialize_value(v, depth + 1) }
|
67
|
-
else
|
68
|
-
serialize_object(value, depth)
|
136
|
+
def values_diffable?(expected, actual, matcher)
|
137
|
+
# First check if the matcher itself declares diffability
|
138
|
+
if matcher.respond_to?(:diffable?)
|
139
|
+
return matcher.diffable?
|
69
140
|
end
|
70
|
-
rescue => e
|
71
|
-
{
|
72
|
-
"class" => value.class.name,
|
73
|
-
"serialization_error" => e.message
|
74
|
-
}
|
75
|
-
end
|
76
141
|
|
77
|
-
|
78
|
-
|
79
|
-
"class" => obj.class.name,
|
80
|
-
"inspect" => safe_inspect(obj),
|
81
|
-
"to_s" => safe_to_s(obj)
|
82
|
-
}
|
142
|
+
# If either value is nil, not diffable
|
143
|
+
return false if expected.nil? || actual.nil?
|
83
144
|
|
84
|
-
#
|
85
|
-
|
86
|
-
result["struct_values"] = obj.to_h.transform_values { |v| serialize_value(v, depth + 1) }
|
87
|
-
end
|
145
|
+
# For different classes, generally not diffable
|
146
|
+
return false unless actual.instance_of?(expected.class)
|
88
147
|
|
89
|
-
#
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
148
|
+
# Check if both values are of the same basic diffable type
|
149
|
+
case expected
|
150
|
+
when String, Array, Hash
|
151
|
+
# These types are inherently diffable when compared to same type
|
152
|
+
true
|
153
|
+
else
|
154
|
+
# For other types, they're diffable if they respond to to_s
|
155
|
+
# and their string representations would be meaningful
|
156
|
+
expected.respond_to?(:to_s) && actual.respond_to?(:to_s)
|
95
157
|
end
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
def truncate_string(str)
|
101
|
-
return str if str.length <= MAX_STRING_LENGTH
|
102
|
-
"#{str[0...MAX_STRING_LENGTH]}... (truncated)"
|
103
|
-
end
|
104
|
-
|
105
|
-
def safe_inspect(obj)
|
106
|
-
truncate_string(obj.inspect)
|
107
|
-
rescue => e
|
108
|
-
"[inspect failed: #{e.class}]"
|
109
|
-
end
|
110
|
-
|
111
|
-
def safe_to_s(obj)
|
112
|
-
truncate_string(obj.to_s)
|
113
|
-
rescue => e
|
114
|
-
"[to_s failed: #{e.class}]"
|
158
|
+
rescue
|
159
|
+
# If any error occurs during checking, assume not diffable
|
160
|
+
false
|
115
161
|
end
|
116
162
|
end
|
117
163
|
end
|
@@ -12,6 +12,9 @@ module RSpec
|
|
12
12
|
def stop(group_notification)
|
13
13
|
@output_hash[:examples] = group_notification.notifications.map do |notification|
|
14
14
|
format_example(notification.example).tap do |hash|
|
15
|
+
# Add enhanced metadata
|
16
|
+
add_metadata(hash, notification.example)
|
17
|
+
|
15
18
|
e = notification.example.exception
|
16
19
|
|
17
20
|
if e
|
@@ -22,18 +25,105 @@ module RSpec
|
|
22
25
|
}
|
23
26
|
|
24
27
|
# Add structured data if available
|
25
|
-
if e.is_a?(RSpec::EnrichedJson::EnrichedExpectationNotMetError) && e.
|
26
|
-
hash[:
|
27
|
-
expected: e.structured_data[:expected],
|
28
|
-
actual: e.structured_data[:actual],
|
29
|
-
matcher_name: e.structured_data[:matcher_name],
|
30
|
-
original_message: e.structured_data[:original_message]
|
31
|
-
}
|
28
|
+
if e.is_a?(RSpec::EnrichedJson::EnrichedExpectationNotMetError) && e.enriched_with
|
29
|
+
hash[:enriched_with] = safe_structured_data(e.enriched_with)
|
32
30
|
end
|
33
31
|
end
|
34
32
|
end
|
35
33
|
end
|
36
34
|
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def add_metadata(hash, example)
|
39
|
+
metadata = example.metadata.dup
|
40
|
+
|
41
|
+
# Extract custom tags (all symbols and specific keys)
|
42
|
+
custom_tags = {}
|
43
|
+
metadata.each do |key, value|
|
44
|
+
# Include all symbol keys (like :focus, :slow, etc.)
|
45
|
+
if key.is_a?(Symbol) && value == true
|
46
|
+
custom_tags[key] = true
|
47
|
+
# Include specific metadata that might be useful
|
48
|
+
elsif [:type, :priority, :severity, :db, :js].include?(key)
|
49
|
+
custom_tags[key] = value
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# Add enhanced metadata
|
54
|
+
hash[:metadata] = {
|
55
|
+
# Location information
|
56
|
+
location: example.location,
|
57
|
+
absolute_file_path: File.expand_path(example.metadata[:file_path]),
|
58
|
+
rerun_file_path: example.location_rerun_argument,
|
59
|
+
|
60
|
+
# Example hierarchy
|
61
|
+
example_group: example.example_group.description,
|
62
|
+
example_group_hierarchy: extract_group_hierarchy(example),
|
63
|
+
|
64
|
+
# Described class if available
|
65
|
+
described_class: metadata[:described_class]&.to_s,
|
66
|
+
|
67
|
+
# Custom tags and metadata
|
68
|
+
tags: custom_tags.empty? ? nil : custom_tags,
|
69
|
+
|
70
|
+
# Shared example information if applicable
|
71
|
+
shared_group_inclusion_backtrace: metadata[:shared_group_inclusion_backtrace]
|
72
|
+
}.compact # Remove nil values
|
73
|
+
end
|
74
|
+
|
75
|
+
def extract_group_hierarchy(example)
|
76
|
+
hierarchy = []
|
77
|
+
current_group = example.example_group
|
78
|
+
|
79
|
+
while current_group
|
80
|
+
hierarchy.unshift(current_group.description)
|
81
|
+
current_group = (current_group.superclass < RSpec::Core::ExampleGroup) ? current_group.superclass : nil
|
82
|
+
end
|
83
|
+
|
84
|
+
hierarchy
|
85
|
+
end
|
86
|
+
|
87
|
+
def safe_structured_data(enriched_with)
|
88
|
+
{
|
89
|
+
expected: safe_serialize(enriched_with[:expected]),
|
90
|
+
actual: safe_serialize(enriched_with[:actual]),
|
91
|
+
matcher_name: enriched_with[:matcher_name],
|
92
|
+
original_message: enriched_with[:original_message],
|
93
|
+
diff_info: enriched_with[:diff_info]
|
94
|
+
}.compact
|
95
|
+
end
|
96
|
+
|
97
|
+
def safe_serialize(value)
|
98
|
+
# Delegate to the existing serialization logic in ExpectationHelperWrapper
|
99
|
+
RSpec::EnrichedJson::ExpectationHelperWrapper::Serializer.serialize_value(value)
|
100
|
+
rescue => e
|
101
|
+
# Better error recovery - provide context about what failed
|
102
|
+
begin
|
103
|
+
obj_class = value.class.name
|
104
|
+
rescue
|
105
|
+
obj_class = "Unknown"
|
106
|
+
end
|
107
|
+
|
108
|
+
{
|
109
|
+
"serialization_error" => true,
|
110
|
+
"error_class" => e.class.name,
|
111
|
+
"error_message" => e.message,
|
112
|
+
"object_class" => obj_class,
|
113
|
+
"fallback_value" => safe_fallback_value(value)
|
114
|
+
}
|
115
|
+
end
|
116
|
+
|
117
|
+
def safe_fallback_value(value)
|
118
|
+
# Try multiple fallback strategies
|
119
|
+
value.to_s
|
120
|
+
rescue
|
121
|
+
begin
|
122
|
+
value.class.name
|
123
|
+
rescue
|
124
|
+
"Unable to serialize"
|
125
|
+
end
|
126
|
+
end
|
37
127
|
end
|
38
128
|
end
|
39
129
|
end
|