insta 0.2.9 → 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/LICENSE.txt +1 -1
- data/Rakefile +30 -3
- data/exe/insta +6 -0
- data/lib/insta/ansi.rb +30 -0
- data/lib/insta/ci.rb +21 -0
- data/lib/insta/cli.rb +714 -0
- data/lib/insta/config_manifest.rb +56 -0
- data/lib/insta/configuration.rb +86 -2
- data/lib/insta/diff.rb +134 -0
- data/lib/insta/inline/call_finder.rb +37 -0
- data/lib/insta/inline/edit.rb +21 -0
- data/lib/insta/inline/file_patcher.rb +269 -0
- data/lib/insta/inline/pending_registry.rb +57 -0
- data/lib/insta/inline/pending_store.rb +145 -0
- data/lib/insta/minitest/assertions.rb +254 -0
- data/lib/insta/minitest/lifecycle.rb +12 -0
- data/lib/insta/minitest.rb +8 -0
- data/lib/insta/pending_locations.rb +64 -0
- data/lib/insta/pending_reporter.rb +47 -0
- data/lib/insta/redaction/applicator.rb +89 -0
- data/lib/insta/redaction/selector.rb +193 -0
- data/lib/insta/rspec/hooks.rb +18 -0
- data/lib/insta/rspec/matchers.rb +248 -0
- data/lib/insta/rspec.rb +7 -0
- data/lib/insta/serializers/base.rb +31 -0
- data/lib/insta/serializers/inspect.rb +14 -0
- data/lib/insta/serializers/json.rb +16 -0
- data/lib/insta/serializers/string.rb +14 -0
- data/lib/insta/serializers/yaml.rb +16 -0
- data/lib/insta/snapshot.rb +74 -0
- data/lib/insta/snapshot_content.rb +44 -0
- data/lib/insta/snapshot_file.rb +64 -0
- data/lib/insta/snapshot_mismatch_handler.rb +34 -0
- data/lib/insta/snapshot_name.rb +51 -0
- data/lib/insta/syntax_highlight.rb +20 -0
- data/lib/insta/update_coordinator.rb +22 -0
- data/lib/insta/version.rb +3 -1
- data/lib/insta.rb +42 -8
- data/lib/minitest/insta_plugin.rb +17 -0
- data/sig/insta/ansi.rbs +28 -0
- data/sig/insta/ci.rbs +10 -0
- data/sig/insta/cli.rbs +150 -0
- data/sig/insta/config_manifest.rbs +27 -0
- data/sig/insta/configuration.rbs +48 -0
- data/sig/insta/diff.rbs +38 -0
- data/sig/insta/inline/call_finder.rbs +22 -0
- data/sig/insta/inline/edit.rbs +18 -0
- data/sig/insta/inline/file_patcher.rbs +55 -0
- data/sig/insta/inline/pending_registry.rbs +22 -0
- data/sig/insta/inline/pending_store.rbs +41 -0
- data/sig/insta/minitest/assertions.rbs +52 -0
- data/sig/insta/minitest/lifecycle.rbs +10 -0
- data/sig/insta/minitest.rbs +2 -0
- data/sig/insta/pending_locations.rbs +22 -0
- data/sig/insta/pending_reporter.rbs +14 -0
- data/sig/insta/redaction/applicator.rbs +22 -0
- data/sig/insta/redaction/selector.rbs +58 -0
- data/sig/insta/rspec/hooks.rbs +10 -0
- data/sig/insta/rspec/matchers.rbs +71 -0
- data/sig/insta/rspec.rbs +2 -0
- data/sig/insta/serializers/base.rbs +21 -0
- data/sig/insta/serializers/inspect.rbs +10 -0
- data/sig/insta/serializers/json.rbs +10 -0
- data/sig/insta/serializers/string.rbs +10 -0
- data/sig/insta/serializers/yaml.rbs +10 -0
- data/sig/insta/snapshot.rbs +37 -0
- data/sig/insta/snapshot_content.rbs +14 -0
- data/sig/insta/snapshot_file.rbs +27 -0
- data/sig/insta/snapshot_mismatch_handler.rbs +12 -0
- data/sig/insta/snapshot_name.rbs +17 -0
- data/sig/insta/syntax_highlight.rbs +8 -0
- data/sig/insta/update_coordinator.rbs +11 -0
- data/sig/insta/version.rbs +5 -0
- data/sig/insta.rbs +12 -0
- data/sig/minitest/insta_plugin.rbs +6 -0
- data/sig/vendor/difftastic.rbs +19 -0
- data/sig/vendor/irb.rbs +5 -0
- data/sig/vendor/psych.rbs +7 -0
- data/sig/vendor/rspec.rbs +9 -0
- data/sig/vendor/rubygems.rbs +3 -0
- metadata +109 -54
- data/.DS_Store +0 -0
- data/.gitignore +0 -11
- data/.rspec +0 -3
- data/.travis.yml +0 -5
- data/CODE_OF_CONDUCT.md +0 -74
- data/Gemfile +0 -6
- data/Gemfile.lock +0 -35
- data/README.md +0 -41
- data/bin/console +0 -14
- data/bin/setup +0 -8
- data/insta.gemspec +0 -36
- data/lib/.DS_Store +0 -0
- data/lib/insta/account.rb +0 -127
- data/lib/insta/api.rb +0 -88
- data/lib/insta/constants.rb +0 -21
- data/lib/insta/device.rb +0 -11852
- data/lib/insta/feed.rb +0 -89
- data/lib/insta/location.rb +0 -18
- data/lib/insta/proxy_manager.rb +0 -23
- data/lib/insta/tag.rb +0 -18
- data/lib/insta/user.rb +0 -103
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insta
|
|
4
|
+
module Redaction
|
|
5
|
+
class Selector
|
|
6
|
+
Segment = Struct.new(:type, :value, keyword_init: true) #: Struct[Symbol, untyped]
|
|
7
|
+
|
|
8
|
+
SEGMENT_TYPES = [:key, :index, :wildcard, :deep_wildcard, :full_range].freeze #: Array[Symbol]
|
|
9
|
+
|
|
10
|
+
attr_reader :segments #: Array[Segment]
|
|
11
|
+
|
|
12
|
+
#: (String) -> void
|
|
13
|
+
def initialize(selector_string)
|
|
14
|
+
@segments = parse(selector_string)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
#: (Array[{ type: Symbol, value: untyped }]) -> bool
|
|
18
|
+
def matches?(path)
|
|
19
|
+
deep_index = segments.index { |segment| segment.type == :deep_wildcard }
|
|
20
|
+
|
|
21
|
+
if deep_index
|
|
22
|
+
deep_wildcard_match?(path, deep_index)
|
|
23
|
+
else
|
|
24
|
+
exact_match?(path, segments)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
#: (String) -> Array[Segment]
|
|
31
|
+
def parse(input)
|
|
32
|
+
raise ArgumentError, "Selector must not be empty" if input.empty?
|
|
33
|
+
raise ArgumentError, "Selector must start with '.' or '['" unless input.start_with?(".", "[")
|
|
34
|
+
|
|
35
|
+
result = [] #: Array[Segment]
|
|
36
|
+
position = 0
|
|
37
|
+
|
|
38
|
+
while position < input.length
|
|
39
|
+
case input[position]
|
|
40
|
+
when "."
|
|
41
|
+
position = parse_dot_segment(input, position + 1, result)
|
|
42
|
+
when "["
|
|
43
|
+
position = parse_bracket_segment(input, position + 1, result)
|
|
44
|
+
else
|
|
45
|
+
raise ArgumentError, "Unexpected character '#{input[position]}' at position #{position}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
result
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
#: (String, Integer, Array[Segment]) -> Integer
|
|
53
|
+
def parse_dot_segment(input, position, result)
|
|
54
|
+
if position < input.length && input[position] == "*"
|
|
55
|
+
parse_wildcard_segment(input, position + 1, result)
|
|
56
|
+
else
|
|
57
|
+
parse_key_segment(input, position, result)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
#: (String, Integer, Array[Segment]) -> Integer
|
|
62
|
+
def parse_wildcard_segment(input, position, result)
|
|
63
|
+
if position < input.length && input[position] == "*"
|
|
64
|
+
raise ArgumentError, "Only one deep wildcard (**) allowed per selector" if result.any? { |segment| segment.type == :deep_wildcard }
|
|
65
|
+
|
|
66
|
+
result << Segment.new(type: :deep_wildcard, value: nil)
|
|
67
|
+
|
|
68
|
+
position + 1
|
|
69
|
+
else
|
|
70
|
+
result << Segment.new(type: :wildcard, value: nil)
|
|
71
|
+
|
|
72
|
+
position
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
#: (String, Integer, Array[Segment]) -> Integer
|
|
77
|
+
def parse_key_segment(input, position, result)
|
|
78
|
+
key = +""
|
|
79
|
+
|
|
80
|
+
while position < input.length && input[position] != "." && input[position] != "["
|
|
81
|
+
key << input[position].to_s
|
|
82
|
+
position += 1
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
raise ArgumentError, "Empty key in selector at position #{position}" if key.empty?
|
|
86
|
+
|
|
87
|
+
result << Segment.new(type: :key, value: key)
|
|
88
|
+
|
|
89
|
+
position
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
#: (String, Integer, Array[Segment]) -> Integer
|
|
93
|
+
def parse_bracket_segment(input, position, result)
|
|
94
|
+
if position < input.length && input[position] == "]"
|
|
95
|
+
result << Segment.new(type: :full_range, value: nil)
|
|
96
|
+
|
|
97
|
+
position + 1
|
|
98
|
+
elsif position < input.length && input[position] == '"'
|
|
99
|
+
parse_quoted_key_segment(input, position + 1, result)
|
|
100
|
+
else
|
|
101
|
+
parse_index_segment(input, position, result)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
#: (String, Integer, Array[Segment]) -> Integer
|
|
106
|
+
def parse_quoted_key_segment(input, position, result)
|
|
107
|
+
key = +""
|
|
108
|
+
|
|
109
|
+
while position < input.length && input[position] != '"'
|
|
110
|
+
key << input[position].to_s
|
|
111
|
+
position += 1
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
raise ArgumentError, "Unterminated quoted key in selector" if position >= input.length
|
|
115
|
+
|
|
116
|
+
position += 1
|
|
117
|
+
raise ArgumentError, "Expected ']' after quoted key" if position >= input.length || input[position] != "]"
|
|
118
|
+
|
|
119
|
+
result << Segment.new(type: :key, value: key)
|
|
120
|
+
|
|
121
|
+
position + 1
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
#: (String, Integer, Array[Segment]) -> Integer
|
|
125
|
+
def parse_index_segment(input, position, result)
|
|
126
|
+
digits = +""
|
|
127
|
+
|
|
128
|
+
while position < input.length && input[position] =~ /\d/
|
|
129
|
+
digits << input[position].to_s
|
|
130
|
+
position += 1
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
raise ArgumentError, "Expected number or ']' in bracket" if digits.empty?
|
|
134
|
+
raise ArgumentError, "Expected ']' after index" if position >= input.length || input[position] != "]"
|
|
135
|
+
|
|
136
|
+
result << Segment.new(type: :index, value: digits.to_i)
|
|
137
|
+
|
|
138
|
+
position + 1
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
#: (Array[{ type: Symbol, value: untyped }], Integer) -> bool
|
|
142
|
+
def deep_wildcard_match?(path, deep_index)
|
|
143
|
+
before = segments[0...deep_index] || [] #: Array[Segment]
|
|
144
|
+
after = segments[(deep_index + 1)..] || [] #: Array[Segment]
|
|
145
|
+
|
|
146
|
+
return false if path.length < before.length + after.length
|
|
147
|
+
|
|
148
|
+
before.each_with_index do |segment, index|
|
|
149
|
+
return false unless segment_matches?(segment, path[index])
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
return true if after.empty?
|
|
153
|
+
|
|
154
|
+
after_length = after.length
|
|
155
|
+
path_length = path.length
|
|
156
|
+
|
|
157
|
+
after.each_with_index do |segment, index|
|
|
158
|
+
path_index = path_length - after_length + index
|
|
159
|
+
return false unless segment_matches?(segment, path[path_index])
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
true
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
#: (Array[{ type: Symbol, value: untyped }], Array[Segment]) -> bool
|
|
166
|
+
def exact_match?(path, expected_segments)
|
|
167
|
+
return false unless path.length == expected_segments.length
|
|
168
|
+
|
|
169
|
+
expected_segments.each_with_index do |segment, index|
|
|
170
|
+
return false unless segment_matches?(segment, path[index])
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
true
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
#: (Segment, { type: Symbol, value: untyped }) -> bool
|
|
177
|
+
def segment_matches?(segment, path_entry)
|
|
178
|
+
case segment.type
|
|
179
|
+
when :key
|
|
180
|
+
path_entry[:type] == :key && path_entry[:value].to_s == segment.value
|
|
181
|
+
when :index
|
|
182
|
+
path_entry[:type] == :index && path_entry[:value] == segment.value
|
|
183
|
+
when :wildcard
|
|
184
|
+
path_entry[:type] == :key
|
|
185
|
+
when :full_range
|
|
186
|
+
path_entry[:type] == :index
|
|
187
|
+
else
|
|
188
|
+
false
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insta
|
|
4
|
+
module RSpec
|
|
5
|
+
module Hooks
|
|
6
|
+
#: () -> void
|
|
7
|
+
def self.install!
|
|
8
|
+
::RSpec.configure do |config|
|
|
9
|
+
config.include(Matchers)
|
|
10
|
+
|
|
11
|
+
config.after(:suite) do
|
|
12
|
+
PendingReporter.flush_and_report!
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/expectations"
|
|
4
|
+
|
|
5
|
+
module Insta
|
|
6
|
+
module RSpec
|
|
7
|
+
module Matchers
|
|
8
|
+
class MatchSnapshot
|
|
9
|
+
include ::RSpec::Matchers::Composable
|
|
10
|
+
|
|
11
|
+
attr_reader :failure_message #: String?
|
|
12
|
+
|
|
13
|
+
#: (String?, ?serializer: Symbol?, ?redact: Hash[String, untyped]?) -> void
|
|
14
|
+
def initialize(name = nil, serializer: nil, redact: nil)
|
|
15
|
+
@name = name
|
|
16
|
+
@serializer = serializer
|
|
17
|
+
@redact = redact
|
|
18
|
+
@failure_message = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
#: (untyped) -> bool
|
|
22
|
+
def matches?(actual)
|
|
23
|
+
@actual = actual
|
|
24
|
+
expression = actual.class.name
|
|
25
|
+
|
|
26
|
+
actual = Redaction::Applicator.apply(actual, @redact) if @redact
|
|
27
|
+
serializer_name = @serializer || Insta.configuration.default_serializer
|
|
28
|
+
serialized = Serializers.serialize(serializer_name, actual)
|
|
29
|
+
content = SnapshotContent.normalize(serialized)
|
|
30
|
+
|
|
31
|
+
example = ::RSpec.current_example
|
|
32
|
+
return record_failure("Could not determine current RSpec example") unless example
|
|
33
|
+
|
|
34
|
+
test_class = example.example_group.description || "Anonymous"
|
|
35
|
+
test_method = example.description || "unknown"
|
|
36
|
+
|
|
37
|
+
snapshot_file = resolve_snapshot_file(example, test_class, test_method)
|
|
38
|
+
path = @name ? snapshot_file.named_path(@name) : snapshot_file.path_for
|
|
39
|
+
|
|
40
|
+
metadata = { "source" => "#{test_class} #{test_method}", "expression" => expression }
|
|
41
|
+
existing = snapshot_file.read(path)
|
|
42
|
+
|
|
43
|
+
return handle_new_snapshot(snapshot_file, path, content, metadata) unless existing
|
|
44
|
+
|
|
45
|
+
if existing.expression && existing.expression != expression
|
|
46
|
+
return handle_type_mismatch(snapshot_file, path, content, metadata, existing, expression, example)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
expected = SnapshotContent.normalize(existing.content)
|
|
50
|
+
return true if content == expected
|
|
51
|
+
|
|
52
|
+
caller_line = example.location
|
|
53
|
+
handle_mismatch(snapshot_file, path, content, metadata, expected, "#{test_class} #{test_method}", caller_line)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
#: () -> String
|
|
57
|
+
def description
|
|
58
|
+
@name ? "match snapshot \"#{@name}\"" : "match snapshot"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
#: (untyped, String, String) -> SnapshotFile
|
|
64
|
+
def resolve_snapshot_file(example, test_class, test_method)
|
|
65
|
+
if @name
|
|
66
|
+
SnapshotFile.new(Insta.configuration.snapshot_path, test_class, test_method)
|
|
67
|
+
else
|
|
68
|
+
example.metadata[:_insta_snapshot_file] ||= SnapshotFile.new(
|
|
69
|
+
Insta.configuration.snapshot_path,
|
|
70
|
+
test_class,
|
|
71
|
+
test_method
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
#: (SnapshotFile, Pathname, String, Snapshot::snapshot_metadata) -> bool
|
|
77
|
+
def handle_new_snapshot(snapshot_file, path, content, metadata)
|
|
78
|
+
if Insta.configuration.resolved_update_mode == :no
|
|
79
|
+
return record_failure("Snapshot file does not exist: #{path}\nRun with INSTA_UPDATE=force to create it.")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
if Insta.configuration.new_snapshot == :review
|
|
83
|
+
pending_path = snapshot_file.pending_path(path)
|
|
84
|
+
snapshot_file.write(pending_path, content, metadata)
|
|
85
|
+
|
|
86
|
+
example = ::RSpec.current_example
|
|
87
|
+
caller_line = example&.location
|
|
88
|
+
PendingLocations.add(pending_path.to_s, caller_line) if caller_line
|
|
89
|
+
|
|
90
|
+
return true if ENV["INSTA_FORCE_PASS"]
|
|
91
|
+
|
|
92
|
+
return record_failure("New snapshot pending review: #{path}\nRun `bundle exec insta review` to accept.")
|
|
93
|
+
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
snapshot_file.write(path, content, metadata)
|
|
97
|
+
|
|
98
|
+
true
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
#: (SnapshotFile, Pathname, String, Snapshot::snapshot_metadata, Snapshot, String?, untyped) -> bool
|
|
102
|
+
def handle_type_mismatch(snapshot_file, path, content, metadata, existing, expression, example)
|
|
103
|
+
expected = SnapshotContent.normalize(existing.content)
|
|
104
|
+
caller_line = example.location
|
|
105
|
+
result = SnapshotMismatchHandler.handle(snapshot_file, path, content, metadata, expected, caller_line: caller_line)
|
|
106
|
+
|
|
107
|
+
case result
|
|
108
|
+
when :updated, :force_passed
|
|
109
|
+
true
|
|
110
|
+
else
|
|
111
|
+
record_failure(
|
|
112
|
+
"Snapshot type mismatch: snapshot was #{existing.expression} but got #{expression}\n" \
|
|
113
|
+
"Run `bundle exec insta review` or delete the snapshot file and re-run the test to update it."
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
#: (SnapshotFile, Pathname, String, Snapshot::snapshot_metadata, String, String, String?) -> bool
|
|
119
|
+
def handle_mismatch(snapshot_file, path, content, metadata, expected, label, caller_line = nil)
|
|
120
|
+
result = SnapshotMismatchHandler.handle(snapshot_file, path, content, metadata, expected,
|
|
121
|
+
caller_line: caller_line)
|
|
122
|
+
|
|
123
|
+
case result
|
|
124
|
+
when :updated, :force_passed
|
|
125
|
+
true
|
|
126
|
+
else
|
|
127
|
+
@failure_message = SnapshotMismatchHandler.failure_message(expected, content, label, path,
|
|
128
|
+
caller_line: caller_line)
|
|
129
|
+
|
|
130
|
+
false
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
#: (String) -> false
|
|
135
|
+
def record_failure(msg)
|
|
136
|
+
@failure_message = msg
|
|
137
|
+
|
|
138
|
+
false
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
class MatchInlineSnapshot
|
|
143
|
+
include ::RSpec::Matchers::Composable
|
|
144
|
+
|
|
145
|
+
attr_reader :failure_message #: String?
|
|
146
|
+
|
|
147
|
+
#: (String?, ?serializer: Symbol?, ?redact: Hash[String, untyped]?) -> void
|
|
148
|
+
def initialize(expected = nil, serializer: nil, redact: nil)
|
|
149
|
+
@expected = expected
|
|
150
|
+
@serializer = serializer
|
|
151
|
+
@redact = redact
|
|
152
|
+
@failure_message = nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
#: (untyped) -> bool
|
|
156
|
+
def matches?(actual)
|
|
157
|
+
@actual = actual
|
|
158
|
+
|
|
159
|
+
actual = Redaction::Applicator.apply(actual, @redact) if @redact
|
|
160
|
+
serializer_name = @serializer || Insta.configuration.default_serializer
|
|
161
|
+
serialized = Serializers.serialize(serializer_name, actual)
|
|
162
|
+
content = SnapshotContent.normalize(serialized)
|
|
163
|
+
|
|
164
|
+
location = (caller_locations || []).find { |location| location.path && !location.path.include?("lib/insta") && !location.path.include?("lib/rspec") }
|
|
165
|
+
return record_failure("Could not determine caller location") unless location
|
|
166
|
+
|
|
167
|
+
file = location.path
|
|
168
|
+
line = location.lineno
|
|
169
|
+
|
|
170
|
+
return record_failure("Could not determine source file") unless file
|
|
171
|
+
return handle_new_inline_snapshot(file, line, content) if @expected.nil?
|
|
172
|
+
|
|
173
|
+
expected_normalized = SnapshotContent.normalize(@expected.to_s)
|
|
174
|
+
return true if content == expected_normalized
|
|
175
|
+
|
|
176
|
+
handle_inline_mismatch(file, line, content, expected_normalized)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
#: () -> String
|
|
180
|
+
def description
|
|
181
|
+
"match inline snapshot"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private
|
|
185
|
+
|
|
186
|
+
#: (String, Integer, String) -> bool
|
|
187
|
+
def handle_new_inline_snapshot(file, line, content)
|
|
188
|
+
if Insta.configuration.new_snapshot == :review
|
|
189
|
+
Inline::PendingStore.add(file: file, line: line, content: content, old_content: "", type: :insert)
|
|
190
|
+
|
|
191
|
+
return true if ENV["INSTA_FORCE_PASS"]
|
|
192
|
+
|
|
193
|
+
return record_failure("New inline snapshot pending review at #{file}:#{line}\nRun `bundle exec insta review` to accept.")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
Inline::PendingRegistry.add(file: file, line: line, content: content, type: :insert)
|
|
197
|
+
|
|
198
|
+
true
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
#: (String, Integer, String, String) -> bool
|
|
202
|
+
def handle_inline_mismatch(file, line, content, expected_normalized)
|
|
203
|
+
mode = Insta.configuration.resolved_update_mode
|
|
204
|
+
|
|
205
|
+
case mode
|
|
206
|
+
when :force
|
|
207
|
+
Inline::PendingRegistry.add(file: file, line: line, content: content, type: :replace)
|
|
208
|
+
|
|
209
|
+
true
|
|
210
|
+
when :no
|
|
211
|
+
@failure_message = Diff.failure_message(expected_normalized, content, "inline snapshot at #{file}:#{line}")
|
|
212
|
+
|
|
213
|
+
false
|
|
214
|
+
else
|
|
215
|
+
Inline::PendingStore.add(file: file, line: line, content: content, old_content: expected_normalized,
|
|
216
|
+
type: :replace)
|
|
217
|
+
|
|
218
|
+
if ENV["INSTA_FORCE_PASS"]
|
|
219
|
+
true
|
|
220
|
+
else
|
|
221
|
+
@failure_message = Diff.failure_message(expected_normalized, content,
|
|
222
|
+
"inline snapshot at #{file}:#{line}")
|
|
223
|
+
|
|
224
|
+
false
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
#: (String) -> false
|
|
230
|
+
def record_failure(msg)
|
|
231
|
+
@failure_message = msg
|
|
232
|
+
|
|
233
|
+
false
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
#: (?String?, ?serializer: Symbol?, ?redact: Hash[String, untyped]?) -> MatchSnapshot
|
|
238
|
+
def match_snapshot(name = nil, serializer: nil, redact: nil)
|
|
239
|
+
MatchSnapshot.new(name, serializer: serializer, redact: redact)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
#: (?String?, ?serializer: Symbol?, ?redact: Hash[String, untyped]?) -> MatchInlineSnapshot
|
|
243
|
+
def match_inline_snapshot(expected = nil, serializer: nil, redact: nil)
|
|
244
|
+
MatchInlineSnapshot.new(expected, serializer: serializer, redact: redact)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
data/lib/insta/rspec.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insta
|
|
4
|
+
module Serializers
|
|
5
|
+
class Base
|
|
6
|
+
#: (untyped) -> String
|
|
7
|
+
def self.serialize(value)
|
|
8
|
+
raise NotImplementedError, "#{name} must implement .serialize"
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
REGISTRY = {} #: Hash[Symbol, singleton(Base)] # rubocop:disable Style/MutableConstant
|
|
13
|
+
|
|
14
|
+
#: (Symbol, singleton(Base)) -> void
|
|
15
|
+
def self.register(name, serializer)
|
|
16
|
+
REGISTRY[name] = serializer
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
#: (Symbol) -> singleton(Base)
|
|
20
|
+
def self.fetch(name)
|
|
21
|
+
REGISTRY.fetch(name) do
|
|
22
|
+
raise ArgumentError, "Unknown serializer: #{name.inspect}. Available: #{REGISTRY.keys.join(", ")}"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
#: (Symbol, untyped) -> String
|
|
27
|
+
def self.serialize(name, value)
|
|
28
|
+
fetch(name).serialize(value)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Insta
|
|
6
|
+
module Serializers
|
|
7
|
+
class JSONSerializer < Base
|
|
8
|
+
#: (untyped) -> String
|
|
9
|
+
def self.serialize(value)
|
|
10
|
+
JSON.pretty_generate(value)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
register(:json, JSONSerializer)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Insta
|
|
6
|
+
module Serializers
|
|
7
|
+
class YAMLSerializer < Base
|
|
8
|
+
#: (untyped) -> String
|
|
9
|
+
def self.serialize(value)
|
|
10
|
+
YAML.dump(value)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
register(:yaml, YAMLSerializer)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Insta
|
|
6
|
+
class Snapshot
|
|
7
|
+
# @rbs!
|
|
8
|
+
# type snapshot_metadata = Hash[String, untyped]
|
|
9
|
+
|
|
10
|
+
SEPARATOR = "---" #: String
|
|
11
|
+
|
|
12
|
+
attr_reader :content #: String
|
|
13
|
+
attr_reader :metadata #: snapshot_metadata
|
|
14
|
+
|
|
15
|
+
#: (String, ?snapshot_metadata) -> void
|
|
16
|
+
def initialize(content, metadata = {})
|
|
17
|
+
@content = content
|
|
18
|
+
@metadata = metadata
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
#: (String) -> Snapshot
|
|
22
|
+
def self.parse(raw)
|
|
23
|
+
raw = raw.to_s
|
|
24
|
+
|
|
25
|
+
if raw.start_with?("#{SEPARATOR}\n")
|
|
26
|
+
parts = raw.split("#{SEPARATOR}\n", 3)
|
|
27
|
+
|
|
28
|
+
if parts.length >= 3
|
|
29
|
+
metadata = begin
|
|
30
|
+
YAML.safe_load(parts[1], permitted_classes: [Symbol]) || {}
|
|
31
|
+
rescue Psych::SyntaxError
|
|
32
|
+
{} #: snapshot_metadata
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
return new(parts[2].chomp.concat("\n"), metadata)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
new(raw, {})
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
#: () -> String
|
|
43
|
+
def serialize
|
|
44
|
+
return @content if @metadata.empty?
|
|
45
|
+
|
|
46
|
+
YAML.dump(@metadata) + "#{SEPARATOR}\n" + @content
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
#: () -> String?
|
|
50
|
+
def source
|
|
51
|
+
@metadata["source"]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
#: () -> String?
|
|
55
|
+
def input
|
|
56
|
+
@metadata["input"]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
#: () -> String?
|
|
60
|
+
def description
|
|
61
|
+
@metadata["description"]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
#: () -> String?
|
|
65
|
+
def expression
|
|
66
|
+
@metadata["expression"]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
#: () -> Hash[String, untyped]?
|
|
70
|
+
def info
|
|
71
|
+
@metadata["info"]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insta
|
|
4
|
+
module SnapshotContent
|
|
5
|
+
#: (String) -> String
|
|
6
|
+
def self.normalize(content)
|
|
7
|
+
content
|
|
8
|
+
.gsub("\r\n", "\n")
|
|
9
|
+
.gsub("\r", "\n")
|
|
10
|
+
.sub(/\n+\z/, "")
|
|
11
|
+
.concat("\n")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
#: (String, Integer) -> String
|
|
15
|
+
def self.indent(content, level)
|
|
16
|
+
prefix = " " * level
|
|
17
|
+
content.each_line.map { |line|
|
|
18
|
+
if line.strip.empty?
|
|
19
|
+
"\n"
|
|
20
|
+
else
|
|
21
|
+
"#{prefix}#{line}"
|
|
22
|
+
end
|
|
23
|
+
}.join
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
#: (String) -> String
|
|
27
|
+
def self.strip_indent(content)
|
|
28
|
+
lines = content.split("\n", -1)
|
|
29
|
+
non_empty = lines.reject { |l| l.strip.empty? }
|
|
30
|
+
return content if non_empty.empty?
|
|
31
|
+
|
|
32
|
+
min_indent = non_empty.map { |l| l.match(/^(\s*)/).to_a[1].to_s.length }.min || 0
|
|
33
|
+
return content if min_indent.zero?
|
|
34
|
+
|
|
35
|
+
lines.map { |l|
|
|
36
|
+
if l.strip.empty?
|
|
37
|
+
""
|
|
38
|
+
else
|
|
39
|
+
l[min_indent..] || ""
|
|
40
|
+
end
|
|
41
|
+
}.join("\n")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|