btape 0.1.0 → 0.2.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 +140 -8
- data/lib/btape/cli.rb +101 -11
- data/lib/btape/duration.rb +26 -0
- data/lib/btape/error.rb +4 -0
- data/lib/btape/executor.rb +186 -0
- data/lib/btape/gif_encoder.rb +88 -27
- data/lib/btape/null_logger.rb +14 -0
- data/lib/btape/palette.rb +203 -0
- data/lib/btape/parser.rb +39 -4
- data/lib/btape/recorder.rb +62 -9
- data/lib/btape/result.rb +18 -0
- data/lib/btape/runner.rb +143 -59
- data/lib/btape/settings.rb +151 -0
- data/lib/btape/version.rb +1 -1
- data/lib/btape.rb +6 -0
- metadata +26 -1
data/lib/btape/runner.rb
CHANGED
|
@@ -2,39 +2,85 @@
|
|
|
2
2
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
require 'ferrum'
|
|
5
|
+
require 'monitor'
|
|
6
|
+
require 'timeout'
|
|
5
7
|
require 'tmpdir'
|
|
6
8
|
|
|
7
9
|
module Btape
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
+
# The recording session around a script: opens the browser, starts the
|
|
11
|
+
# recorder, hands the commands to Executor, and turns the captured frames
|
|
12
|
+
# into a GIF.
|
|
10
13
|
class Runner
|
|
11
14
|
DEFAULT_VIEWPORT = [1280, 720].freeze
|
|
12
15
|
|
|
16
|
+
# How long unwinding gets. Set Timeout bounds the run, but the thing that
|
|
17
|
+
# usually trips it is a wedged browser, and both stopping the recorder and
|
|
18
|
+
# quitting the browser then talk to it — so without a deadline of their
|
|
19
|
+
# own they would hang the process after the timeout had already fired.
|
|
20
|
+
CLEANUP_TIMEOUT = 10
|
|
21
|
+
|
|
22
|
+
# Everything one run threads through recording, kept in one object rather
|
|
23
|
+
# than a parameter list that grows with each new command.
|
|
24
|
+
Context = Struct.new(:commands, :directory, :settings, :geometry, :sink, :output_path, :on_frame, :keep_frames,
|
|
25
|
+
keyword_init: true)
|
|
26
|
+
|
|
27
|
+
# An encoder passed here is used as given; otherwise one is built from the
|
|
28
|
+
# settings the tape and the caller supplied.
|
|
13
29
|
def initialize(browser_factory: lambda { |options|
|
|
14
30
|
Ferrum::Browser.new(**options)
|
|
15
|
-
}, recorder_class: Recorder, gif_encoder:
|
|
31
|
+
}, recorder_class: Recorder, gif_encoder: nil, logger: NullLogger.new)
|
|
16
32
|
@browser_factory = browser_factory
|
|
17
33
|
@recorder_class = recorder_class
|
|
18
34
|
@gif_encoder = gif_encoder
|
|
35
|
+
@logger = logger
|
|
19
36
|
end
|
|
20
37
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
38
|
+
# Returns a Result. Pass `frames_directory:` to keep the PNG frames after
|
|
39
|
+
# the run, `on_frame:` to be handed each one as it is captured, and
|
|
40
|
+
# `output:` to override the tape's Output with another path or with an IO
|
|
41
|
+
# to write the GIF into.
|
|
42
|
+
def run(commands, base_directory: Dir.pwd, settings: {}, frames_directory: nil, on_frame: nil, output: nil)
|
|
43
|
+
settings = Settings.from_commands(commands).merge(settings)
|
|
44
|
+
sink, output_path = resolve_output(commands, base_directory, output)
|
|
45
|
+
context = Context.new(
|
|
46
|
+
commands: commands,
|
|
47
|
+
settings: settings,
|
|
48
|
+
geometry: resolve_viewport(commands),
|
|
49
|
+
sink: sink,
|
|
50
|
+
output_path: output_path,
|
|
51
|
+
on_frame: on_frame,
|
|
52
|
+
keep_frames: !frames_directory.nil?
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
within_frames_directory(frames_directory, base_directory) do |directory|
|
|
56
|
+
context.directory = directory
|
|
57
|
+
record(context)
|
|
58
|
+
end
|
|
27
59
|
end
|
|
28
60
|
|
|
29
61
|
private
|
|
30
62
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
63
|
+
# Frames go to a temporary directory that is cleaned up as the run
|
|
64
|
+
# unwinds, unless the caller named a directory to keep them in.
|
|
65
|
+
def within_frames_directory(frames_directory, base_directory, &block)
|
|
66
|
+
return Dir.mktmpdir('btape-', &block) unless frames_directory
|
|
67
|
+
|
|
68
|
+
directory = File.expand_path(frames_directory, base_directory)
|
|
69
|
+
FileUtils.mkdir_p(directory)
|
|
70
|
+
block.call(directory)
|
|
71
|
+
end
|
|
34
72
|
|
|
35
|
-
|
|
73
|
+
# Returns where the GIF goes and, when that is a file, its path. Writing
|
|
74
|
+
# into an IO leaves no path behind, so Result reports nil rather than a
|
|
75
|
+
# path nothing was written to.
|
|
76
|
+
def resolve_output(commands, base_directory, override)
|
|
77
|
+
declared = commands.find { |command| command.name == 'Output' }&.arguments&.first
|
|
78
|
+
raise Error, 'script must contain an Output command' unless declared
|
|
79
|
+
return [override, nil] if override.respond_to?(:write)
|
|
80
|
+
|
|
81
|
+
path = File.expand_path(override || declared, base_directory)
|
|
36
82
|
FileUtils.mkdir_p(File.dirname(path))
|
|
37
|
-
path
|
|
83
|
+
[path, path]
|
|
38
84
|
end
|
|
39
85
|
|
|
40
86
|
def resolve_viewport(commands)
|
|
@@ -42,70 +88,108 @@ module Btape
|
|
|
42
88
|
viewport ? viewport.split('x').map(&:to_i) : DEFAULT_VIEWPORT
|
|
43
89
|
end
|
|
44
90
|
|
|
45
|
-
def record(
|
|
91
|
+
def record(context)
|
|
46
92
|
browser = nil
|
|
47
93
|
recorder = nil
|
|
94
|
+
lock = Monitor.new
|
|
48
95
|
begin
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
ensure
|
|
56
|
-
begin
|
|
57
|
-
recorder&.stop
|
|
58
|
-
rescue StandardError
|
|
59
|
-
nil
|
|
96
|
+
timed(context.settings.timeout) do
|
|
97
|
+
browser = open_browser(context.settings, context.geometry)
|
|
98
|
+
recorder = build_recorder(browser, context, lock)
|
|
99
|
+
recorder.start
|
|
100
|
+
execute(context, browser, recorder, lock)
|
|
101
|
+
recorder.stop
|
|
60
102
|
end
|
|
61
|
-
browser
|
|
103
|
+
# Outside the deadline: it is there to bound the browser session, and
|
|
104
|
+
# interrupting a write part-way through would leave a truncated GIF at
|
|
105
|
+
# the output path that nothing downstream could tell from a whole one.
|
|
106
|
+
encoder(context.settings).write(recorder.paths, context.sink)
|
|
107
|
+
result(context, recorder)
|
|
108
|
+
ensure
|
|
109
|
+
cleanup(recorder, browser)
|
|
62
110
|
end
|
|
63
111
|
end
|
|
64
112
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
113
|
+
# A page that never finishes loading would otherwise record until the
|
|
114
|
+
# frame limit or the disk stopped it.
|
|
115
|
+
def timed(seconds, &)
|
|
116
|
+
Timeout.timeout(seconds, TimeoutError, "run timed out after #{seconds}s", &)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def execute(context, browser, recorder, lock)
|
|
120
|
+
Executor.new(
|
|
121
|
+
browser: browser, recorder: recorder, settings: context.settings, lock: lock, logger: @logger
|
|
122
|
+
).call(context.commands)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Giving up on the cleanup is not a failure worth raising: the run has
|
|
126
|
+
# already produced its answer, or its exception, and this is only the
|
|
127
|
+
# tidying afterwards.
|
|
128
|
+
def cleanup(recorder, browser)
|
|
129
|
+
Timeout.timeout(CLEANUP_TIMEOUT) { unwind(recorder, browser) }
|
|
130
|
+
rescue Timeout::Error
|
|
131
|
+
@logger.warn("btape: cleanup did not finish within #{CLEANUP_TIMEOUT}s")
|
|
71
132
|
end
|
|
72
133
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
134
|
+
# stop and quit are reached again here after they have already run, which
|
|
135
|
+
# is a no-op; the case that matters is the run failing before them. A
|
|
136
|
+
# failure to unwind must not replace the exception on its way out, since
|
|
137
|
+
# that is the one that says why the run failed — so it is logged, not
|
|
138
|
+
# swallowed and not raised.
|
|
139
|
+
def unwind(recorder, browser)
|
|
140
|
+
recorder&.stop
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
@logger.warn("btape: could not stop the recorder: #{e.message}")
|
|
143
|
+
ensure
|
|
144
|
+
begin
|
|
145
|
+
browser&.quit
|
|
146
|
+
rescue StandardError => e
|
|
147
|
+
@logger.warn("btape: could not quit the browser: #{e.message}")
|
|
80
148
|
end
|
|
81
149
|
end
|
|
82
150
|
|
|
83
|
-
def
|
|
84
|
-
|
|
85
|
-
element.focus
|
|
86
|
-
element.type(arguments.last)
|
|
151
|
+
def encoder(settings)
|
|
152
|
+
@gif_encoder || GifEncoder.for(settings)
|
|
87
153
|
end
|
|
88
154
|
|
|
89
|
-
def
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
155
|
+
def build_recorder(browser, context, lock)
|
|
156
|
+
@recorder_class.new(
|
|
157
|
+
browser,
|
|
158
|
+
context.directory,
|
|
159
|
+
interval: 1.0 / context.settings.framerate,
|
|
160
|
+
mode: context.settings.capture_mode,
|
|
161
|
+
on_frame: context.on_frame,
|
|
162
|
+
max_frames: context.settings.max_frames,
|
|
163
|
+
lock: lock
|
|
164
|
+
)
|
|
97
165
|
end
|
|
98
166
|
|
|
99
|
-
def
|
|
100
|
-
|
|
167
|
+
def result(context, recorder)
|
|
168
|
+
width, height = context.geometry
|
|
169
|
+
Result.new(
|
|
170
|
+
output_path: context.output_path,
|
|
171
|
+
frame_paths: context.keep_frames ? recorder.paths.dup : [],
|
|
172
|
+
named_frames: context.keep_frames ? recorder.named_paths.dup : {},
|
|
173
|
+
frame_count: recorder.paths.length,
|
|
174
|
+
width: width,
|
|
175
|
+
height: height
|
|
176
|
+
)
|
|
177
|
+
end
|
|
101
178
|
|
|
102
|
-
|
|
103
|
-
|
|
179
|
+
def open_browser(settings, geometry)
|
|
180
|
+
width, height = geometry
|
|
181
|
+
browser = @browser_factory.call(browser_options(settings, geometry))
|
|
182
|
+
# window_size only ever reaches Chrome as a launch flag, so a browser we
|
|
183
|
+
# connected to over ws_url keeps whatever size it was started with and
|
|
184
|
+
# has to be resized over the wire instead.
|
|
185
|
+
browser.resize(width: width, height: height) if settings.ws_url
|
|
186
|
+
browser
|
|
104
187
|
end
|
|
105
188
|
|
|
106
|
-
def
|
|
107
|
-
|
|
108
|
-
|
|
189
|
+
def browser_options(settings, geometry)
|
|
190
|
+
return { ws_url: settings.ws_url } if settings.ws_url
|
|
191
|
+
|
|
192
|
+
{ window_size: geometry }
|
|
109
193
|
end
|
|
110
194
|
end
|
|
111
195
|
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'duration'
|
|
4
|
+
|
|
5
|
+
module Btape
|
|
6
|
+
# The knobs a script turns with `Set NAME VALUE`, along with their defaults,
|
|
7
|
+
# their coercions, and the validation the parser calls while reading a tape.
|
|
8
|
+
#
|
|
9
|
+
# Values arrive from three places, in increasing order of precedence: the
|
|
10
|
+
# defaults here, the `Set` lines in the tape, and whatever the caller passes
|
|
11
|
+
# to Runner#run or the CLI passes from its flags.
|
|
12
|
+
class Settings
|
|
13
|
+
CAPTURE_MODES = %w[interval manual].freeze
|
|
14
|
+
QUANTIZERS = %w[adaptive rgb332].freeze
|
|
15
|
+
URL_SCHEMES = %w[ws:// wss:// http:// https://].freeze
|
|
16
|
+
|
|
17
|
+
# Script name => [attribute, coercion, default]. Defaults are already
|
|
18
|
+
# coerced, so durations are seconds and not "100ms".
|
|
19
|
+
DEFINITIONS = {
|
|
20
|
+
'WsUrl' => [:ws_url, :url, nil],
|
|
21
|
+
'CaptureMode' => [:capture_mode, CAPTURE_MODES, 'interval'],
|
|
22
|
+
'Framerate' => [:framerate, :positive_float, 10.0],
|
|
23
|
+
'FrameDelay' => [:frame_delay, :duration, 0.1],
|
|
24
|
+
'Loop' => [:loop_count, :count, 0],
|
|
25
|
+
'Scale' => [:scale, :positive_float, 1.0],
|
|
26
|
+
'OutputWidth' => [:output_width, :positive_integer, nil],
|
|
27
|
+
'Quantizer' => [:quantizer, QUANTIZERS, 'adaptive'],
|
|
28
|
+
'Timeout' => [:timeout, :duration, 120.0],
|
|
29
|
+
'WaitTimeout' => [:wait_timeout, :duration, 10.0],
|
|
30
|
+
'WaitInterval' => [:wait_interval, :duration, 0.1],
|
|
31
|
+
'WaitStable' => [:wait_stable, :positive_integer, 1],
|
|
32
|
+
'MaxFrames' => [:max_frames, :positive_integer, 600]
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
ATTRIBUTES = DEFINITIONS.each_value.map(&:first).freeze
|
|
36
|
+
|
|
37
|
+
ATTRIBUTES.each { |attribute| define_method(attribute) { @values.fetch(attribute) } }
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
def defaults
|
|
41
|
+
DEFINITIONS.each_value.to_h { |attribute, _coercion, default| [attribute, default] }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Validates one `Set` line and returns the coerced value. Raises
|
|
45
|
+
# ArgumentError, which Parser turns into a ScriptError carrying the line.
|
|
46
|
+
def validate!(name, value)
|
|
47
|
+
attribute, coercion, = DEFINITIONS.fetch(name) { raise ArgumentError, "unknown setting #{name.inspect}" }
|
|
48
|
+
[attribute, coerce(coercion, value, name)]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def from_commands(commands)
|
|
52
|
+
values = commands.select { |command| command.name == 'Set' }.to_h do |command|
|
|
53
|
+
validate!(*command.arguments)
|
|
54
|
+
rescue ArgumentError => e
|
|
55
|
+
raise ScriptError.new(command.line_number, e.message)
|
|
56
|
+
end
|
|
57
|
+
new(values)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def coerce(coercion, value, name)
|
|
63
|
+
return enum(coercion, value, name) if coercion.is_a?(Array)
|
|
64
|
+
|
|
65
|
+
case coercion
|
|
66
|
+
when :duration then duration(value, name)
|
|
67
|
+
when :url then url(value, name)
|
|
68
|
+
when :count then integer(value, name, minimum: 0)
|
|
69
|
+
when :positive_integer then integer(value, name, minimum: 1)
|
|
70
|
+
when :positive_float then positive_float(value, name)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def enum(allowed, value, name)
|
|
75
|
+
return value if allowed.include?(value)
|
|
76
|
+
|
|
77
|
+
raise ArgumentError, "Set #{name} must be one of #{allowed.join(', ')}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def duration(value, name)
|
|
81
|
+
raise ArgumentError, "Set #{name} #{Duration::DESCRIPTION}" unless Duration.valid?(value)
|
|
82
|
+
|
|
83
|
+
Duration.parse(value)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A String key means the value has not been coerced yet, but nothing
|
|
87
|
+
# says it arrived as a String: `merge('Loop' => 3)` is a reasonable
|
|
88
|
+
# thing for a caller to write. These read it as the text a Set line
|
|
89
|
+
# would have carried, so a value of the wrong type fails validation
|
|
90
|
+
# rather than raising TypeError past the ArgumentError rescue.
|
|
91
|
+
def url(value, name)
|
|
92
|
+
text = value.to_s
|
|
93
|
+
return text if URL_SCHEMES.any? { |scheme| text.start_with?(scheme) }
|
|
94
|
+
|
|
95
|
+
raise ArgumentError, "Set #{name} must start with #{URL_SCHEMES.join(', ')}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def integer(value, name, minimum:)
|
|
99
|
+
text = value.to_s
|
|
100
|
+
number = Integer(text, 10) if /\A\d+\z/.match?(text)
|
|
101
|
+
raise ArgumentError, "Set #{name} must be an integer of at least #{minimum}" if number.nil? || number < minimum
|
|
102
|
+
|
|
103
|
+
number
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def positive_float(value, name)
|
|
107
|
+
text = value.to_s
|
|
108
|
+
number = Float(text) if /\A\d+(?:\.\d+)?\z/.match?(text)
|
|
109
|
+
raise ArgumentError, "Set #{name} must be a positive number" unless number&.positive?
|
|
110
|
+
|
|
111
|
+
number
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def initialize(values = {})
|
|
116
|
+
@values = defaults_merged_with(values).freeze
|
|
117
|
+
freeze
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def merge(overrides)
|
|
121
|
+
return self if overrides.nil? || (overrides.respond_to?(:empty?) && overrides.empty?)
|
|
122
|
+
|
|
123
|
+
self.class.new(@values.merge(normalize(overrides)))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def to_h
|
|
127
|
+
@values.dup
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def defaults_merged_with(values)
|
|
133
|
+
values.is_a?(Settings) ? values.to_h : self.class.defaults.merge(normalize(values))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Symbol keys are attributes the caller has already coerced (`frame_delay:
|
|
137
|
+
# 0.15`); String keys are script names that go through the same validation
|
|
138
|
+
# a `Set` line would. nil values are dropped so callers can pass unset
|
|
139
|
+
# flags straight through.
|
|
140
|
+
def normalize(values)
|
|
141
|
+
values.to_h.compact.to_h do |key, value|
|
|
142
|
+
next self.class.validate!(key, value) if key.is_a?(String)
|
|
143
|
+
raise Error, "unknown setting #{key.inspect}" unless ATTRIBUTES.include?(key)
|
|
144
|
+
|
|
145
|
+
[key, value]
|
|
146
|
+
end
|
|
147
|
+
rescue ArgumentError => e
|
|
148
|
+
raise Error, e.message
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
data/lib/btape/version.rb
CHANGED
data/lib/btape.rb
CHANGED
|
@@ -2,8 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'btape/version'
|
|
4
4
|
require_relative 'btape/error'
|
|
5
|
+
require_relative 'btape/null_logger'
|
|
6
|
+
require_relative 'btape/duration'
|
|
7
|
+
require_relative 'btape/settings'
|
|
5
8
|
require_relative 'btape/parser'
|
|
9
|
+
require_relative 'btape/result'
|
|
10
|
+
require_relative 'btape/palette'
|
|
6
11
|
require_relative 'btape/gif_encoder'
|
|
7
12
|
require_relative 'btape/recorder'
|
|
13
|
+
require_relative 'btape/executor'
|
|
8
14
|
require_relative 'btape/runner'
|
|
9
15
|
require_relative 'btape/cli'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: btape
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- btape contributors
|
|
@@ -37,6 +37,20 @@ dependencies:
|
|
|
37
37
|
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '0.16'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: logger
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.5'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '1.5'
|
|
40
54
|
description: A small VHS-inspired browser recorder driven by .tape files.
|
|
41
55
|
executables:
|
|
42
56
|
- btape
|
|
@@ -48,16 +62,27 @@ files:
|
|
|
48
62
|
- exe/btape
|
|
49
63
|
- lib/btape.rb
|
|
50
64
|
- lib/btape/cli.rb
|
|
65
|
+
- lib/btape/duration.rb
|
|
51
66
|
- lib/btape/error.rb
|
|
67
|
+
- lib/btape/executor.rb
|
|
52
68
|
- lib/btape/gif_encoder.rb
|
|
53
69
|
- lib/btape/lzw_compressor.rb
|
|
70
|
+
- lib/btape/null_logger.rb
|
|
71
|
+
- lib/btape/palette.rb
|
|
54
72
|
- lib/btape/parser.rb
|
|
55
73
|
- lib/btape/recorder.rb
|
|
74
|
+
- lib/btape/result.rb
|
|
56
75
|
- lib/btape/runner.rb
|
|
76
|
+
- lib/btape/settings.rb
|
|
57
77
|
- lib/btape/version.rb
|
|
78
|
+
homepage: https://github.com/slidict/btape
|
|
58
79
|
licenses:
|
|
59
80
|
- MIT
|
|
60
81
|
metadata:
|
|
82
|
+
source_code_uri: https://github.com/slidict/btape
|
|
83
|
+
changelog_uri: https://github.com/slidict/btape/releases
|
|
84
|
+
bug_tracker_uri: https://github.com/slidict/btape/issues
|
|
85
|
+
documentation_uri: https://github.com/slidict/btape#readme
|
|
61
86
|
rubygems_mfa_required: 'true'
|
|
62
87
|
rdoc_options: []
|
|
63
88
|
require_paths:
|