backspin 0.2.1 → 0.4.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/.circleci/config.yml +31 -0
- data/.gitignore +1 -4
- data/CHANGELOG.md +6 -0
- data/CLAUDE.md +4 -5
- data/CONTRIBUTING.md +222 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +87 -0
- data/README.md +108 -34
- data/backspin.gemspec +4 -4
- data/bin/rake +27 -0
- data/bin/rspec +27 -0
- data/bin/setup +1 -3
- data/lib/backspin/command.rb +49 -12
- data/lib/backspin/command_diff.rb +88 -0
- data/lib/backspin/command_result.rb +60 -0
- data/lib/backspin/record.rb +2 -2
- data/lib/backspin/record_result.rb +153 -0
- data/lib/backspin/recorder.rb +4 -23
- data/lib/backspin/version.rb +1 -1
- data/lib/backspin.rb +170 -285
- metadata +17 -10
data/bin/rspec
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
#
|
5
|
+
# This file was generated by Bundler.
|
6
|
+
#
|
7
|
+
# The application 'rspec' is installed as part of a gem, and
|
8
|
+
# this file is here to facilitate running it.
|
9
|
+
#
|
10
|
+
|
11
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
|
12
|
+
|
13
|
+
bundle_binstub = File.expand_path("bundle", __dir__)
|
14
|
+
|
15
|
+
if File.file?(bundle_binstub)
|
16
|
+
if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
|
17
|
+
load(bundle_binstub)
|
18
|
+
else
|
19
|
+
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
20
|
+
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
require "rubygems"
|
25
|
+
require "bundler/setup"
|
26
|
+
|
27
|
+
load Gem.bin_path("rspec-core", "rspec")
|
data/bin/setup
CHANGED
data/lib/backspin/command.rb
CHANGED
@@ -1,31 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "command_result"
|
4
|
+
|
1
5
|
module Backspin
|
2
6
|
class Command
|
3
|
-
attr_reader :args, :
|
7
|
+
attr_reader :args, :result, :recorded_at, :method_class
|
4
8
|
|
5
|
-
def initialize(method_class:, args:, stdout: nil, stderr: nil, status: nil, recorded_at: nil)
|
9
|
+
def initialize(method_class:, args:, stdout: nil, stderr: nil, status: nil, result: nil, recorded_at: nil)
|
6
10
|
@method_class = method_class
|
7
11
|
@args = args
|
8
|
-
@stdout = stdout
|
9
|
-
@stderr = stderr
|
10
|
-
@status = status
|
11
12
|
@recorded_at = recorded_at
|
13
|
+
|
14
|
+
# Accept either a CommandResult or individual stdout/stderr/status
|
15
|
+
@result = result || CommandResult.new(
|
16
|
+
stdout: stdout || "",
|
17
|
+
stderr: stderr || "",
|
18
|
+
status: status || 0
|
19
|
+
)
|
20
|
+
end
|
21
|
+
|
22
|
+
def stdout
|
23
|
+
@result.stdout
|
24
|
+
end
|
25
|
+
|
26
|
+
def stderr
|
27
|
+
@result.stderr
|
28
|
+
end
|
29
|
+
|
30
|
+
def status
|
31
|
+
@result.status
|
12
32
|
end
|
13
33
|
|
14
34
|
# Convert to hash for YAML serialization
|
15
35
|
def to_h(filter: nil)
|
16
36
|
data = {
|
17
37
|
"command_type" => @method_class.name,
|
18
|
-
"args" => @args,
|
19
|
-
"stdout" => Backspin.scrub_text(@stdout),
|
20
|
-
"stderr" => Backspin.scrub_text(@stderr),
|
21
|
-
"status" => @status,
|
38
|
+
"args" => scrub_args(@args),
|
39
|
+
"stdout" => Backspin.scrub_text(@result.stdout),
|
40
|
+
"stderr" => Backspin.scrub_text(@result.stderr),
|
41
|
+
"status" => @result.status,
|
22
42
|
"recorded_at" => @recorded_at
|
23
43
|
}
|
24
44
|
|
25
45
|
# Apply filter if provided
|
26
|
-
if filter
|
27
|
-
data = filter.call(data)
|
28
|
-
end
|
46
|
+
data = filter.call(data) if filter
|
29
47
|
|
30
48
|
data
|
31
49
|
end
|
@@ -52,6 +70,25 @@ module Backspin
|
|
52
70
|
recorded_at: data["recorded_at"]
|
53
71
|
)
|
54
72
|
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
def scrub_args(args)
|
77
|
+
return args unless Backspin.configuration.scrub_credentials && args
|
78
|
+
|
79
|
+
args.map do |arg|
|
80
|
+
case arg
|
81
|
+
when String
|
82
|
+
Backspin.scrub_text(arg)
|
83
|
+
when Array
|
84
|
+
scrub_args(arg)
|
85
|
+
when Hash
|
86
|
+
arg.transform_values { |v| v.is_a?(String) ? Backspin.scrub_text(v) : v }
|
87
|
+
else
|
88
|
+
arg
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
55
92
|
end
|
56
93
|
end
|
57
94
|
|
@@ -0,0 +1,88 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Backspin
|
4
|
+
# Represents the difference between a recorded command and actual execution
|
5
|
+
# Handles verification and diff generation for a single command
|
6
|
+
class CommandDiff
|
7
|
+
attr_reader :recorded_command, :actual_result, :matcher
|
8
|
+
|
9
|
+
def initialize(recorded_command:, actual_result:, matcher: nil)
|
10
|
+
@recorded_command = recorded_command
|
11
|
+
@actual_result = actual_result
|
12
|
+
@matcher = matcher
|
13
|
+
end
|
14
|
+
|
15
|
+
# @return [Boolean] true if the command output matches
|
16
|
+
def verified?
|
17
|
+
if matcher
|
18
|
+
matcher.call(recorded_command.to_h, actual_result.to_h)
|
19
|
+
else
|
20
|
+
recorded_command.result == actual_result
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# @return [String, nil] Human-readable diff if not verified
|
25
|
+
def diff
|
26
|
+
return nil if verified?
|
27
|
+
|
28
|
+
parts = []
|
29
|
+
|
30
|
+
parts << stdout_diff if recorded_command.stdout != actual_result.stdout
|
31
|
+
|
32
|
+
parts << stderr_diff if recorded_command.stderr != actual_result.stderr
|
33
|
+
|
34
|
+
if recorded_command.status != actual_result.status
|
35
|
+
parts << "Exit status: expected #{recorded_command.status}, got #{actual_result.status}"
|
36
|
+
end
|
37
|
+
|
38
|
+
parts.join("\n\n")
|
39
|
+
end
|
40
|
+
|
41
|
+
# @return [String] Single line summary for error messages
|
42
|
+
def summary
|
43
|
+
if verified?
|
44
|
+
"✓ Command verified"
|
45
|
+
else
|
46
|
+
"✗ Command failed: #{failure_reason}"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def failure_reason
|
53
|
+
reasons = []
|
54
|
+
reasons << "stdout differs" if recorded_command.stdout != actual_result.stdout
|
55
|
+
reasons << "stderr differs" if recorded_command.stderr != actual_result.stderr
|
56
|
+
reasons << "exit status differs" if recorded_command.status != actual_result.status
|
57
|
+
reasons.join(", ")
|
58
|
+
end
|
59
|
+
|
60
|
+
def stdout_diff
|
61
|
+
"stdout diff:\n#{generate_line_diff(recorded_command.stdout, actual_result.stdout)}"
|
62
|
+
end
|
63
|
+
|
64
|
+
def stderr_diff
|
65
|
+
"stderr diff:\n#{generate_line_diff(recorded_command.stderr, actual_result.stderr)}"
|
66
|
+
end
|
67
|
+
|
68
|
+
def generate_line_diff(expected, actual)
|
69
|
+
expected_lines = (expected || "").lines
|
70
|
+
actual_lines = (actual || "").lines
|
71
|
+
|
72
|
+
diff_lines = []
|
73
|
+
max_lines = [expected_lines.length, actual_lines.length].max
|
74
|
+
|
75
|
+
max_lines.times do |i|
|
76
|
+
expected_line = expected_lines[i]
|
77
|
+
actual_line = actual_lines[i]
|
78
|
+
|
79
|
+
if expected_line != actual_line
|
80
|
+
diff_lines << "-#{expected_line.chomp}" if expected_line
|
81
|
+
diff_lines << "+#{actual_line.chomp}" if actual_line
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
diff_lines.join("\n")
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Backspin
|
4
|
+
# Represents the result of executing a command
|
5
|
+
# Stores stdout, stderr, and exit status
|
6
|
+
class CommandResult
|
7
|
+
attr_reader :stdout, :stderr, :status
|
8
|
+
|
9
|
+
def initialize(stdout:, stderr:, status:)
|
10
|
+
@stdout = stdout
|
11
|
+
@stderr = stderr
|
12
|
+
@status = normalize_status(status)
|
13
|
+
end
|
14
|
+
|
15
|
+
# @return [Boolean] true if the command succeeded (exit status 0)
|
16
|
+
def success?
|
17
|
+
status.zero?
|
18
|
+
end
|
19
|
+
|
20
|
+
# @return [Boolean] true if the command failed (non-zero exit status)
|
21
|
+
def failure?
|
22
|
+
!success?
|
23
|
+
end
|
24
|
+
|
25
|
+
# @return [Hash] Hash representation of the result
|
26
|
+
def to_h
|
27
|
+
{
|
28
|
+
"stdout" => stdout,
|
29
|
+
"stderr" => stderr,
|
30
|
+
"status" => status
|
31
|
+
}
|
32
|
+
end
|
33
|
+
|
34
|
+
# Compare two results for equality
|
35
|
+
def ==(other)
|
36
|
+
return false unless other.is_a?(CommandResult)
|
37
|
+
|
38
|
+
stdout == other.stdout &&
|
39
|
+
stderr == other.stderr &&
|
40
|
+
status == other.status
|
41
|
+
end
|
42
|
+
|
43
|
+
def inspect
|
44
|
+
"#<Backspin::CommandResult status=#{status} stdout=#{stdout.inspect.truncate(50)} stderr=#{stderr.inspect.truncate(50)}>"
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def normalize_status(status)
|
50
|
+
case status
|
51
|
+
when Integer
|
52
|
+
status
|
53
|
+
when Process::Status
|
54
|
+
status.exitstatus
|
55
|
+
else
|
56
|
+
status.respond_to?(:exitstatus) ? status.exitstatus : status.to_i
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
data/lib/backspin/record.rb
CHANGED
@@ -4,6 +4,7 @@ module Backspin
|
|
4
4
|
class NoMoreRecordingsError < StandardError; end
|
5
5
|
|
6
6
|
class Record
|
7
|
+
FORMAT_VERSION = "2.0"
|
7
8
|
attr_reader :path, :commands, :first_recorded_at
|
8
9
|
|
9
10
|
def initialize(path)
|
@@ -22,10 +23,9 @@ module Backspin
|
|
22
23
|
|
23
24
|
def save(filter: nil)
|
24
25
|
FileUtils.mkdir_p(File.dirname(@path))
|
25
|
-
# New format: top-level metadata with commands array
|
26
26
|
record_data = {
|
27
27
|
"first_recorded_at" => @first_recorded_at,
|
28
|
-
"format_version" =>
|
28
|
+
"format_version" => FORMAT_VERSION,
|
29
29
|
"commands" => @commands.map { |cmd| cmd.to_h(filter: filter) }
|
30
30
|
}
|
31
31
|
File.write(@path, record_data.to_yaml)
|
@@ -0,0 +1,153 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Backspin
|
4
|
+
# Result object for all Backspin record operations
|
5
|
+
# Provides a consistent interface whether recording, verifying, or playing back
|
6
|
+
class RecordResult
|
7
|
+
attr_reader :output, :record_path, :commands, :mode, :command_diffs
|
8
|
+
|
9
|
+
def initialize(output:, mode:, record_path:, commands:, verified: nil, command_diffs: nil)
|
10
|
+
@output = output
|
11
|
+
@mode = mode
|
12
|
+
@record_path = record_path
|
13
|
+
@commands = commands
|
14
|
+
@verified = verified
|
15
|
+
@command_diffs = command_diffs || []
|
16
|
+
end
|
17
|
+
|
18
|
+
# @return [Boolean] true if this result is from recording
|
19
|
+
def recorded?
|
20
|
+
mode == :record
|
21
|
+
end
|
22
|
+
|
23
|
+
# @return [Boolean, nil] true/false for verification results, nil for recording
|
24
|
+
def verified?
|
25
|
+
@verified
|
26
|
+
end
|
27
|
+
|
28
|
+
# @return [Boolean] true if this result is from playback mode
|
29
|
+
def playback?
|
30
|
+
mode == :playback
|
31
|
+
end
|
32
|
+
|
33
|
+
# @return [String, nil] Human-readable error message if verification failed
|
34
|
+
def error_message
|
35
|
+
return nil unless verified? == false
|
36
|
+
return "No commands to verify" if command_diffs.empty?
|
37
|
+
|
38
|
+
failed_diffs = command_diffs.reject(&:verified?)
|
39
|
+
return "All commands verified" if failed_diffs.empty?
|
40
|
+
|
41
|
+
msg = "Output verification failed for #{failed_diffs.size} command(s):\n\n"
|
42
|
+
|
43
|
+
command_diffs.each_with_index do |diff, idx|
|
44
|
+
next if diff.verified?
|
45
|
+
|
46
|
+
msg += "Command #{idx + 1}: #{diff.summary}\n"
|
47
|
+
msg += diff.diff
|
48
|
+
msg += "\n\n" if idx < command_diffs.size - 1
|
49
|
+
end
|
50
|
+
|
51
|
+
msg
|
52
|
+
end
|
53
|
+
|
54
|
+
# @return [String, nil] Combined diff from all failed commands
|
55
|
+
def diff
|
56
|
+
return nil if command_diffs.empty?
|
57
|
+
|
58
|
+
failed_diffs = command_diffs.reject(&:verified?)
|
59
|
+
return nil if failed_diffs.empty?
|
60
|
+
|
61
|
+
diff_parts = []
|
62
|
+
command_diffs.each_with_index do |cmd_diff, idx|
|
63
|
+
diff_parts << "Command #{idx + 1}:\n#{cmd_diff.diff}" unless cmd_diff.verified?
|
64
|
+
end
|
65
|
+
|
66
|
+
diff_parts.join("\n\n")
|
67
|
+
end
|
68
|
+
|
69
|
+
# Convenience accessors for command output
|
70
|
+
# For single command (common case), these provide direct access
|
71
|
+
# For multiple commands, use all_stdout, all_stderr, etc.
|
72
|
+
|
73
|
+
# @return [String, nil] stdout from the first command
|
74
|
+
def stdout
|
75
|
+
commands.first&.result&.stdout
|
76
|
+
end
|
77
|
+
|
78
|
+
# @return [String, nil] stderr from the first command
|
79
|
+
def stderr
|
80
|
+
commands.first&.result&.stderr
|
81
|
+
end
|
82
|
+
|
83
|
+
# @return [Integer, nil] exit status from the first command
|
84
|
+
def status
|
85
|
+
commands.first&.result&.status
|
86
|
+
end
|
87
|
+
|
88
|
+
# Multiple command accessors
|
89
|
+
|
90
|
+
# @return [Array<String>] stdout from all commands
|
91
|
+
def all_stdout
|
92
|
+
commands.map { |cmd| cmd.result.stdout }
|
93
|
+
end
|
94
|
+
|
95
|
+
# @return [Array<String>] stderr from all commands
|
96
|
+
def all_stderr
|
97
|
+
commands.map { |cmd| cmd.result.stderr }
|
98
|
+
end
|
99
|
+
|
100
|
+
# @return [Array<Integer>] exit status from all commands
|
101
|
+
def all_status
|
102
|
+
commands.map { |cmd| cmd.result.status }
|
103
|
+
end
|
104
|
+
|
105
|
+
# @return [Boolean] true if this result contains multiple commands
|
106
|
+
def multiple_commands?
|
107
|
+
commands.size > 1
|
108
|
+
end
|
109
|
+
|
110
|
+
# @return [Boolean] true if all commands succeeded (exit status 0)
|
111
|
+
def success?
|
112
|
+
if multiple_commands?
|
113
|
+
# Check all commands - if any command has non-zero status, we're not successful
|
114
|
+
commands.all? { |cmd| cmd.result.status.zero? }
|
115
|
+
else
|
116
|
+
status&.zero? || false
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
# @return [Boolean] true if any command failed (non-zero exit status)
|
121
|
+
def failure?
|
122
|
+
!success?
|
123
|
+
end
|
124
|
+
|
125
|
+
# @return [Hash] Summary of the result for debugging
|
126
|
+
def to_h
|
127
|
+
hash = {
|
128
|
+
mode: mode,
|
129
|
+
recorded: recorded?,
|
130
|
+
playback: playback?,
|
131
|
+
stdout: stdout,
|
132
|
+
stderr: stderr,
|
133
|
+
status: status,
|
134
|
+
record_path: record_path.to_s
|
135
|
+
}
|
136
|
+
|
137
|
+
# Only include verified if it's not nil
|
138
|
+
hash[:verified] = verified? unless verified?.nil?
|
139
|
+
|
140
|
+
# Only include diff if present
|
141
|
+
hash[:diff] = diff if diff
|
142
|
+
|
143
|
+
# Include number of failed commands if in verify mode
|
144
|
+
hash[:failed_commands] = command_diffs.count { |d| !d.verified? } if mode == :verify && command_diffs.any?
|
145
|
+
|
146
|
+
hash
|
147
|
+
end
|
148
|
+
|
149
|
+
def inspect
|
150
|
+
"#<Backspin::RecordResult mode=#{mode} verified=#{verified?.inspect} status=#{status}>"
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
data/lib/backspin/recorder.rb
CHANGED
@@ -6,6 +6,7 @@ module Backspin
|
|
6
6
|
# Handles stubbing and recording of command executions
|
7
7
|
class Recorder
|
8
8
|
include RSpec::Mocks::ExampleMethods
|
9
|
+
SUPPORTED_COMMAND_TYPES = [:capture3, :system]
|
9
10
|
|
10
11
|
attr_reader :commands, :verification_data, :mode, :record
|
11
12
|
|
@@ -17,7 +18,7 @@ module Backspin
|
|
17
18
|
end
|
18
19
|
|
19
20
|
def record_calls(*command_types)
|
20
|
-
command_types =
|
21
|
+
command_types = SUPPORTED_COMMAND_TYPES if command_types.empty?
|
21
22
|
|
22
23
|
command_types.each do |command_type|
|
23
24
|
record_call(command_type)
|
@@ -31,7 +32,7 @@ module Backspin
|
|
31
32
|
when :capture3
|
32
33
|
setup_capture3_call_stub
|
33
34
|
else
|
34
|
-
raise ArgumentError, "
|
35
|
+
raise ArgumentError, "Unsupported command type: #{command_type} - currently supported types: #{SUPPORTED_COMMAND_TYPES.join(", ")}"
|
35
36
|
end
|
36
37
|
end
|
37
38
|
|
@@ -66,7 +67,6 @@ module Backspin
|
|
66
67
|
# For system calls, we only track the exit status
|
67
68
|
@verification_data["stdout"] = ""
|
68
69
|
@verification_data["stderr"] = ""
|
69
|
-
# Derive exit status from result: true = 0, false = non-zero
|
70
70
|
@verification_data["status"] = result ? 0 : 1
|
71
71
|
|
72
72
|
result
|
@@ -107,12 +107,10 @@ module Backspin
|
|
107
107
|
allow_any_instance_of(Object).to receive(:system) do |receiver, *args|
|
108
108
|
command = @record.next_command
|
109
109
|
|
110
|
-
# Make sure this is a system command
|
111
110
|
unless command.method_class == ::Kernel::System
|
112
111
|
raise RecordNotFoundError, "Expected Kernel::System command but got #{command.method_class.name}"
|
113
112
|
end
|
114
113
|
|
115
|
-
# Return true if exit status was 0, false otherwise
|
116
114
|
command.status == 0
|
117
115
|
rescue NoMoreRecordingsError => e
|
118
116
|
raise RecordNotFoundError, e.message
|
@@ -121,17 +119,14 @@ module Backspin
|
|
121
119
|
|
122
120
|
def setup_capture3_call_stub
|
123
121
|
allow(Open3).to receive(:capture3).and_wrap_original do |original_method, *args|
|
124
|
-
# Execute the real command
|
125
122
|
stdout, stderr, status = original_method.call(*args)
|
126
123
|
|
127
|
-
# Parse command args
|
128
124
|
cmd_args = if args.length == 1 && args.first.is_a?(String)
|
129
125
|
args.first.split(" ")
|
130
126
|
else
|
131
127
|
args
|
132
128
|
end
|
133
129
|
|
134
|
-
# Create command with interaction data
|
135
130
|
command = Command.new(
|
136
131
|
method_class: Open3::Capture3,
|
137
132
|
args: cmd_args,
|
@@ -142,17 +137,12 @@ module Backspin
|
|
142
137
|
)
|
143
138
|
@commands << command
|
144
139
|
|
145
|
-
# Store output for later access (last one wins)
|
146
|
-
Backspin.last_output = stdout
|
147
|
-
|
148
|
-
# Return original result
|
149
140
|
[stdout, stderr, status]
|
150
141
|
end
|
151
142
|
end
|
152
143
|
|
153
144
|
def setup_system_call_stub
|
154
145
|
allow_any_instance_of(Object).to receive(:system).and_wrap_original do |original_method, receiver, *args|
|
155
|
-
# Execute the real system call
|
156
146
|
result = original_method.call(receiver, *args)
|
157
147
|
|
158
148
|
# Parse command args based on how system was called
|
@@ -164,14 +154,9 @@ module Backspin
|
|
164
154
|
args
|
165
155
|
end
|
166
156
|
|
167
|
-
|
168
|
-
# The caller of system() doesn't have access to them
|
169
|
-
stdout = ""
|
170
|
-
stderr = ""
|
171
|
-
# Derive exit status from result: true = 0, false = non-zero, nil = command failed
|
157
|
+
stdout, stderr = "", ""
|
172
158
|
status = result ? 0 : 1
|
173
159
|
|
174
|
-
# Create command with interaction data
|
175
160
|
command = Command.new(
|
176
161
|
method_class: ::Kernel::System,
|
177
162
|
args: parsed_args,
|
@@ -182,10 +167,6 @@ module Backspin
|
|
182
167
|
)
|
183
168
|
@commands << command
|
184
169
|
|
185
|
-
# Store output for later access (for consistency with capture3)
|
186
|
-
Backspin.last_output = stdout
|
187
|
-
|
188
|
-
# Return the original result (true/false/nil)
|
189
170
|
result
|
190
171
|
end
|
191
172
|
end
|
data/lib/backspin/version.rb
CHANGED