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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 18261c2d64fe2120e746ab4e2991a85d8c6327aa32ad497125e6b8b1efb3779a
|
|
4
|
+
data.tar.gz: aa731da95d316a63404d3ae6a89f03d3c435bea95ff26eeda824c699f8390ef7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dbb660a8f316b473a9c0a62e9842f3cc131e28d5061b49227cdc7b474be90abae39a7dfd5104e5cca26931458e60140a3423c41043de72a2d0e27b2adb51e155
|
|
7
|
+
data.tar.gz: 89ddb3f16ff21c0710dce00bfdef3a5588e3ee70a0d7df29373afb32b8e0dbf976ba30f63b83971aedd460ad8ff8c16b980f086f0d58f8d4699fe335554c1144
|
data/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# btape
|
|
2
2
|
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="assets/logo.png" alt="btape logo" width="480">
|
|
5
|
+
</p>
|
|
6
|
+
|
|
3
7
|
`btape` is a small, VHS-inspired Ruby CLI that runs browser actions from a
|
|
4
8
|
`.tape` file and records them as an animated GIF. Ferrum controls Chromium
|
|
5
9
|
and captures PNG frames, and a pure-Ruby encoder produces the GIF. It
|
|
@@ -10,9 +14,16 @@ does not require Playwright, Selenium, ffmpeg, or an external service.
|
|
|
10
14
|
```text
|
|
11
15
|
Output <path>
|
|
12
16
|
Viewport <width>x<height>
|
|
17
|
+
Set <name> <value>
|
|
13
18
|
Goto <url>
|
|
14
19
|
Click <CSS selector or text=Text>
|
|
15
20
|
Type <CSS selector> <text>
|
|
21
|
+
Press <key> [count]
|
|
22
|
+
Frame <CSS selector or main>
|
|
23
|
+
Evaluate <javascript>
|
|
24
|
+
WaitFor <CSS selector or text=Text> [duration]
|
|
25
|
+
WaitForJS <javascript> [duration]
|
|
26
|
+
Screenshot [name]
|
|
16
27
|
Sleep <number>ms|s
|
|
17
28
|
```
|
|
18
29
|
|
|
@@ -29,6 +40,37 @@ Type "#email" "demo@example.com"
|
|
|
29
40
|
Sleep 1s
|
|
30
41
|
```
|
|
31
42
|
|
|
43
|
+
`Evaluate` runs JavaScript in the current frame, which is how a tape reaches
|
|
44
|
+
an API the page exposes rather than clicking at it. `Frame` points the
|
|
45
|
+
commands that follow at an iframe, and `Frame main` returns to the page;
|
|
46
|
+
navigating returns to the page too, since the frame belonged to the page that
|
|
47
|
+
was left. `WaitFor` and `WaitForJS` poll instead of guessing at a `Sleep`.
|
|
48
|
+
|
|
49
|
+
`Screenshot` captures a frame there and then. With a name it also lands at a
|
|
50
|
+
predictable path, for picking one particular frame out of a run.
|
|
51
|
+
|
|
52
|
+
## Settings
|
|
53
|
+
|
|
54
|
+
`Set NAME VALUE` configures a run. Every setting can also be given on the
|
|
55
|
+
command line with `--set NAME=VALUE`, which wins over the tape, so one tape
|
|
56
|
+
can run in more than one place.
|
|
57
|
+
|
|
58
|
+
| Name | Default | Meaning |
|
|
59
|
+
| --- | --- | --- |
|
|
60
|
+
| `WsUrl` | — | Connect to a browser already running at this CDP url instead of launching one |
|
|
61
|
+
| `CaptureMode` | `interval` | `interval` records continuously; `manual` captures only where `Screenshot` says to |
|
|
62
|
+
| `Framerate` | `10` | Captures per second in interval mode |
|
|
63
|
+
| `FrameDelay` | `100ms` | How long each frame is shown in the GIF |
|
|
64
|
+
| `Loop` | `0` | Times to loop; 0 is forever |
|
|
65
|
+
| `Scale` | `1.0` | Scale the output down |
|
|
66
|
+
| `OutputWidth` | — | Output width in pixels; overrides `Scale` and keeps the aspect ratio |
|
|
67
|
+
| `Quantizer` | `adaptive` | `adaptive` picks the palette from the frames; `rgb332` uses a fixed one |
|
|
68
|
+
| `Timeout` | `120s` | Give up on the whole run after this |
|
|
69
|
+
| `WaitTimeout` | `10s` | Give up on a `WaitFor` or `WaitForJS` after this |
|
|
70
|
+
| `WaitInterval` | `100ms` | How often those two check |
|
|
71
|
+
| `WaitStable` | `1` | How many checks in a row must pass before a wait is satisfied |
|
|
72
|
+
| `MaxFrames` | `600` | Stop rather than record a hung page until the disk fills |
|
|
73
|
+
|
|
32
74
|
## Install and run
|
|
33
75
|
|
|
34
76
|
Chromium must be installed and discoverable by Ferrum. Then:
|
|
@@ -39,9 +81,79 @@ bundle exec btape demo.tape
|
|
|
39
81
|
bundle exec rake spec
|
|
40
82
|
```
|
|
41
83
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
84
|
+
```text
|
|
85
|
+
Usage: btape [options] SCRIPT.tape
|
|
86
|
+
|
|
87
|
+
--ws-url URL Connect to a browser already running at this CDP url
|
|
88
|
+
--set NAME=VALUE Override a setting, as a Set line would
|
|
89
|
+
--frames-dir DIR Write the PNG frames here and keep them
|
|
90
|
+
--verbose Report each command on stderr as it runs
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`BTAPE_WS_URL` is used when neither `--ws-url` nor `--set WsUrl=` is given.
|
|
94
|
+
|
|
95
|
+
### A browser running somewhere else
|
|
96
|
+
|
|
97
|
+
btape launches its own Chromium by default. Point it at one that is already
|
|
98
|
+
running — a `browserless`/`chrome` container, say — and no browser needs to be
|
|
99
|
+
in the image btape runs from:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
btape --ws-url ws://chrome:3000 examples/thumbnails.tape
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Each connection gets its own browser context, so concurrent runs against one
|
|
106
|
+
shared browser do not see each other. The viewport is applied over the wire,
|
|
107
|
+
since a browser that is already running cannot be told its window size at
|
|
108
|
+
launch.
|
|
109
|
+
|
|
110
|
+
### Frames, not just the GIF
|
|
111
|
+
|
|
112
|
+
Frames are normally written to a temporary directory and removed as the run
|
|
113
|
+
unwinds. `--frames-dir` keeps them:
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
btape --frames-dir frames examples/thumbnails.tape
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## From Ruby
|
|
120
|
+
|
|
121
|
+
`Runner#run` returns a `Btape::Result`:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
commands = Btape::Parser.new.parse(File.read('deck.tape'))
|
|
125
|
+
|
|
126
|
+
result = Btape::Runner.new(logger: Rails.logger).run(
|
|
127
|
+
commands,
|
|
128
|
+
base_directory: File.dirname('deck.tape'),
|
|
129
|
+
settings: { ws_url: ENV['CHROME_WS_URL'] },
|
|
130
|
+
frames_directory: frames,
|
|
131
|
+
on_frame: ->(path, index) { logger.debug("captured #{index}: #{path}") }
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
result.output_path # where the GIF went
|
|
135
|
+
result.frame_count # frames that went into it
|
|
136
|
+
result.frame_paths # the frames, when frames_directory was given
|
|
137
|
+
result.named_frames['page-01'] # the frame a Screenshot named
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Nothing has to touch the filesystem. Pass an IO to write the GIF into:
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
buffer = StringIO.new(+''.b)
|
|
144
|
+
Btape::Runner.new.run(commands, base_directory: '.', output: buffer)
|
|
145
|
+
|
|
146
|
+
# `buffer.string` is the GIF. Hand it to whatever holds on to it — an Active
|
|
147
|
+
# Storage attachment on one of your own records, say:
|
|
148
|
+
deck = Deck.find(params[:id])
|
|
149
|
+
deck.animation.attach(io: StringIO.new(buffer.string), filename: 'deck.gif')
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
or use the encoder on its own, with PNG paths or ChunkyPNG images:
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
Btape::GifEncoder.new(delay: 150, width: 640).encode(frame_paths) # => String
|
|
156
|
+
```
|
|
45
157
|
|
|
46
158
|
## Container development with dip or wip
|
|
47
159
|
|
|
@@ -60,11 +172,31 @@ wip dispatch btape examples/demo.tape
|
|
|
60
172
|
`examples/demo.tape` drives a small static page bundled at
|
|
61
173
|
`examples/demo_app.html`, so the demo is self-contained and needs no other
|
|
62
174
|
service running. Edit the tape (or point `Goto` at a different URL) to record
|
|
63
|
-
something else.
|
|
175
|
+
something else. `examples/thumbnails.tape` shows the other shape of run: one
|
|
176
|
+
frame per page of a deck, against a browser running elsewhere.
|
|
177
|
+
|
|
178
|
+
## Limitations
|
|
179
|
+
|
|
180
|
+
The palette is chosen from the frames being encoded, which tracks gradients
|
|
181
|
+
and text edges far more closely than the fixed RGB332 palette earlier versions
|
|
182
|
+
used — but banding compresses well and fidelity does not, so the files are
|
|
183
|
+
larger than they were. `Set Scale` or `Set OutputWidth` are the levers to pull
|
|
184
|
+
back; identical consecutive frames are already collapsed into one held for
|
|
185
|
+
longer. `Set Quantizer rgb332` restores the old palette.
|
|
186
|
+
|
|
187
|
+
The first matching element is used for `Click` and `Type`.
|
|
188
|
+
|
|
189
|
+
## Upgrading to 0.2
|
|
190
|
+
|
|
191
|
+
`Runner#run` returns a `Btape::Result` rather than the output path. Read
|
|
192
|
+
`result.output_path` where the path was used before.
|
|
193
|
+
|
|
194
|
+
## Contributing
|
|
64
195
|
|
|
65
|
-
|
|
196
|
+
Bug reports and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
197
|
+
for the development setup, PR conventions, and how releases are generated.
|
|
198
|
+
This project follows the [Code of Conduct](CODE_OF_CONDUCT.md).
|
|
66
199
|
|
|
67
|
-
|
|
68
|
-
This favors portability over photographic colour fidelity and file size. The
|
|
69
|
-
first matching element is used for `Click` and `Type`.
|
|
200
|
+
## License
|
|
70
201
|
|
|
202
|
+
[MIT](LICENSE)
|
data/lib/btape/cli.rb
CHANGED
|
@@ -1,26 +1,39 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'logger'
|
|
4
|
+
require 'optparse'
|
|
5
|
+
|
|
3
6
|
module Btape
|
|
4
7
|
# Entry point invoked by the `btape` executable: parses argv, runs the
|
|
5
8
|
# script, and reports success or failure.
|
|
6
9
|
class CLI
|
|
7
|
-
|
|
10
|
+
Options = Struct.new(:help, :settings, :frames_directory, :verbose)
|
|
11
|
+
|
|
12
|
+
USAGE = 'Usage: btape [options] SCRIPT.tape'
|
|
13
|
+
HELP_ARGUMENTS = %w[help -h --help].freeze
|
|
14
|
+
HELP_COMMANDS = ['Output PATH', 'Viewport WIDTHxHEIGHT', 'Goto URL', 'Click SELECTOR',
|
|
15
|
+
'Type SELECTOR TEXT', 'Sleep DURATION', 'Set NAME VALUE', 'Screenshot [NAME]',
|
|
16
|
+
'Evaluate JAVASCRIPT', 'WaitFor SELECTOR [TIMEOUT]',
|
|
17
|
+
'WaitForJS JAVASCRIPT [TIMEOUT]', 'Frame SELECTOR|main',
|
|
18
|
+
'Press KEY [COUNT]'].freeze
|
|
19
|
+
# The two ways a flag can name a browser: --ws-url and --set WsUrl=.
|
|
20
|
+
WS_URL_KEYS = [:ws_url, 'WsUrl'].freeze
|
|
21
|
+
|
|
22
|
+
# The runner is built after the flags are read, so that --verbose can
|
|
23
|
+
# reach it. Pass one to use it as given.
|
|
24
|
+
def initialize(out: $stdout, err: $stderr, runner: nil)
|
|
8
25
|
@out = out
|
|
9
26
|
@err = err
|
|
10
27
|
@runner = runner
|
|
11
28
|
end
|
|
12
29
|
|
|
13
|
-
HELP_COMMANDS = ['Output PATH', 'Viewport WIDTHxHEIGHT', 'Goto URL', 'Click SELECTOR',
|
|
14
|
-
'Type SELECTOR TEXT', 'Sleep DURATION'].freeze
|
|
15
|
-
|
|
16
30
|
def run(argv)
|
|
17
|
-
|
|
18
|
-
|
|
31
|
+
options = Options.new(false, {}, nil, false)
|
|
32
|
+
arguments = parse_options(argv, options)
|
|
33
|
+
return print_help if options.help || arguments.empty? || HELP_ARGUMENTS.include?(arguments.first)
|
|
34
|
+
raise Error, USAGE unless arguments.length == 1
|
|
19
35
|
|
|
20
|
-
|
|
21
|
-
commands = Parser.new.parse(File.read(script))
|
|
22
|
-
output = @runner.run(commands, base_directory: File.dirname(script))
|
|
23
|
-
@out.puts "Created #{output}"
|
|
36
|
+
record(arguments.first, options)
|
|
24
37
|
0
|
|
25
38
|
rescue Error, SystemCallError => e
|
|
26
39
|
@err.puts "btape: #{e.message}"
|
|
@@ -29,11 +42,88 @@ module Btape
|
|
|
29
42
|
|
|
30
43
|
private
|
|
31
44
|
|
|
45
|
+
def record(argument, options)
|
|
46
|
+
script = File.expand_path(argument)
|
|
47
|
+
commands = Parser.new.parse(File.read(script))
|
|
48
|
+
result = runner(options).run(
|
|
49
|
+
commands,
|
|
50
|
+
base_directory: File.dirname(script),
|
|
51
|
+
settings: settings(options),
|
|
52
|
+
frames_directory: options.frames_directory
|
|
53
|
+
)
|
|
54
|
+
@out.puts "Created #{result.output_path}"
|
|
55
|
+
report_frames(result)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def runner(options)
|
|
59
|
+
@runner || Runner.new(logger: logger(options))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def logger(options)
|
|
63
|
+
return NullLogger.new unless options.verbose
|
|
64
|
+
|
|
65
|
+
Logger.new(@err, level: Logger::DEBUG, formatter: ->(_severity, _time, _program, message) { "#{message}\n" })
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def report_frames(result)
|
|
69
|
+
return if result.frame_paths.empty?
|
|
70
|
+
|
|
71
|
+
@out.puts "Kept #{result.frame_paths.length} frame(s) in #{File.dirname(result.frame_paths.first)}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The tape's own `Set` lines are the baseline; these flags override them,
|
|
75
|
+
# which is what lets one tape run against a local and a remote browser.
|
|
76
|
+
#
|
|
77
|
+
# BTAPE_WS_URL is the last resort, and only applies when neither flag named
|
|
78
|
+
# a browser. Settings resolves --ws-url and --set WsUrl to the same
|
|
79
|
+
# attribute, so merging the environment in unconditionally would let it
|
|
80
|
+
# overwrite the --set value rather than fall back to it.
|
|
81
|
+
def settings(options)
|
|
82
|
+
return options.settings if WS_URL_KEYS.any? { |key| options.settings.key?(key) }
|
|
83
|
+
|
|
84
|
+
options.settings.merge(ws_url: ENV.fetch('BTAPE_WS_URL', nil))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def parse_options(argv, options)
|
|
88
|
+
option_parser(options).parse(argv)
|
|
89
|
+
rescue OptionParser::ParseError => e
|
|
90
|
+
raise Error, e.message
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def option_parser(options)
|
|
94
|
+
OptionParser.new do |parser|
|
|
95
|
+
parser.banner = USAGE
|
|
96
|
+
parser.on('--ws-url URL', 'Connect to a browser already running at this CDP url') do |url|
|
|
97
|
+
options.settings[:ws_url] = url
|
|
98
|
+
end
|
|
99
|
+
parser.on('--set NAME=VALUE', 'Override a setting, as a Set line would') do |pair|
|
|
100
|
+
name, value = pair.split('=', 2)
|
|
101
|
+
raise Error, '--set expects NAME=VALUE' if value.nil?
|
|
102
|
+
|
|
103
|
+
options.settings[name] = value
|
|
104
|
+
end
|
|
105
|
+
parser.on('--frames-dir DIR', 'Write the PNG frames here and keep them') do |directory|
|
|
106
|
+
options.frames_directory = directory
|
|
107
|
+
end
|
|
108
|
+
parser.on('--verbose', 'Report each command on stderr as it runs') { options.verbose = true }
|
|
109
|
+
parser.on('-h', '--help', 'Show this message') { options.help = true }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
32
113
|
def print_help
|
|
33
|
-
@out.puts
|
|
114
|
+
@out.puts USAGE
|
|
115
|
+
@out.puts
|
|
116
|
+
@out.puts 'Options:'
|
|
117
|
+
@out.puts ' --ws-url URL Connect to a browser already running at this CDP url'
|
|
118
|
+
@out.puts ' --set NAME=VALUE Override a setting, as a Set line would'
|
|
119
|
+
@out.puts ' --frames-dir DIR Write the PNG frames here and keep them'
|
|
120
|
+
@out.puts ' --verbose Report each command on stderr as it runs'
|
|
34
121
|
@out.puts
|
|
35
122
|
@out.puts 'Commands:'
|
|
36
123
|
HELP_COMMANDS.each { |command| @out.puts " #{command}" }
|
|
124
|
+
@out.puts
|
|
125
|
+
@out.puts 'Settings:'
|
|
126
|
+
Settings::DEFINITIONS.each_key { |name| @out.puts " #{name}" }
|
|
37
127
|
0
|
|
38
128
|
end
|
|
39
129
|
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Btape
|
|
4
|
+
# The duration literals .tape scripts use ("500ms", "1.5s"). The parser,
|
|
5
|
+
# the runner and Settings all need the same format, so it lives here once
|
|
6
|
+
# rather than as a regexp repeated at each call site.
|
|
7
|
+
module Duration
|
|
8
|
+
PATTERN = /\A(\d+(?:\.\d+)?)(ms|s)\z/
|
|
9
|
+
DESCRIPTION = 'must use ms or s (for example, 500ms or 1.5s)'
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def valid?(value)
|
|
14
|
+
PATTERN.match?(value.to_s)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns the duration in seconds.
|
|
18
|
+
def parse(value)
|
|
19
|
+
match = PATTERN.match(value.to_s)
|
|
20
|
+
raise ArgumentError, "duration #{DESCRIPTION}" unless match
|
|
21
|
+
|
|
22
|
+
amount, unit = match.captures
|
|
23
|
+
amount.to_f / (unit == 'ms' ? 1000 : 1)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/btape/error.rb
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
module Btape
|
|
4
4
|
class Error < StandardError; end
|
|
5
5
|
|
|
6
|
+
# Raised when a run outlasts Set Timeout. A page that never finishes
|
|
7
|
+
# loading would otherwise record until the disk filled.
|
|
8
|
+
class TimeoutError < Error; end
|
|
9
|
+
|
|
6
10
|
# Raised for a problem in the .tape script itself, carrying the line
|
|
7
11
|
# number so the CLI can report where the script went wrong.
|
|
8
12
|
class ScriptError < Error
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'monitor'
|
|
4
|
+
require_relative 'duration'
|
|
5
|
+
require_relative 'null_logger'
|
|
6
|
+
|
|
7
|
+
module Btape
|
|
8
|
+
# Performs the parsed commands against a browser, turning any failure into a
|
|
9
|
+
# ScriptError that points back at the line it came from.
|
|
10
|
+
#
|
|
11
|
+
# It lives apart from Runner because Runner's job is the recording session
|
|
12
|
+
# around the script — the browser, the frames, the GIF — while this is the
|
|
13
|
+
# script itself, and only this grows with each new command.
|
|
14
|
+
class Executor
|
|
15
|
+
# Commands that do something at run time, and the method that does it.
|
|
16
|
+
# Output, Viewport and Set are read before the run starts and have no
|
|
17
|
+
# behaviour of their own here.
|
|
18
|
+
HANDLERS = {
|
|
19
|
+
'Goto' => :goto, 'Click' => :click, 'Type' => :enter, 'Sleep' => :pause,
|
|
20
|
+
'Screenshot' => :screenshot, 'Evaluate' => :evaluate,
|
|
21
|
+
'WaitFor' => :wait_for_element, 'WaitForJS' => :wait_for_js,
|
|
22
|
+
'Frame' => :frame, 'Press' => :press
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
MAIN_FRAME = 'main'
|
|
26
|
+
|
|
27
|
+
def initialize(browser:, recorder:, settings:, lock: Monitor.new, logger: NullLogger.new)
|
|
28
|
+
@browser = browser
|
|
29
|
+
# Elements and JavaScript are looked up in the current frame, which
|
|
30
|
+
# starts as the page itself and moves when a Frame command says so.
|
|
31
|
+
@target = browser
|
|
32
|
+
@recorder = recorder
|
|
33
|
+
@settings = settings
|
|
34
|
+
@lock = lock
|
|
35
|
+
@logger = logger
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call(commands)
|
|
39
|
+
commands.each do |command|
|
|
40
|
+
@logger.debug("btape: line #{command.line_number}: #{command.name}")
|
|
41
|
+
perform(command)
|
|
42
|
+
# The run's own deadline arrives asynchronously and usually lands inside
|
|
43
|
+
# a command, which would otherwise be reported as that command failing.
|
|
44
|
+
# A caller has to be able to tell "the run outlasted Set Timeout" from
|
|
45
|
+
# "this line is broken", so it goes out as it came in.
|
|
46
|
+
rescue TimeoutError
|
|
47
|
+
raise
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
raise ScriptError.new(command.line_number, "#{command.name} failed: #{e.message}")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Held across each exchange with the browser, and shared with the
|
|
56
|
+
# recorder, so a screenshot taken on the recorder's thread is never in
|
|
57
|
+
# flight at the same time as a command on this one.
|
|
58
|
+
#
|
|
59
|
+
# It is deliberately not held for a whole command: Sleep and the waiting
|
|
60
|
+
# commands spend most of their time not talking to the browser at all,
|
|
61
|
+
# and holding the lock through that would leave interval recording with
|
|
62
|
+
# nothing to capture for the duration.
|
|
63
|
+
def locked(&)
|
|
64
|
+
@lock.synchronize(&)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def perform(command)
|
|
68
|
+
handler = HANDLERS[command.name]
|
|
69
|
+
send(handler, *command.arguments) if handler
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def goto(url)
|
|
73
|
+
locked { @browser.go_to(url) }
|
|
74
|
+
# Whatever frame we were in belongs to the page we just left.
|
|
75
|
+
@target = @browser
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Points the following commands at an iframe, so a tape can reach an API
|
|
79
|
+
# inside it — a slide deck's own navigation, say — rather than only what
|
|
80
|
+
# the outer document exposes. Nesting works by switching again from
|
|
81
|
+
# within, and `Frame main` returns to the page.
|
|
82
|
+
def frame(selector)
|
|
83
|
+
return @target = @browser if selector == MAIN_FRAME
|
|
84
|
+
|
|
85
|
+
# Reading .frame off the node is a further exchange with the browser, so
|
|
86
|
+
# it belongs under the lock as much as the lookup that found the node.
|
|
87
|
+
node = find(selector)
|
|
88
|
+
@target = locked { node.frame } || raise("#{selector} is not a frame")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def press(key, count = '1')
|
|
92
|
+
Integer(count, 10).times { locked { @browser.keyboard.type(key.to_sym) } }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def click(selector)
|
|
96
|
+
locked { find(selector).click }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def enter(selector, text)
|
|
100
|
+
locked do
|
|
101
|
+
element = find(selector)
|
|
102
|
+
element.focus
|
|
103
|
+
element.type(text)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def pause(duration)
|
|
108
|
+
sleep(Duration.parse(duration))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def screenshot(name = nil)
|
|
112
|
+
@recorder.capture(name: name)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def evaluate(expression)
|
|
116
|
+
locked { @target.evaluate(expression) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def wait_for_element(selector, timeout = nil)
|
|
120
|
+
wait_until(timeout, "#{selector} to appear") { element(selector) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def wait_for_js(expression, timeout = nil)
|
|
124
|
+
wait_until(timeout, "#{expression} to be true") { evaluate(expression) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Polls until the block has been satisfied WaitStable times in a row. The
|
|
128
|
+
# streak matters for pages that report readiness before they have settled:
|
|
129
|
+
# a single true reading can land mid-render, several in a row cannot.
|
|
130
|
+
#
|
|
131
|
+
# A block that raises counts as not-yet-satisfied, since a page part-way
|
|
132
|
+
# through loading will happily throw on a property that is about to
|
|
133
|
+
# exist. The last error is reported if the wait times out, so a broken
|
|
134
|
+
# expression still surfaces rather than being silently polled forever.
|
|
135
|
+
def wait_until(timeout, description)
|
|
136
|
+
timeout = timeout ? Duration.parse(timeout) : @settings.wait_timeout
|
|
137
|
+
deadline = monotonic + timeout
|
|
138
|
+
stable = 0
|
|
139
|
+
failure = nil
|
|
140
|
+
|
|
141
|
+
loop do
|
|
142
|
+
satisfied = begin
|
|
143
|
+
yield
|
|
144
|
+
rescue StandardError => e
|
|
145
|
+
failure = e
|
|
146
|
+
false
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
stable = satisfied ? stable + 1 : 0
|
|
150
|
+
return if stable >= @settings.wait_stable
|
|
151
|
+
raise Error, timed_out(description, timeout, failure) if monotonic >= deadline
|
|
152
|
+
|
|
153
|
+
sleep(@settings.wait_interval)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def timed_out(description, timeout, failure)
|
|
158
|
+
message = "timed out after #{timeout}s waiting for #{description}"
|
|
159
|
+
failure ? "#{message} (last error: #{failure.message})" : message
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def monotonic
|
|
163
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def find(selector)
|
|
167
|
+
element(selector) || raise("element not found: #{selector}")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def element(selector)
|
|
171
|
+
locked do
|
|
172
|
+
next @target.at_css(selector) unless selector.start_with?('text=')
|
|
173
|
+
|
|
174
|
+
literal = xpath_literal(selector.delete_prefix('text='))
|
|
175
|
+
@target.at_xpath("//*[normalize-space(text())=#{literal}]")
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def xpath_literal(text)
|
|
180
|
+
return %("#{text}") unless text.include?('"')
|
|
181
|
+
|
|
182
|
+
parts = text.split('"', -1).map { |part| %("#{part}") }
|
|
183
|
+
"concat(#{parts.join(%q(, '"', ))})"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|