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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 83f37a580ea032f35ec04083a301339d43bb35bd13881799774d275527558e43
4
- data.tar.gz: 6b47a03f994310102fc3534c621d7fe78e782e6ab39653a6c227b56838ba17c0
3
+ metadata.gz: f7ea547bca5c9c270e16d779e170c68b0ab30b8bc4156cde58d4cfe7560ebbe4
4
+ data.tar.gz: fb55fff428c21bfbc889aa653708a1d02c77ffe795c515238716ad9e246fecf6
5
5
  SHA512:
6
- metadata.gz: 493ebb303b92ba896387e67236c61deaf5cfaed5ba746990e67c9be9ad06d88d1ea34985d3719a87b6edfbbafe06c59b8d052720b05471fc1215589db23da43d
7
- data.tar.gz: b5cf6dcfc0995a623f080f30bd5903dc8fed6f456d42f697534a720494d7c8418bcfdeb3917cb0c4537120216382578456642671b849d07b2365c486e9f0a329
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
- 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.
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 Chromium.
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
- ## MVP limitations
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
- 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`.
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
- def initialize(out: $stdout, err: $stderr, runner: Runner.new)
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 print_help if argv.empty? || %w[help -h --help].include?(argv.first)
18
- raise Error, 'usage: btape SCRIPT.tape' unless argv.length == 1
32
+ return generate(argv.drop(1)) if argv.first == GENERATE
19
33
 
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}"
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 'Usage: btape SCRIPT.tape'
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