btape 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +70 -0
- data/exe/btape +6 -0
- data/lib/btape/cli.rb +40 -0
- data/lib/btape/error.rb +16 -0
- data/lib/btape/gif_encoder.rb +78 -0
- data/lib/btape/lzw_compressor.rb +79 -0
- data/lib/btape/parser.rb +59 -0
- data/lib/btape/recorder.rb +54 -0
- data/lib/btape/runner.rb +111 -0
- data/lib/btape/version.rb +5 -0
- data/lib/btape.rb +9 -0
- metadata +79 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 83f37a580ea032f35ec04083a301339d43bb35bd13881799774d275527558e43
|
|
4
|
+
data.tar.gz: 6b47a03f994310102fc3534c621d7fe78e782e6ab39653a6c227b56838ba17c0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 493ebb303b92ba896387e67236c61deaf5cfaed5ba746990e67c9be9ad06d88d1ea34985d3719a87b6edfbbafe06c59b8d052720b05471fc1215589db23da43d
|
|
7
|
+
data.tar.gz: b5cf6dcfc0995a623f080f30bd5903dc8fed6f456d42f697534a720494d7c8418bcfdeb3917cb0c4537120216382578456642671b849d07b2365c486e9f0a329
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 slidict
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# btape
|
|
2
|
+
|
|
3
|
+
`btape` is a small, VHS-inspired Ruby CLI that runs browser actions from a
|
|
4
|
+
`.tape` file and records them as an animated GIF. Ferrum controls Chromium
|
|
5
|
+
and captures PNG frames, and a pure-Ruby encoder produces the GIF. It
|
|
6
|
+
does not require Playwright, Selenium, ffmpeg, or an external service.
|
|
7
|
+
|
|
8
|
+
## Commands
|
|
9
|
+
|
|
10
|
+
```text
|
|
11
|
+
Output <path>
|
|
12
|
+
Viewport <width>x<height>
|
|
13
|
+
Goto <url>
|
|
14
|
+
Click <CSS selector or text=Text>
|
|
15
|
+
Type <CSS selector> <text>
|
|
16
|
+
Sleep <number>ms|s
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Arguments containing spaces must be quoted. Empty lines and lines beginning
|
|
20
|
+
with `#` are ignored. `Output` is required; `Viewport` defaults to `1280x720`.
|
|
21
|
+
Output paths are resolved relative to the tape file.
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
Output demo.gif
|
|
25
|
+
Viewport 1280x720
|
|
26
|
+
Goto http://localhost:3000
|
|
27
|
+
Click "text=Login"
|
|
28
|
+
Type "#email" "demo@example.com"
|
|
29
|
+
Sleep 1s
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Install and run
|
|
33
|
+
|
|
34
|
+
Chromium must be installed and discoverable by Ferrum. Then:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
bundle install
|
|
38
|
+
bundle exec btape demo.tape
|
|
39
|
+
bundle exec rake spec
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The recording interval is 100 ms (10 fps). Temporary PNG frames are removed
|
|
43
|
+
after a successful run. They are also isolated in the system temporary
|
|
44
|
+
directory and cleaned when an error unwinds the run.
|
|
45
|
+
|
|
46
|
+
## Container development with dip or wip
|
|
47
|
+
|
|
48
|
+
The development image contains Ruby and Chromium.
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
dip provision
|
|
52
|
+
dip test
|
|
53
|
+
dip demo
|
|
54
|
+
|
|
55
|
+
wip up
|
|
56
|
+
wip dispatch demo
|
|
57
|
+
wip dispatch btape examples/demo.tape
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`examples/demo.tape` drives a small static page bundled at
|
|
61
|
+
`examples/demo_app.html`, so the demo is self-contained and needs no other
|
|
62
|
+
service running. Edit the tape (or point `Goto` at a different URL) to record
|
|
63
|
+
something else.
|
|
64
|
+
|
|
65
|
+
## MVP limitations
|
|
66
|
+
|
|
67
|
+
The GIF encoder uses a fixed 256-colour RGB332 palette to stay dependency-light.
|
|
68
|
+
This favors portability over photographic colour fidelity and file size. The
|
|
69
|
+
first matching element is used for `Click` and `Type`.
|
|
70
|
+
|
data/exe/btape
ADDED
data/lib/btape/cli.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Btape
|
|
4
|
+
# Entry point invoked by the `btape` executable: parses argv, runs the
|
|
5
|
+
# script, and reports success or failure.
|
|
6
|
+
class CLI
|
|
7
|
+
def initialize(out: $stdout, err: $stderr, runner: Runner.new)
|
|
8
|
+
@out = out
|
|
9
|
+
@err = err
|
|
10
|
+
@runner = runner
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
HELP_COMMANDS = ['Output PATH', 'Viewport WIDTHxHEIGHT', 'Goto URL', 'Click SELECTOR',
|
|
14
|
+
'Type SELECTOR TEXT', 'Sleep DURATION'].freeze
|
|
15
|
+
|
|
16
|
+
def run(argv)
|
|
17
|
+
return print_help if argv.empty? || %w[help -h --help].include?(argv.first)
|
|
18
|
+
raise Error, 'usage: btape SCRIPT.tape' unless argv.length == 1
|
|
19
|
+
|
|
20
|
+
script = File.expand_path(argv.first)
|
|
21
|
+
commands = Parser.new.parse(File.read(script))
|
|
22
|
+
output = @runner.run(commands, base_directory: File.dirname(script))
|
|
23
|
+
@out.puts "Created #{output}"
|
|
24
|
+
0
|
|
25
|
+
rescue Error, SystemCallError => e
|
|
26
|
+
@err.puts "btape: #{e.message}"
|
|
27
|
+
1
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def print_help
|
|
33
|
+
@out.puts 'Usage: btape SCRIPT.tape'
|
|
34
|
+
@out.puts
|
|
35
|
+
@out.puts 'Commands:'
|
|
36
|
+
HELP_COMMANDS.each { |command| @out.puts " #{command}" }
|
|
37
|
+
0
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/btape/error.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Btape
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised for a problem in the .tape script itself, carrying the line
|
|
7
|
+
# number so the CLI can report where the script went wrong.
|
|
8
|
+
class ScriptError < Error
|
|
9
|
+
attr_reader :line_number
|
|
10
|
+
|
|
11
|
+
def initialize(line_number, message)
|
|
12
|
+
@line_number = line_number
|
|
13
|
+
super("line #{line_number}: #{message}")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'chunky_png'
|
|
4
|
+
require_relative 'lzw_compressor'
|
|
5
|
+
|
|
6
|
+
module Btape
|
|
7
|
+
# A deliberately small GIF89a encoder. RGB332 gives a fixed 256-colour palette,
|
|
8
|
+
# avoiding a native image or video dependency.
|
|
9
|
+
class GifEncoder
|
|
10
|
+
def initialize(delay: 10)
|
|
11
|
+
@delay = delay
|
|
12
|
+
@compressor = LzwCompressor.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def write(png_paths, output)
|
|
16
|
+
raise Error, 'no screenshots were captured' if png_paths.empty?
|
|
17
|
+
|
|
18
|
+
images = png_paths.map { |path| ChunkyPNG::Image.from_file(path) }
|
|
19
|
+
width, height = images.first.dimension.to_a
|
|
20
|
+
raise Error, 'captured screenshots have different dimensions' unless images.all? do |image|
|
|
21
|
+
image.dimension.to_a == [width, height]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
File.binwrite(output, gif(images, width, height))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def gif(images, width, height)
|
|
30
|
+
data = +'GIF89a'.b
|
|
31
|
+
data << [width, height, 0b11110111, 0, 0].pack('vvCCC') << palette
|
|
32
|
+
data << "!\xFF\x0BNETSCAPE2.0\x03\x01\x00\x00\x00".b
|
|
33
|
+
images.each { |image| write_frame(data, image, width, height) }
|
|
34
|
+
data << ';'.b
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def write_frame(data, image, width, height)
|
|
38
|
+
data << "!\xF9\x04\x00".b << [@delay].pack('v') << "\x00\x00".b
|
|
39
|
+
data << ','.b << [0, 0, width, height, 0].pack('vvvvC')
|
|
40
|
+
compressed = lzw(image)
|
|
41
|
+
data << "\x08".b
|
|
42
|
+
compressed.bytes.each_slice(255) { |slice| data << slice.length.chr << slice.pack('C*') }
|
|
43
|
+
data << "\x00".b
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def palette
|
|
47
|
+
(0..255).map { |i| [((i >> 5) & 7) * 255 / 7, ((i >> 2) & 7) * 255 / 7, (i & 3) * 255 / 3].pack('C3') }.join.b
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def lzw(image)
|
|
51
|
+
pixels = image.pixels.map { |pixel| rgb332(pixel) }
|
|
52
|
+
codes = []
|
|
53
|
+
@compressor.call(pixels) { |code, width| codes << [code, width] }
|
|
54
|
+
pack_codes(codes)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def rgb332(pixel)
|
|
58
|
+
(ChunkyPNG::Color.r(pixel) & 0xe0) | ((ChunkyPNG::Color.g(pixel) & 0xe0) >> 3) | (ChunkyPNG::Color.b(pixel) >> 6)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def pack_codes(codes)
|
|
62
|
+
buffer = 0
|
|
63
|
+
count = 0
|
|
64
|
+
output = +''.b
|
|
65
|
+
codes.each do |code, bits|
|
|
66
|
+
buffer |= code << count
|
|
67
|
+
count += bits
|
|
68
|
+
while count >= 8
|
|
69
|
+
output << (buffer & 0xff).chr
|
|
70
|
+
buffer >>= 8
|
|
71
|
+
count -= 8
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
output << (buffer & 0xff).chr if count.positive?
|
|
75
|
+
output
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Btape
|
|
4
|
+
class GifEncoder
|
|
5
|
+
# Variable-width LZW compression of a flat array of palette indices,
|
|
6
|
+
# producing (code, width) pairs ready for bit-packing into a GIF stream.
|
|
7
|
+
class LzwCompressor
|
|
8
|
+
CLEAR_CODE = 256
|
|
9
|
+
FINISH_CODE = 257
|
|
10
|
+
MAX_CODE_WIDTH = 12
|
|
11
|
+
MAX_DICTIONARY_SIZE = 1 << MAX_CODE_WIDTH
|
|
12
|
+
|
|
13
|
+
# Mutable state threaded through compress/advance: the growing code
|
|
14
|
+
# dictionary, the next free code, the current code width in bits, and
|
|
15
|
+
# whether a width bump is owed (see #advance).
|
|
16
|
+
State = Struct.new(:dictionary, :next_code, :code_width, :pending_width_bump)
|
|
17
|
+
|
|
18
|
+
def call(pixels, &emit)
|
|
19
|
+
emit.call(CLEAR_CODE, 9)
|
|
20
|
+
return emit.call(FINISH_CODE, 9) if pixels.empty?
|
|
21
|
+
|
|
22
|
+
compress(pixels, emit)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def compress(pixels, emit)
|
|
28
|
+
state = State.new(root_dictionary, FINISH_CODE + 1, 9, false)
|
|
29
|
+
current_code = pixels.first
|
|
30
|
+
|
|
31
|
+
pixels.drop(1).each do |pixel|
|
|
32
|
+
key = ((current_code + 1) << 8) | pixel
|
|
33
|
+
if state.dictionary.key?(key)
|
|
34
|
+
current_code = state.dictionary[key]
|
|
35
|
+
next
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
emit.call(current_code, state.code_width)
|
|
39
|
+
advance(state, key, emit)
|
|
40
|
+
current_code = pixel
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
emit.call(current_code, state.code_width)
|
|
44
|
+
emit.call(FINISH_CODE, state.code_width)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A GIF decoder cannot add its own dictionary entry until it has seen two
|
|
48
|
+
# codes (it has nothing to extend after just the first), so its
|
|
49
|
+
# code-width bump for a given dictionary slot always lands one code later
|
|
50
|
+
# than the encoder's. Deferring the bump by one entry here keeps the two
|
|
51
|
+
# in lockstep.
|
|
52
|
+
def advance(state, key, emit)
|
|
53
|
+
if state.next_code == MAX_DICTIONARY_SIZE
|
|
54
|
+
emit.call(CLEAR_CODE, state.code_width)
|
|
55
|
+
return reset!(state)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
state.dictionary[key] = state.next_code
|
|
59
|
+
state.next_code += 1
|
|
60
|
+
if state.pending_width_bump
|
|
61
|
+
state.code_width += 1 if state.code_width < MAX_CODE_WIDTH
|
|
62
|
+
state.pending_width_bump = false
|
|
63
|
+
end
|
|
64
|
+
state.pending_width_bump = true if state.next_code > (1 << state.code_width) - 1
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def reset!(state)
|
|
68
|
+
state.dictionary = root_dictionary
|
|
69
|
+
state.next_code = FINISH_CODE + 1
|
|
70
|
+
state.code_width = 9
|
|
71
|
+
state.pending_width_bump = false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def root_dictionary
|
|
75
|
+
(0..255).to_h { |value| [value, value] }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/btape/parser.rb
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
|
|
5
|
+
module Btape
|
|
6
|
+
Command = Struct.new(:name, :arguments, :line_number, keyword_init: true)
|
|
7
|
+
|
|
8
|
+
# Parses .tape scripts into a list of Command structs, raising ScriptError
|
|
9
|
+
# for unknown commands, wrong argument counts, or invalid argument values.
|
|
10
|
+
class Parser
|
|
11
|
+
ARITY = { 'Output' => 1, 'Viewport' => 1, 'Goto' => 1, 'Click' => 1, 'Type' => 2, 'Sleep' => 1 }.freeze
|
|
12
|
+
|
|
13
|
+
def parse(source)
|
|
14
|
+
source.each_line.with_index(1).filter_map do |line, number|
|
|
15
|
+
text = line.strip
|
|
16
|
+
next if text.empty? || text.start_with?('#')
|
|
17
|
+
|
|
18
|
+
parse_line(text, number)
|
|
19
|
+
rescue ArgumentError => e
|
|
20
|
+
raise ScriptError.new(number, e.message)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def parse_line(text, number)
|
|
27
|
+
words = Shellwords.shellsplit(text)
|
|
28
|
+
name = words.shift
|
|
29
|
+
raise ScriptError.new(number, "unknown command #{name.inspect}") unless ARITY.key?(name)
|
|
30
|
+
|
|
31
|
+
arity = ARITY.fetch(name)
|
|
32
|
+
unless words.length == arity
|
|
33
|
+
raise ScriptError.new(number, "#{name} expects #{arity} argument(s), got #{words.length}")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
validate(name, words, number)
|
|
37
|
+
Command.new(name:, arguments: words.freeze, line_number: number)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def validate(name, arguments, number)
|
|
41
|
+
case name
|
|
42
|
+
when 'Viewport' then validate_viewport(arguments.first, number)
|
|
43
|
+
when 'Sleep' then validate_sleep(arguments.first, number)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def validate_viewport(value, number)
|
|
48
|
+
match = /\A(\d+)x(\d+)\z/.match(value)
|
|
49
|
+
valid = match&.captures&.all? { |part| part.to_i.positive? }
|
|
50
|
+
raise ScriptError.new(number, 'Viewport must be WIDTHxHEIGHT') unless valid
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate_sleep(value, number)
|
|
54
|
+
return if /\A(\d+(?:\.\d+)?)(ms|s)\z/.match?(value)
|
|
55
|
+
|
|
56
|
+
raise ScriptError.new(number, 'Sleep duration must use ms or s (for example, 500ms or 1.5s)')
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Btape
|
|
4
|
+
# Captures periodic screenshots of a page on a background thread until
|
|
5
|
+
# stopped, building the frame sequence GifEncoder turns into a GIF.
|
|
6
|
+
class Recorder
|
|
7
|
+
def initialize(page, directory, interval: 0.1)
|
|
8
|
+
@page = page
|
|
9
|
+
@directory = directory
|
|
10
|
+
@interval = interval
|
|
11
|
+
@paths = []
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_reader :paths
|
|
16
|
+
|
|
17
|
+
def start
|
|
18
|
+
capture
|
|
19
|
+
@running = true
|
|
20
|
+
@thread = Thread.new do
|
|
21
|
+
loop do
|
|
22
|
+
sleep @interval
|
|
23
|
+
capture
|
|
24
|
+
end
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
@error = e
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def stop
|
|
31
|
+
return unless @running
|
|
32
|
+
|
|
33
|
+
@running = false
|
|
34
|
+
@thread&.kill
|
|
35
|
+
@thread&.join
|
|
36
|
+
capture
|
|
37
|
+
raise @error if @error
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def capture
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
path = File.join(@directory, format('frame-%06d.png', @paths.length))
|
|
45
|
+
screenshot(path)
|
|
46
|
+
@paths << path
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def screenshot(path)
|
|
51
|
+
@page.screenshot(path: path)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/btape/runner.rb
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'ferrum'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
|
|
7
|
+
module Btape
|
|
8
|
+
# Drives a headless browser through the parsed commands and captures a
|
|
9
|
+
# screenshot per step, handing the frames off to GifEncoder.
|
|
10
|
+
class Runner
|
|
11
|
+
DEFAULT_VIEWPORT = [1280, 720].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(browser_factory: lambda { |options|
|
|
14
|
+
Ferrum::Browser.new(**options)
|
|
15
|
+
}, recorder_class: Recorder, gif_encoder: GifEncoder.new)
|
|
16
|
+
@browser_factory = browser_factory
|
|
17
|
+
@recorder_class = recorder_class
|
|
18
|
+
@gif_encoder = gif_encoder
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run(commands, base_directory: Dir.pwd)
|
|
22
|
+
output_path = resolve_output_path(commands, base_directory)
|
|
23
|
+
width, height = resolve_viewport(commands)
|
|
24
|
+
|
|
25
|
+
Dir.mktmpdir('btape-') { |directory| record(commands, directory, width, height, output_path) }
|
|
26
|
+
output_path
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def resolve_output_path(commands, base_directory)
|
|
32
|
+
output = commands.find { |command| command.name == 'Output' }&.arguments&.first
|
|
33
|
+
raise Error, 'script must contain an Output command' unless output
|
|
34
|
+
|
|
35
|
+
path = File.expand_path(output, base_directory)
|
|
36
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
37
|
+
path
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def resolve_viewport(commands)
|
|
41
|
+
viewport = commands.find { |command| command.name == 'Viewport' }&.arguments&.first
|
|
42
|
+
viewport ? viewport.split('x').map(&:to_i) : DEFAULT_VIEWPORT
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def record(commands, directory, width, height, output_path)
|
|
46
|
+
browser = nil
|
|
47
|
+
recorder = nil
|
|
48
|
+
begin
|
|
49
|
+
browser = @browser_factory.call(window_size: [width, height])
|
|
50
|
+
recorder = @recorder_class.new(browser, directory)
|
|
51
|
+
recorder.start
|
|
52
|
+
execute(commands, browser)
|
|
53
|
+
recorder.stop
|
|
54
|
+
@gif_encoder.write(recorder.paths, output_path)
|
|
55
|
+
ensure
|
|
56
|
+
begin
|
|
57
|
+
recorder&.stop
|
|
58
|
+
rescue StandardError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
browser&.quit
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def execute(commands, browser)
|
|
66
|
+
commands.each do |command|
|
|
67
|
+
perform(command, browser)
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
raise ScriptError.new(command.line_number, "#{command.name} failed: #{e.message}")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def perform(command, browser)
|
|
74
|
+
case command.name
|
|
75
|
+
when 'Output', 'Viewport' then nil
|
|
76
|
+
when 'Goto' then browser.go_to(command.arguments.first)
|
|
77
|
+
when 'Click' then find(browser, command.arguments.first).click
|
|
78
|
+
when 'Type' then type(browser, command.arguments)
|
|
79
|
+
when 'Sleep' then sleep_seconds(command.arguments.first)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def type(browser, arguments)
|
|
84
|
+
element = find(browser, arguments.first)
|
|
85
|
+
element.focus
|
|
86
|
+
element.type(arguments.last)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def find(browser, selector)
|
|
90
|
+
element = if selector.start_with?('text=')
|
|
91
|
+
literal = xpath_literal(selector.delete_prefix('text='))
|
|
92
|
+
browser.at_xpath("//*[normalize-space(text())=#{literal}]")
|
|
93
|
+
else
|
|
94
|
+
browser.at_css(selector)
|
|
95
|
+
end
|
|
96
|
+
element || raise("element not found: #{selector}")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def xpath_literal(text)
|
|
100
|
+
return %("#{text}") unless text.include?('"')
|
|
101
|
+
|
|
102
|
+
parts = text.split('"', -1).map { |part| %("#{part}") }
|
|
103
|
+
"concat(#{parts.join(%q(, '"', ))})"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def sleep_seconds(duration)
|
|
107
|
+
value, unit = duration.match(/\A(\d+(?:\.\d+)?)(ms|s)\z/).captures
|
|
108
|
+
sleep(value.to_f / (unit == 'ms' ? 1000 : 1))
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
data/lib/btape.rb
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'btape/version'
|
|
4
|
+
require_relative 'btape/error'
|
|
5
|
+
require_relative 'btape/parser'
|
|
6
|
+
require_relative 'btape/gif_encoder'
|
|
7
|
+
require_relative 'btape/recorder'
|
|
8
|
+
require_relative 'btape/runner'
|
|
9
|
+
require_relative 'btape/cli'
|
metadata
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: btape
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- btape contributors
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: chunky_png
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.4'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.4'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: ferrum
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0.16'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0.16'
|
|
40
|
+
description: A small VHS-inspired browser recorder driven by .tape files.
|
|
41
|
+
executables:
|
|
42
|
+
- btape
|
|
43
|
+
extensions: []
|
|
44
|
+
extra_rdoc_files: []
|
|
45
|
+
files:
|
|
46
|
+
- LICENSE
|
|
47
|
+
- README.md
|
|
48
|
+
- exe/btape
|
|
49
|
+
- lib/btape.rb
|
|
50
|
+
- lib/btape/cli.rb
|
|
51
|
+
- lib/btape/error.rb
|
|
52
|
+
- lib/btape/gif_encoder.rb
|
|
53
|
+
- lib/btape/lzw_compressor.rb
|
|
54
|
+
- lib/btape/parser.rb
|
|
55
|
+
- lib/btape/recorder.rb
|
|
56
|
+
- lib/btape/runner.rb
|
|
57
|
+
- lib/btape/version.rb
|
|
58
|
+
licenses:
|
|
59
|
+
- MIT
|
|
60
|
+
metadata:
|
|
61
|
+
rubygems_mfa_required: 'true'
|
|
62
|
+
rdoc_options: []
|
|
63
|
+
require_paths:
|
|
64
|
+
- lib
|
|
65
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '3.1'
|
|
70
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0'
|
|
75
|
+
requirements: []
|
|
76
|
+
rubygems_version: 3.6.7
|
|
77
|
+
specification_version: 4
|
|
78
|
+
summary: Record browser automation scripts as animated GIFs
|
|
79
|
+
test_files: []
|