btape 0.1.0 → 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/README.md +297 -9
- data/lib/btape/cli.rb +120 -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/generate_command.rb +123 -0
- data/lib/btape/gif_encoder.rb +88 -27
- data/lib/btape/llm/client.rb +141 -0
- data/lib/btape/llm/generator.rb +91 -0
- data/lib/btape/llm/prompt.rb +133 -0
- data/lib/btape/null_logger.rb +14 -0
- data/lib/btape/palette.rb +203 -0
- data/lib/btape/parser.rb +50 -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 +10 -0
- metadata +30 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f7ea547bca5c9c270e16d779e170c68b0ab30b8bc4156cde58d4cfe7560ebbe4
|
|
4
|
+
data.tar.gz: fb55fff428c21bfbc889aa653708a1d02c77ffe795c515238716ad9e246fecf6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 715e86c7d8e3d18834de6350eacac2ddad337d031faf3fae46f57d86364c445679509d9ef9db25e74688df5ac5c8c341a34418d85fb597b82ec312f2d02fd9d9
|
|
7
|
+
data.tar.gz: 3b67abd203cd1619bc2de88fb4259437a994ec970ef58e81c937ca68efc0ebebc71588942942efa06e6dc56191af8935891c945d5241d87df3c5440787fa18be
|
data/README.md
CHANGED
|
@@ -1,18 +1,34 @@
|
|
|
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
|
|
6
10
|
does not require Playwright, Selenium, ffmpeg, or an external service.
|
|
7
11
|
|
|
12
|
+
Tapes are written by hand, or asked of a language model running on the same
|
|
13
|
+
machine: `btape generate` describes the language to LM Studio, Ollama or
|
|
14
|
+
anything else speaking their API, and holds the answer to the parser before
|
|
15
|
+
handing it over.
|
|
16
|
+
|
|
8
17
|
## Commands
|
|
9
18
|
|
|
10
19
|
```text
|
|
11
20
|
Output <path>
|
|
12
21
|
Viewport <width>x<height>
|
|
22
|
+
Set <name> <value>
|
|
13
23
|
Goto <url>
|
|
14
24
|
Click <CSS selector or text=Text>
|
|
15
25
|
Type <CSS selector> <text>
|
|
26
|
+
Press <key> [count]
|
|
27
|
+
Frame <CSS selector or main>
|
|
28
|
+
Evaluate <javascript>
|
|
29
|
+
WaitFor <CSS selector or text=Text> [duration]
|
|
30
|
+
WaitForJS <javascript> [duration]
|
|
31
|
+
Screenshot [name]
|
|
16
32
|
Sleep <number>ms|s
|
|
17
33
|
```
|
|
18
34
|
|
|
@@ -29,6 +45,37 @@ Type "#email" "demo@example.com"
|
|
|
29
45
|
Sleep 1s
|
|
30
46
|
```
|
|
31
47
|
|
|
48
|
+
`Evaluate` runs JavaScript in the current frame, which is how a tape reaches
|
|
49
|
+
an API the page exposes rather than clicking at it. `Frame` points the
|
|
50
|
+
commands that follow at an iframe, and `Frame main` returns to the page;
|
|
51
|
+
navigating returns to the page too, since the frame belonged to the page that
|
|
52
|
+
was left. `WaitFor` and `WaitForJS` poll instead of guessing at a `Sleep`.
|
|
53
|
+
|
|
54
|
+
`Screenshot` captures a frame there and then. With a name it also lands at a
|
|
55
|
+
predictable path, for picking one particular frame out of a run.
|
|
56
|
+
|
|
57
|
+
## Settings
|
|
58
|
+
|
|
59
|
+
`Set NAME VALUE` configures a run. Every setting can also be given on the
|
|
60
|
+
command line with `--set NAME=VALUE`, which wins over the tape, so one tape
|
|
61
|
+
can run in more than one place.
|
|
62
|
+
|
|
63
|
+
| Name | Default | Meaning |
|
|
64
|
+
| --- | --- | --- |
|
|
65
|
+
| `WsUrl` | — | Connect to a browser already running at this CDP url instead of launching one |
|
|
66
|
+
| `CaptureMode` | `interval` | `interval` records continuously; `manual` captures only where `Screenshot` says to |
|
|
67
|
+
| `Framerate` | `10` | Captures per second in interval mode |
|
|
68
|
+
| `FrameDelay` | `100ms` | How long each frame is shown in the GIF |
|
|
69
|
+
| `Loop` | `0` | Times to loop; 0 is forever |
|
|
70
|
+
| `Scale` | `1.0` | Scale the output down |
|
|
71
|
+
| `OutputWidth` | — | Output width in pixels; overrides `Scale` and keeps the aspect ratio |
|
|
72
|
+
| `Quantizer` | `adaptive` | `adaptive` picks the palette from the frames; `rgb332` uses a fixed one |
|
|
73
|
+
| `Timeout` | `120s` | Give up on the whole run after this |
|
|
74
|
+
| `WaitTimeout` | `10s` | Give up on a `WaitFor` or `WaitForJS` after this |
|
|
75
|
+
| `WaitInterval` | `100ms` | How often those two check |
|
|
76
|
+
| `WaitStable` | `1` | How many checks in a row must pass before a wait is satisfied |
|
|
77
|
+
| `MaxFrames` | `600` | Stop rather than record a hung page until the disk fills |
|
|
78
|
+
|
|
32
79
|
## Install and run
|
|
33
80
|
|
|
34
81
|
Chromium must be installed and discoverable by Ferrum. Then:
|
|
@@ -39,13 +86,234 @@ bundle exec btape demo.tape
|
|
|
39
86
|
bundle exec rake spec
|
|
40
87
|
```
|
|
41
88
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
89
|
+
```text
|
|
90
|
+
Usage: btape [options] SCRIPT.tape
|
|
91
|
+
|
|
92
|
+
--ws-url URL Connect to a browser already running at this CDP url
|
|
93
|
+
--set NAME=VALUE Override a setting, as a Set line would
|
|
94
|
+
--frames-dir DIR Write the PNG frames here and keep them
|
|
95
|
+
--verbose Report each command on stderr as it runs
|
|
96
|
+
|
|
97
|
+
Subcommands:
|
|
98
|
+
generate DESCRIPTION Write a tape by asking a local model; btape generate --help
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`BTAPE_WS_URL` is used when neither `--ws-url` nor `--set WsUrl=` is given.
|
|
102
|
+
|
|
103
|
+
### Fonts, and text that is not Latin
|
|
104
|
+
|
|
105
|
+
Glyphs come from the fonts the browser can see, which is not necessarily the
|
|
106
|
+
machine btape runs on: the host when btape launches Chromium itself, the other
|
|
107
|
+
machine when `Set WsUrl` points at one, and the image when either of those is
|
|
108
|
+
a container. Nothing raises when a script has no coverage — the page records as
|
|
109
|
+
rows of tofu boxes instead — so a font missing from a headless image shows up
|
|
110
|
+
in the GIF and nowhere earlier.
|
|
111
|
+
|
|
112
|
+
Install fonts covering the scripts the tapes visit. On Debian or Ubuntu:
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
apt-get install fonts-noto-core # most scripts, Latin included
|
|
116
|
+
apt-get install fonts-noto-cjk # Chinese, Japanese, Korean
|
|
117
|
+
apt-get install fonts-ipafont fonts-ipaexfont # Japanese, as IPAGothic, IPAexGothic, IPAMincho
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Fontconfig reads `/usr/share/fonts`, `/usr/local/share/fonts`,
|
|
121
|
+
`~/.local/share/fonts` and `~/.fonts`, and font files copied in by hand need
|
|
122
|
+
an `fc-cache -f` after them. The home directory in that list is the one
|
|
123
|
+
belonging to whoever launches the browser, which under a service manager is
|
|
124
|
+
often not the user who installed the font — `sudo -u deploy fc-list` settles
|
|
125
|
+
that faster than another recording does. Chromium reads the configuration as
|
|
126
|
+
it starts, and btape starts one browser per run; a browser shared over `WsUrl`
|
|
127
|
+
keeps the fonts it was launched with until it is restarted.
|
|
128
|
+
|
|
129
|
+
Where one installed font covers a script, that is the whole job: the browser
|
|
130
|
+
falls back to it even for a page that asked for `sans-serif`. Naming a family
|
|
131
|
+
matters when several cover the same script — Noto CJK and IPA together, or a
|
|
132
|
+
developer's macOS with Hiragino already on it. Either force it from the tape:
|
|
133
|
+
|
|
134
|
+
```text
|
|
135
|
+
Evaluate "document.head.insertAdjacentHTML('beforeend', '<style>*{font-family:IPAexGothic!important}</style>')"
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
which lasts as long as the document it ran in, so it is repeated after each
|
|
139
|
+
`Goto` and inside a `Frame`; or prefer it for everything the machine renders,
|
|
140
|
+
in `/etc/fonts/local.conf`:
|
|
141
|
+
|
|
142
|
+
```xml
|
|
143
|
+
<?xml version="1.0"?>
|
|
144
|
+
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
|
|
145
|
+
<fontconfig>
|
|
146
|
+
<alias><family>sans-serif</family><prefer><family>IPAexGothic</family></prefer></alias>
|
|
147
|
+
<alias><family>serif</family><prefer><family>IPAexMincho</family></prefer></alias>
|
|
148
|
+
<alias><family>monospace</family><prefer><family>IPAGothic</family></prefer></alias>
|
|
149
|
+
</fontconfig>
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
An alias only answers for the generic families. A page naming `Helvetica,
|
|
153
|
+
Arial` ahead of `sans-serif` keeps whatever those resolve to — Liberation Sans,
|
|
154
|
+
in an image that has it — so such a page is served by forcing the family from
|
|
155
|
+
the tape, or by matching those names in the fontconfig file as well. macOS has
|
|
156
|
+
no fontconfig at all, and there the tape is the only route.
|
|
157
|
+
|
|
158
|
+
A tape can check that the font arrived rather than trust the image it runs in,
|
|
159
|
+
since a family that is not installed measures the same as one that does not
|
|
160
|
+
exist:
|
|
161
|
+
|
|
162
|
+
```text
|
|
163
|
+
WaitForJS "(() => { const c = document.createElement('canvas').getContext('2d'); const w = (f) => { c.font = '48px ' + f; return c.measureText('AあÄ0').width; }; return w('IPAexGothic') !== w('__missing__'); })()" 3s
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Tape files themselves are read as UTF-8 whatever the locale says, so a `Type`
|
|
167
|
+
line or a `text=` selector can be written in any script.
|
|
168
|
+
|
|
169
|
+
### A browser running somewhere else
|
|
170
|
+
|
|
171
|
+
btape launches its own Chromium by default. Point it at one that is already
|
|
172
|
+
running — a `browserless`/`chrome` container, say — and no browser needs to be
|
|
173
|
+
in the image btape runs from:
|
|
174
|
+
|
|
175
|
+
```sh
|
|
176
|
+
btape --ws-url ws://chrome:3000 examples/thumbnails.tape
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Each connection gets its own browser context, so concurrent runs against one
|
|
180
|
+
shared browser do not see each other. The viewport is applied over the wire,
|
|
181
|
+
since a browser that is already running cannot be told its window size at
|
|
182
|
+
launch.
|
|
183
|
+
|
|
184
|
+
### Frames, not just the GIF
|
|
185
|
+
|
|
186
|
+
Frames are normally written to a temporary directory and removed as the run
|
|
187
|
+
unwinds. `--frames-dir` keeps them:
|
|
188
|
+
|
|
189
|
+
```sh
|
|
190
|
+
btape --frames-dir frames examples/thumbnails.tape
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Writing a tape with a local model
|
|
194
|
+
|
|
195
|
+
`btape generate` describes the language to a model running on your own
|
|
196
|
+
machine and asks it for a tape:
|
|
197
|
+
|
|
198
|
+
```sh
|
|
199
|
+
btape generate "record signing in at localhost:3000 and landing on the dashboard" -o signin.tape
|
|
200
|
+
btape signin.tape
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The default is `http://localhost:1234/v1`, which is where LM Studio serves.
|
|
204
|
+
Anything else speaking the same API answers just as well — Ollama on
|
|
205
|
+
`http://localhost:11434/v1`, llama.cpp's server, vLLM — so nothing about the
|
|
206
|
+
description or the page being recorded leaves the machine unless you point
|
|
207
|
+
`--llm-url` somewhere that it does.
|
|
208
|
+
|
|
209
|
+
```text
|
|
210
|
+
Usage: btape generate [options] DESCRIPTION
|
|
211
|
+
|
|
212
|
+
--llm-url URL The OpenAI-compatible model server to ask
|
|
213
|
+
--model NAME Ask for this model rather than whichever one is loaded
|
|
214
|
+
--temperature N How freely the model writes; 0.2 by default
|
|
215
|
+
--context FILE Give the model this file as context: selectors, notes, markup
|
|
216
|
+
-o, --out FILE Write the tape here rather than to standard output
|
|
217
|
+
--verbose Report each attempt on stderr
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
`BTAPE_LLM_URL`, `BTAPE_LLM_MODEL` and `BTAPE_LLM_KEY` stand in for the first
|
|
221
|
+
two flags and for a key, which a local server rarely wants and a proxy in
|
|
222
|
+
front of one usually does. With no model named, the server is asked which one
|
|
223
|
+
it has loaded — the name a download was given is not worth remembering.
|
|
224
|
+
|
|
225
|
+
The description can be piped in rather than quoted, which is easier for
|
|
226
|
+
anything longer than a line:
|
|
227
|
+
|
|
228
|
+
```sh
|
|
229
|
+
btape generate --context app/views/sessions/new.html.erb < what-to-record.txt
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
What comes back is parsed before you see it, and a tape that does not parse
|
|
233
|
+
goes back to the model with the parser's own complaint — `line 4: unknown
|
|
234
|
+
command "Navigate"` — for it to fix, up to three times. That loop is why this
|
|
235
|
+
is worth more than pasting the command list into a chat window: a small model
|
|
236
|
+
reliably invents a command or drops a quote, and just as reliably repairs it
|
|
237
|
+
when told which line. What it cannot know is your markup, so a tape it wrote
|
|
238
|
+
still names selectors that have to be checked against the page. Read it before
|
|
239
|
+
you run it, the way you would read anything else generated for you.
|
|
240
|
+
|
|
241
|
+
### A model that is not on this machine
|
|
242
|
+
|
|
243
|
+
`--llm-url` is the whole of the configuration, so a hosted endpoint speaking
|
|
244
|
+
the same API works as well as a local one. Name the model rather than leaving
|
|
245
|
+
it to be discovered: these servers answer `/v1/models` with a catalogue rather
|
|
246
|
+
than with the one thing they have loaded, and the first entry of it is not
|
|
247
|
+
necessarily something that holds a conversation.
|
|
248
|
+
|
|
249
|
+
```sh
|
|
250
|
+
BTAPE_LLM_KEY=sk-... btape generate --llm-url https://api.openai.com/v1 --model gpt-4.1 "record signing in"
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Anthropic serves an OpenAI-compatible layer on the same host as its own API,
|
|
254
|
+
so `--llm-url https://api.anthropic.com/v1 --model claude-opus-5` records too.
|
|
255
|
+
It is meant for trying models rather than for living on, but nothing btape
|
|
256
|
+
asks of it is among the parts that are missing. A model that refuses
|
|
257
|
+
`--temperature` at anything but its default wants `--temperature 1`.
|
|
258
|
+
|
|
259
|
+
Sending the work somewhere else is the thing to weigh, not the flag. A local
|
|
260
|
+
model keeps the description and the `--context` file on the machine that ran
|
|
261
|
+
the command; a hosted one is handed both, and a context file is usually a page
|
|
262
|
+
of your own markup rather than something you would have published.
|
|
263
|
+
|
|
264
|
+
## From Ruby
|
|
265
|
+
|
|
266
|
+
`Runner#run` returns a `Btape::Result`:
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
commands = Btape::Parser.new.parse(File.read('deck.tape'))
|
|
270
|
+
|
|
271
|
+
result = Btape::Runner.new(logger: Rails.logger).run(
|
|
272
|
+
commands,
|
|
273
|
+
base_directory: File.dirname('deck.tape'),
|
|
274
|
+
settings: { ws_url: ENV['CHROME_WS_URL'] },
|
|
275
|
+
frames_directory: frames,
|
|
276
|
+
on_frame: ->(path, index) { logger.debug("captured #{index}: #{path}") }
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
result.output_path # where the GIF went
|
|
280
|
+
result.frame_count # frames that went into it
|
|
281
|
+
result.frame_paths # the frames, when frames_directory was given
|
|
282
|
+
result.named_frames['page-01'] # the frame a Screenshot named
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Nothing has to touch the filesystem. Pass an IO to write the GIF into:
|
|
286
|
+
|
|
287
|
+
```ruby
|
|
288
|
+
buffer = StringIO.new(+''.b)
|
|
289
|
+
Btape::Runner.new.run(commands, base_directory: '.', output: buffer)
|
|
290
|
+
|
|
291
|
+
# `buffer.string` is the GIF. Hand it to whatever holds on to it — an Active
|
|
292
|
+
# Storage attachment on one of your own records, say:
|
|
293
|
+
deck = Deck.find(params[:id])
|
|
294
|
+
deck.animation.attach(io: StringIO.new(buffer.string), filename: 'deck.gif')
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
or use the encoder on its own, with PNG paths or ChunkyPNG images:
|
|
298
|
+
|
|
299
|
+
```ruby
|
|
300
|
+
Btape::GifEncoder.new(delay: 150, width: 640).encode(frame_paths) # => String
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
The generator is a plain object too, so an application that already knows what
|
|
304
|
+
it wants recorded can go from a sentence to a GIF without a file in between:
|
|
305
|
+
|
|
306
|
+
```ruby
|
|
307
|
+
generator = Btape::LLM::Generator.new(client: Btape::LLM::Client.new(base_url: ENV['BTAPE_LLM_URL']))
|
|
308
|
+
tape = generator.call('record the dashboard loading', context: page_markup)
|
|
309
|
+
|
|
310
|
+
Btape::Runner.new.run(Btape::Parser.new.parse(tape), base_directory: '.', output: buffer)
|
|
311
|
+
```
|
|
45
312
|
|
|
46
313
|
## Container development with dip or wip
|
|
47
314
|
|
|
48
|
-
The development image contains Ruby and
|
|
315
|
+
The development image contains Ruby, Chromium and Latin fonts; tapes that
|
|
316
|
+
record other scripts need fonts for them added to it.
|
|
49
317
|
|
|
50
318
|
```sh
|
|
51
319
|
dip provision
|
|
@@ -60,11 +328,31 @@ wip dispatch btape examples/demo.tape
|
|
|
60
328
|
`examples/demo.tape` drives a small static page bundled at
|
|
61
329
|
`examples/demo_app.html`, so the demo is self-contained and needs no other
|
|
62
330
|
service running. Edit the tape (or point `Goto` at a different URL) to record
|
|
63
|
-
something else.
|
|
331
|
+
something else. `examples/thumbnails.tape` shows the other shape of run: one
|
|
332
|
+
frame per page of a deck, against a browser running elsewhere.
|
|
333
|
+
|
|
334
|
+
## Limitations
|
|
335
|
+
|
|
336
|
+
The palette is chosen from the frames being encoded, which tracks gradients
|
|
337
|
+
and text edges far more closely than the fixed RGB332 palette earlier versions
|
|
338
|
+
used — but banding compresses well and fidelity does not, so the files are
|
|
339
|
+
larger than they were. `Set Scale` or `Set OutputWidth` are the levers to pull
|
|
340
|
+
back; identical consecutive frames are already collapsed into one held for
|
|
341
|
+
longer. `Set Quantizer rgb332` restores the old palette.
|
|
342
|
+
|
|
343
|
+
The first matching element is used for `Click` and `Type`.
|
|
344
|
+
|
|
345
|
+
## Upgrading to 0.2
|
|
346
|
+
|
|
347
|
+
`Runner#run` returns a `Btape::Result` rather than the output path. Read
|
|
348
|
+
`result.output_path` where the path was used before.
|
|
349
|
+
|
|
350
|
+
## Contributing
|
|
64
351
|
|
|
65
|
-
|
|
352
|
+
Bug reports and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
353
|
+
for the development setup, PR conventions, and how releases are generated.
|
|
354
|
+
This project follows the [Code of Conduct](CODE_OF_CONDUCT.md).
|
|
66
355
|
|
|
67
|
-
|
|
68
|
-
This favors portability over photographic colour fidelity and file size. The
|
|
69
|
-
first matching element is used for `Click` and `Type`.
|
|
356
|
+
## License
|
|
70
357
|
|
|
358
|
+
[MIT](LICENSE)
|
data/lib/btape/cli.rb
CHANGED
|
@@ -1,26 +1,42 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'logger'
|
|
4
|
+
require 'optparse'
|
|
5
|
+
require_relative 'generate_command'
|
|
6
|
+
require_relative 'parser'
|
|
7
|
+
|
|
3
8
|
module Btape
|
|
4
9
|
# Entry point invoked by the `btape` executable: parses argv, runs the
|
|
5
10
|
# script, and reports success or failure.
|
|
6
11
|
class CLI
|
|
7
|
-
|
|
12
|
+
Options = Struct.new(:help, :settings, :frames_directory, :verbose)
|
|
13
|
+
|
|
14
|
+
USAGE = 'Usage: btape [options] SCRIPT.tape'
|
|
15
|
+
HELP_ARGUMENTS = %w[help -h --help].freeze
|
|
16
|
+
HELP_COMMANDS = Parser::SIGNATURES.map { |name, arguments| "#{name} #{arguments}".strip }.freeze
|
|
17
|
+
GENERATE = 'generate'
|
|
18
|
+
# The two ways a flag can name a browser: --ws-url and --set WsUrl=.
|
|
19
|
+
WS_URL_KEYS = [:ws_url, 'WsUrl'].freeze
|
|
20
|
+
|
|
21
|
+
# The runner is built after the flags are read, so that --verbose can
|
|
22
|
+
# reach it. Pass one to use it as given.
|
|
23
|
+
def initialize(out: $stdout, err: $stderr, stdin: $stdin, runner: nil, generator: nil)
|
|
8
24
|
@out = out
|
|
9
25
|
@err = err
|
|
26
|
+
@stdin = stdin
|
|
10
27
|
@runner = runner
|
|
28
|
+
@generator = generator
|
|
11
29
|
end
|
|
12
30
|
|
|
13
|
-
HELP_COMMANDS = ['Output PATH', 'Viewport WIDTHxHEIGHT', 'Goto URL', 'Click SELECTOR',
|
|
14
|
-
'Type SELECTOR TEXT', 'Sleep DURATION'].freeze
|
|
15
|
-
|
|
16
31
|
def run(argv)
|
|
17
|
-
return
|
|
18
|
-
raise Error, 'usage: btape SCRIPT.tape' unless argv.length == 1
|
|
32
|
+
return generate(argv.drop(1)) if argv.first == GENERATE
|
|
19
33
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
34
|
+
options = Options.new(false, {}, nil, false)
|
|
35
|
+
arguments = parse_options(argv, options)
|
|
36
|
+
return print_help if options.help || arguments.empty? || HELP_ARGUMENTS.include?(arguments.first)
|
|
37
|
+
raise Error, USAGE unless arguments.length == 1
|
|
38
|
+
|
|
39
|
+
record(arguments.first, options)
|
|
24
40
|
0
|
|
25
41
|
rescue Error, SystemCallError => e
|
|
26
42
|
@err.puts "btape: #{e.message}"
|
|
@@ -29,11 +45,104 @@ module Btape
|
|
|
29
45
|
|
|
30
46
|
private
|
|
31
47
|
|
|
48
|
+
def generate(argv)
|
|
49
|
+
GenerateCommand.new(out: @out, err: @err, stdin: @stdin, generator: @generator).run(argv)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def record(argument, options)
|
|
53
|
+
script = File.expand_path(argument)
|
|
54
|
+
commands = Parser.new.parse(read_script(script))
|
|
55
|
+
result = runner(options).run(
|
|
56
|
+
commands,
|
|
57
|
+
base_directory: File.dirname(script),
|
|
58
|
+
settings: settings(options),
|
|
59
|
+
frames_directory: options.frames_directory
|
|
60
|
+
)
|
|
61
|
+
@out.puts "Created #{result.output_path}"
|
|
62
|
+
report_frames(result)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Tapes are UTF-8, not whatever the locale happens to be. A tape that
|
|
66
|
+
# types text or matches on it is as likely to be written in Japanese or
|
|
67
|
+
# Greek as in ASCII, and read through the default external encoding a
|
|
68
|
+
# machine with LANG unset would reject those bytes while parsing rather
|
|
69
|
+
# than while recording.
|
|
70
|
+
def read_script(path)
|
|
71
|
+
File.read(path, encoding: Encoding::UTF_8)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def runner(options)
|
|
75
|
+
@runner || Runner.new(logger: logger(options))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def logger(options)
|
|
79
|
+
return NullLogger.new unless options.verbose
|
|
80
|
+
|
|
81
|
+
Logger.new(@err, level: Logger::DEBUG, formatter: ->(_severity, _time, _program, message) { "#{message}\n" })
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def report_frames(result)
|
|
85
|
+
return if result.frame_paths.empty?
|
|
86
|
+
|
|
87
|
+
@out.puts "Kept #{result.frame_paths.length} frame(s) in #{File.dirname(result.frame_paths.first)}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The tape's own `Set` lines are the baseline; these flags override them,
|
|
91
|
+
# which is what lets one tape run against a local and a remote browser.
|
|
92
|
+
#
|
|
93
|
+
# BTAPE_WS_URL is the last resort, and only applies when neither flag named
|
|
94
|
+
# a browser. Settings resolves --ws-url and --set WsUrl to the same
|
|
95
|
+
# attribute, so merging the environment in unconditionally would let it
|
|
96
|
+
# overwrite the --set value rather than fall back to it.
|
|
97
|
+
def settings(options)
|
|
98
|
+
return options.settings if WS_URL_KEYS.any? { |key| options.settings.key?(key) }
|
|
99
|
+
|
|
100
|
+
options.settings.merge(ws_url: ENV.fetch('BTAPE_WS_URL', nil))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def parse_options(argv, options)
|
|
104
|
+
option_parser(options).parse(argv)
|
|
105
|
+
rescue OptionParser::ParseError => e
|
|
106
|
+
raise Error, e.message
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def option_parser(options)
|
|
110
|
+
OptionParser.new do |parser|
|
|
111
|
+
parser.banner = USAGE
|
|
112
|
+
parser.on('--ws-url URL', 'Connect to a browser already running at this CDP url') do |url|
|
|
113
|
+
options.settings[:ws_url] = url
|
|
114
|
+
end
|
|
115
|
+
parser.on('--set NAME=VALUE', 'Override a setting, as a Set line would') do |pair|
|
|
116
|
+
name, value = pair.split('=', 2)
|
|
117
|
+
raise Error, '--set expects NAME=VALUE' if value.nil?
|
|
118
|
+
|
|
119
|
+
options.settings[name] = value
|
|
120
|
+
end
|
|
121
|
+
parser.on('--frames-dir DIR', 'Write the PNG frames here and keep them') do |directory|
|
|
122
|
+
options.frames_directory = directory
|
|
123
|
+
end
|
|
124
|
+
parser.on('--verbose', 'Report each command on stderr as it runs') { options.verbose = true }
|
|
125
|
+
parser.on('-h', '--help', 'Show this message') { options.help = true }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
32
129
|
def print_help
|
|
33
|
-
@out.puts
|
|
130
|
+
@out.puts USAGE
|
|
131
|
+
@out.puts
|
|
132
|
+
@out.puts 'Options:'
|
|
133
|
+
@out.puts ' --ws-url URL Connect to a browser already running at this CDP url'
|
|
134
|
+
@out.puts ' --set NAME=VALUE Override a setting, as a Set line would'
|
|
135
|
+
@out.puts ' --frames-dir DIR Write the PNG frames here and keep them'
|
|
136
|
+
@out.puts ' --verbose Report each command on stderr as it runs'
|
|
137
|
+
@out.puts
|
|
138
|
+
@out.puts 'Subcommands:'
|
|
139
|
+
@out.puts ' generate DESCRIPTION Write a tape by asking a local model; btape generate --help'
|
|
34
140
|
@out.puts
|
|
35
141
|
@out.puts 'Commands:'
|
|
36
142
|
HELP_COMMANDS.each { |command| @out.puts " #{command}" }
|
|
143
|
+
@out.puts
|
|
144
|
+
@out.puts 'Settings:'
|
|
145
|
+
Settings::DEFINITIONS.each_key { |name| @out.puts " #{name}" }
|
|
37
146
|
0
|
|
38
147
|
end
|
|
39
148
|
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
|