capybara-simulated 0.3.0 → 0.5.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: 02f56bfd3215e67c4a8faa799352e0ae3c399557f928926cafcdc9dde7dd2b9c
4
- data.tar.gz: 12e88e441d93de921d0727bfae9e3728cc56d100f8e32e525e37e21a28f0e273
3
+ metadata.gz: 67e9168ef15e413c1d85b1805ea4d68f36cae09578af5fc539bd1613d52ac04d
4
+ data.tar.gz: 6214303ef25079d567bc91db43460eda49a54a292ed08021b98a81ce48af0c31
5
5
  SHA512:
6
- metadata.gz: a1f22b4a5d7ffec1b33080982aa169b5b3356c014d49a399de4cf5b5a430021eec7fb1ba4d624b00769640ffdcd314e55df765afdd44ac90575c88cb137519c9
7
- data.tar.gz: 04d95fd0e7215800124d4d81de11247b814e352c0b511d550bb9bb1557a0af069c1261b52a570e82f6100cab90e07d627b18b7a02267f193dd92c4c98c92e2cd
6
+ metadata.gz: 8112cd3056184946f65739dfb30e76e55bee2a79f1058e3128bc8ae15f0b5790f195d0c289759518e667b69902962e70922af21f48dea6610744ab1093a10ca4
7
+ data.tar.gz: 53ccb66cb2bd4723d1e999ebd03b0a56bdbc83978834f82b66c128aa2c7e3ba1c4f5464c5965442104de94de2fbabe79e185a74f6e1aaa3cd2c2c2c2bfe68a26
data/README.md CHANGED
@@ -2,9 +2,8 @@
2
2
 
3
3
  A lightweight Capybara driver that runs JavaScript against an
4
4
  in-process JS-resident DOM, with no Chrome. Forms submit through
5
- `Rack::MockRequest`, inline `<script>` and event handlers run,
6
- MutationObserver / custom elements / `<template>` / Shadow DOM /
7
- Trix / Stimulus / Turbo all work, and the Capybara DSL is unchanged.
5
+ `Rack::MockRequest`, inline `<script>` and event handlers run, and the
6
+ Capybara DSL is unchanged.
8
7
 
9
8
  The DOM lives entirely inside the JS engine — V8 via
10
9
  [rusty_racer](https://github.com/ursm/rusty_racer) or QuickJS via
@@ -37,35 +36,15 @@ visual layout:
37
36
 
38
37
  **Reach for a real browser** (Selenium / Cuprite) **when** your tests need
39
38
  what this driver doesn't simulate **by design** — there's no rendering
40
- engine or real network stack, the same ground Selenium covers via a real
41
- browser:
42
-
43
- - **Pixel layout** — `getBoundingClientRect()` returns zeros and
44
- `elementFromPoint()` isn't implemented, so visual hit-testing,
45
- coordinate drag-and-drop, and sticky-scroll math don't work.
46
- - **Real networking** `fetch` / XHR are synchronous through Rack: no
47
- streaming, no HTTP concurrency. (`EventSource` and `WebSocket` *do*
48
- work they ride real reader threads / the in-process `rack.hijack`
49
- socket; see below.)
50
- - **Screenshots**.
51
-
52
- **`within_frame` / `switch_to_frame`** work on the V8 (rusty_racer) engine:
53
- each `<iframe>` runs its own scripts in its own per-frame realm, and the DSL
54
- routes finds, reads, interactions, `evaluate_script`, and self-targeted
55
- navigation (a link or form submit inside the frame) into the active frame —
56
- nested frames included. (QuickJS keeps a same-realm fallback, so
57
- `within_frame` is V8-only.)
58
-
59
- **Multiple windows / tabs** work on both engines: each window is its own
60
- Browser + JS VM (own DOM, sessionStorage, history; cookies + localStorage
61
- shared). `open_new_window` / `within_window` / `switch_to_window` /
62
- `window_opened_by` drive them, and JS `window.open` opens a real window,
63
- `window.opener` points back to the opener, and `postMessage` is delivered
64
- across windows. (`target="_blank"` defaults to no-opener, matching modern
65
- browsers; cross-window `postMessage` data is JSON-shaped, not a full
66
- structured clone.)
67
-
68
- See [Known limits](#known-limits) for the full picture.
39
+ engine: **pixel layout** (`getBoundingClientRect()` returns zeros,
40
+ `elementFromPoint()` isn't implemented, so visual hit-testing, coordinate
41
+ drag-and-drop, and sticky-scroll math don't work) and **screenshots**.
42
+
43
+ Most of the rest runs in-process — including the things that usually mean
44
+ "you need a real browser": **`within_frame`**, **multiple windows / tabs**,
45
+ **WebSocket + Action Cable**, **EventSource**, and **Web Workers** all work.
46
+ Each has constraints (JS engine, settle-timing, no layout); see
47
+ [Capabilities & limits](#capabilities--limits).
69
48
 
70
49
  ## Status
71
50
 
@@ -78,10 +57,9 @@ Mastodon / Discourse) runs its full system suite against the driver in
78
57
  [capybara-simulated-vs-world](https://github.com/ursm/capybara-simulated-vs-world)
79
58
  as an integration check.
80
59
 
81
- The remaining gaps need a real layout engine (`elementFromPoint`,
82
- truthy `getBoundingClientRect`, viewport-clip visibility, `display:
83
- contents` table edge cases) — the same set Selenium escapes via
84
- screenshots and this driver deliberately doesn't simulate.
60
+ The remaining gaps are the layout / pixel-geometry features the driver
61
+ deliberately doesn't simulate the same set Selenium escapes via
62
+ screenshots (see [Capabilities & limits](#capabilities--limits)).
85
63
 
86
64
  ## Install
87
65
 
@@ -222,11 +200,18 @@ end
222
200
 
223
201
  ### File output
224
202
 
225
- Set `CSIM_TRACE_DIR=/path/to/dir` to enable file output. The bundled
226
- RSpec hook ([`csim_rspec.rb`](https://github.com/ursm/capybara-simulated-vs-world/blob/main/support/csim_rspec.rb))
227
- writes `<example slug>.json` into that directory after each test;
228
- mirror it in `application_system_test_case.rb`'s teardown for
229
- Minitest.
203
+ Require the test-framework integration in your `spec_helper` /
204
+ `rails_helper` (RSpec) or `test_helper` /
205
+ `application_system_test_case.rb` (Minitest):
206
+
207
+ ```ruby
208
+ require 'capybara/simulated/rspec' # RSpec
209
+ require 'capybara/simulated/minitest' # Minitest / Rails system tests
210
+ ```
211
+
212
+ With `CSIM_TRACE_DIR=/path/to/dir` set, each example that recorded a
213
+ trace is written to `<dir>/<slug>.json` after it runs; both integrations
214
+ are inert when the env var is unset.
230
215
 
231
216
  ```sh
232
217
  CSIM_TRACE_DIR=tmp/csim-traces bundle exec rspec spec/system
@@ -236,6 +221,31 @@ The metadata block on each trace includes `title`, `file`, `outcome`
236
221
  (`passed` / `failed`), and the exception message — enough to index a
237
222
  CI artifact directory by failure.
238
223
 
224
+ ### Viewing traces
225
+
226
+ The recorded JSON stays plain data; to look at one, render it into a
227
+ self-contained HTML viewer with the bundled CLI:
228
+
229
+ ```sh
230
+ capybara-simulated trace tmp/csim-traces/checkout_flow.json
231
+ # wrote /tmp/checkout_flow.html (then opens it in your browser)
232
+ ```
233
+
234
+ By default the HTML is written to a temp file and opened in your
235
+ browser. The viewer works straight from `file://` — the trace JSON is
236
+ embedded inline, so no server is needed — and shows a step-by-step UI: a
237
+ timeline of actions, and per step the URL before/after, console output,
238
+ network requests, the error, and a rendered preview of the post-action
239
+ DOM snapshot. Its **Load JSON…** button / drag-and-drop swaps in any
240
+ other trace file.
241
+
242
+ `-o PATH` writes the HTML somewhere specific (`-o -` to stdout);
243
+ `--no-open` skips launching the browser. Browser launching uses
244
+ [launchy](https://rubygems.org/gems/launchy) when it's installed
245
+ (`gem 'launchy'`, recommended for reliable cross-platform / WSL opening)
246
+ and falls back to the platform opener (`xdg-open` / `open` / `start`)
247
+ otherwise.
248
+
239
249
  ### Programmatic
240
250
 
241
251
  For finer control, call `driver.start_tracing(...)` /
@@ -290,21 +300,6 @@ isolate whose context is reset to a clean realm per navigation
290
300
  snapshot-loaded VM out of a small pre-warmed pool. Either way, every
291
301
  navigation lands on a clean, warm JS context near-instantly.
292
302
 
293
- **Wall time is sensitive to whether the app uses Turbo Drive**,
294
- because navigation simulates real-browser semantics:
295
-
296
- | navigation source | what happens |
297
- |---|---|
298
- | `visit(...)`, `refresh`, programmatic `location.assign` | full reload — fresh JS Context, scripts re-evaluated |
299
- | link click *with Turbo Drive loaded* | Turbo intercepts, body-swap via JS, **JS context preserved** |
300
- | link click *without Turbo Drive* | full reload (anchor default action) |
301
- | form submit *with Turbo Drive loaded* | Turbo intercepts (turbo-frame or page-level), body-swap |
302
- | form submit *without Turbo Drive* | full reload |
303
-
304
- So Turbo Drive apps stay fast even with click-heavy tests; non-Turbo
305
- apps pay full-reload cost per click — exactly mirroring what the
306
- production site does.
307
-
308
303
  ### Library snapshot policy
309
304
 
310
305
  Per visit, `<script src>`-referenced libraries (jQuery, Stimulus,
@@ -335,67 +330,64 @@ referenced page-specific DOM.
335
330
  for 2 s; the callback fires once polling has advanced the clock past
336
331
  it.
337
332
 
338
- ## Known limits
339
-
340
- - **No layout engine.** `visible?` and `Node#style` consult the CSS
341
- cascade and the inline `style` attribute, but
342
- `getBoundingClientRect()` returns zeros and `elementFromPoint()`
343
- isn't implemented. Click offsets work for fixture-style absolute /
344
- relative positioning (ancestor-summed `top`/`left`); position-via-
345
- layout (Dragula drops, sticky-header scroll math) needs a real
346
- browser.
347
- - **`:hover` / `:focus-within`-gated content** is reachable two ways:
348
- call `element.hover` explicitly (we track the most-recently-hovered
349
- element and propagate `:hover` up its chain), or rely on the
350
- candidate-chain fallback (when stateless cascade reports
351
- `display: none`, we re-evaluate with the candidate itself in the
352
- `:hover` set). Symmetric peers — N rows each with `tr:hover .icon`
353
- revealing `.icon`, queried as bare `find('.icon')` — reveal all and
354
- Capybara raises `Capybara::Ambiguous`. Scope the test (`find('tr',
355
- text: 'foo').hover` then `find('.icon')`) — also more robust
356
- against real-browser flake.
357
- - **`fetch` is synchronous-via-Rack** — HTML / JSON round-trips work
358
- but there's no real network, no streaming, no `Request#body`
359
- ReadableStream, and no concurrent requests. XHR is implemented
360
- with the same Rack pass-through.
361
- - **WebSocket** works in-process: `new WebSocket(url)` rides the
362
- `rack.hijack` socket the Rack app hijacks, with a hand-rolled RFC6455
363
- client (handshake + subprotocol negotiation, masked client frames,
364
- ping/pong, close handshake). Frames deliver as `message` events when
365
- the page next settles, like SSE. **Action Cable** works end-to-end on
366
- this: the real `@rails/actioncable` consumer connects, subscribes, and
367
- receives server broadcasts (so `turbo_stream_from` live updates are
368
- reachable) — Action Cable hijacks the connection just as csim drives
369
- it. Caveats: server pushes land at settle (not instant); the app must
370
- use the **async / in-process** Cable adapter (a real Redis adapter
371
- would need real Redis); binary frames are V8-only (QuickJS corrupts
372
- raw bytes across the host boundary — text, hence Action Cable, is fine
373
- on both). `EventSource` and Web Workers are likewise implemented.
374
- - **Screenshots and drag pixel coordinates** are out of scope by
375
- design — use Selenium / Cuprite.
376
- - **`within_frame` / `switch_to_frame`** work on the V8 engine: each
377
- `<iframe>` runs in its own per-frame realm and the DSL routes finds,
378
- reads, interactions, `evaluate_script`, and self-targeted navigation
379
- (link / form submit) into the active frame (nested frames included) — the
380
- frame's realm is rebuilt from the fetched document, leaving the top page
381
- untouched. `_top` navigates the main page; a `_parent` target from a
382
- frame nested ≥2 levels falls back to navigating the main page, and
383
- cross-origin frame locality resolves against the main origin. QuickJS has
384
- no nested browsing context, so `within_frame` raises there.
385
- - **Multiple windows / tabs** work on both engines: each window is its own
333
+ ## Capabilities & limits
334
+
335
+ Most features run in-process; the notes below are mostly "works, but…",
336
+ followed by the short list of things that need a real browser **by design**.
337
+
338
+ ### Works, with constraints
339
+
340
+ - **`within_frame` / `switch_to_frame`** (V8 engine) each `<iframe>` runs
341
+ its own scripts in its own per-frame realm; the DSL routes finds, reads,
342
+ interactions, `evaluate_script`, and navigation into the active frame,
343
+ nested frames included the target frame's realm is rebuilt from the
344
+ fetched document, the top page untouched. QuickJS has no nested browsing
345
+ context, so `within_frame` raises there.
346
+ - **Multiple windows / tabs** (both engines) each window is its own
386
347
  Browser + JS VM (own DOM, sessionStorage, history; cookies + localStorage
387
- shared across windows). `open_new_window` / `within_window` /
388
- `switch_to_window` / `window_opened_by` drive them; JS `window.open` opens
389
- a real window, `window.opener` links back, and `postMessage` is routed
390
- across windows (delivered as a `message` event when the target window next
391
- settles). Caveats: `target="_blank"` opens with no opener (modern-browser
392
- no-opener default); cross-window `postMessage` data is JSON-shaped, not a
393
- full structured clone (no `DataCloneError`, `undefined`→`null`) but a
394
- buffer in the `transfer` list moves **zero-copy** (its backing store crosses
395
- isolates by token and the source is detached); and only the active window's
396
- event loop runs, so a message is delivered when you switch to its window.
397
- Window viewport APIs (`maximize` / `fullscreen` /
398
- pixel-exact `resize_to`) are no-opsno layout engine.
348
+ shared). `open_new_window` / `within_window` / `switch_to_window` /
349
+ `window_opened_by` drive them; JS `window.open` opens a real window,
350
+ `window.opener` links back, and `postMessage` crosses windows. Only the
351
+ active window's event loop runs, so a message is delivered when you switch
352
+ to its window. `target="_blank"` opens with no opener (modern-browser
353
+ default). `postMessage` carries real structured data (not a lossy JSON
354
+ hop) `Map` / `Set` / `Date` / `BigInt` / typed arrays / cyclic graphs all
355
+ round-trip on V8 — and a `transfer`-list buffer moves **zero-copy** (backing
356
+ store by token, source detached); only bare `undefined` collapses to `null`
357
+ (Ruby has no distinct `undefined`). Window viewport APIs (`maximize` /
358
+ `fullscreen` / pixel-exact `resize_to`) are no-ops (no layout engine).
359
+ - **WebSocket + Action Cable** `new WebSocket(url)` works in-process over
360
+ the `rack.hijack` socket the Rack app hijacks (hand-rolled RFC6455, including
361
+ subprotocol negotiation). The real `@rails/actioncable` consumer connects,
362
+ subscribes, and receives broadcasts, so `turbo_stream_from` live updates
363
+ work. Constraints: server
364
+ pushes land at settle (not instant); the Cable app must use the **async /
365
+ in-process** adapter (a real Redis adapter needs real Redis); binary frames
366
+ are V8-only (QuickJS corrupts raw bytes across the host boundary — text,
367
+ hence Action Cable, works on both engines). `EventSource` and Web Workers
368
+ are likewise real (background reader threads draining at settle).
369
+ - **`fetch` / XHR** — synchronous through Rack: HTML / JSON round-trips work,
370
+ but there's no streaming, no `Request#body` ReadableStream, and no
371
+ concurrent requests.
372
+ - **`:hover` / `:focus-within`-gated content** — reachable two ways: call
373
+ `element.hover` explicitly (we track the most-recently-hovered element and
374
+ propagate `:hover` up its chain), or rely on the candidate-chain fallback
375
+ (when the stateless cascade reports `display: none`, we re-evaluate with the
376
+ candidate itself in the `:hover` set). Symmetric peers — N rows each with
377
+ `tr:hover .icon` revealing `.icon`, queried as a bare `find('.icon')` —
378
+ reveal all and Capybara raises `Capybara::Ambiguous`; scope the test
379
+ (`find('tr', text: 'foo').hover` then `find('.icon')`), which is also more
380
+ robust against real-browser flake.
381
+
382
+ ### Out of scope (by design — use Selenium / Cuprite)
383
+
384
+ - **Layout / pixel geometry.** `visible?` and `Node#style` consult the CSS
385
+ cascade and the inline `style` attribute, but `getBoundingClientRect()`
386
+ returns zeros and `elementFromPoint()` isn't implemented. Click offsets work
387
+ for fixture-style absolute / relative positioning (ancestor-summed
388
+ `top`/`left`); position-via-layout (Dragula drops, sticky-header scroll math,
389
+ viewport-clip visibility) needs a real browser.
390
+ - **Screenshots.**
399
391
 
400
392
  ## Architecture
401
393
 
@@ -429,39 +421,6 @@ referenced page-specific DOM.
429
421
  `(handle_id, context_gen)` pair so a handle from a pre-rebuild
430
422
  Context can't ghost into the next one.
431
423
 
432
- ## ES modules + importmap
433
-
434
- `<script type="module">` and `<script type="importmap">` work the
435
- same way they do in a real browser: bare specifiers resolve through
436
- the importmap, relative paths resolve against the importer's URL,
437
- and every load (including dynamic `import(...)`) routes back through
438
- the in-process Rack app. No bundling step, no Node toolchain.
439
-
440
- The standard importmap-rails layout works as-is:
441
-
442
- ```erb
443
- <%= javascript_importmap_tags %>
444
- <!-- emits:
445
- <script type="importmap">{ "imports": { "application": "/assets/application-...js", ... } }</script>
446
- <script type="module">import "application"</script>
447
- -->
448
- ```
449
-
450
- ## Hotwire (Stimulus + Turbo)
451
-
452
- Stimulus and Turbo work both via UMD (classic `<script src>`) and via
453
- the standard ESM bundles imported through importmap. For
454
- importmap-rails apps, no changes are needed:
455
-
456
- ```ruby
457
- # config/importmap.rb
458
- pin '@hotwired/stimulus'
459
- pin '@hotwired/turbo'
460
- ```
461
-
462
- `window.fetch` routes through Rack, so Turbo's frame fetch and
463
- link-action POSTs round-trip the test app.
464
-
465
424
  ## License
466
425
 
467
426
  [MIT](LICENSE).
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # capybara-simulated CLI. Currently one subcommand:
5
+ #
6
+ # capybara-simulated trace TRACE.json [-o OUT.html] [--no-open]
7
+ #
8
+ # Renders a trace JSON (produced by CSIM_TRACE_DIR / driver.stop_tracing)
9
+ # into a self-contained HTML viewer and opens it in your browser. The
10
+ # viewer embeds the JSON inline so it opens straight from file:// — no
11
+ # server, unlike a fetch/import-based loader (CORS blocks those for
12
+ # file:// origins). The persisted trace stays plain JSON; this turns one
13
+ # into a UI on demand.
14
+
15
+ require 'optparse'
16
+ require 'tmpdir'
17
+
18
+ # Prefer the library co-located with this executable. In an installed
19
+ # gem that's the gem's own lib (correct version); in a checkout it's the
20
+ # working tree — so `ruby exe/capybara-simulated` doesn't accidentally
21
+ # load an older *installed* capybara-simulated that predates this CLI.
22
+ lib = File.expand_path('../lib', __dir__)
23
+ $LOAD_PATH.unshift(lib) if File.directory?(lib) && !$LOAD_PATH.include?(lib)
24
+ require 'capybara/simulated/trace'
25
+
26
+ PROG = 'capybara-simulated'
27
+
28
+ # `file://` URL for a local path, percent-encoding each segment so a
29
+ # path with spaces / special chars stays a valid URI for launchy.
30
+ def file_url(path)
31
+ require 'cgi'
32
+ 'file://' + path.split('/').map {|seg| CGI.escapeURIComponent(seg) }.join('/')
33
+ end
34
+
35
+ # Hand the raw path (not a URL) to the platform opener; `system`'s array
36
+ # form needs no shell-quoting, so spaces are fine.
37
+ def open_with_platform(path)
38
+ cmd =
39
+ case RbConfig::CONFIG['host_os']
40
+ when /darwin/ then ['open', path]
41
+ when /mswin|mingw/ then ['cmd', '/c', 'start', '', path]
42
+ else ['xdg-open', path] # Linux / *BSD
43
+ end
44
+ system(*cmd)
45
+ rescue StandardError
46
+ false
47
+ end
48
+
49
+ # Open a local file in the default browser. Prefer launchy (handles
50
+ # macOS / Linux / Windows / WSL) when installed; on either a missing gem
51
+ # OR a launchy runtime failure (headless box, no opener), fall back to
52
+ # the platform opener.
53
+ def open_in_browser(path)
54
+ begin
55
+ require 'launchy'
56
+ Launchy.open(file_url(path))
57
+ return true
58
+ rescue LoadError
59
+ # launchy not installed
60
+ rescue StandardError
61
+ # launchy present but failed — try the platform opener below
62
+ end
63
+ open_with_platform(path)
64
+ end
65
+
66
+ def cmd_trace(argv)
67
+ out = nil
68
+ open = nil # nil → default (open), unless writing to stdout
69
+ parser = OptionParser.new do |o|
70
+ o.banner = "Usage: #{PROG} trace [options] TRACE.json\n\n" \
71
+ "Render TRACE.json into a self-contained HTML viewer and open it\n" \
72
+ "in your browser. By default the HTML goes to a temp file."
73
+ o.on('-o', '--output PATH', "write HTML to PATH instead of a temp file ('-' = stdout)") {|v| out = v }
74
+ o.on('--[no-]open', 'open the HTML in your browser (default: yes)') {|v| open = v }
75
+ o.on('-h', '--help') do
76
+ puts o
77
+ exit
78
+ end
79
+ end
80
+ begin
81
+ parser.parse!(argv)
82
+ rescue OptionParser::ParseError => e
83
+ warn e.message
84
+ warn parser.help
85
+ exit 1
86
+ end
87
+
88
+ input = argv.shift
89
+ unless input
90
+ warn parser.help
91
+ exit 1
92
+ end
93
+ unless File.file?(input)
94
+ warn "#{PROG}: no such file: #{input}"
95
+ exit 1
96
+ end
97
+
98
+ json = File.read(input)
99
+ begin
100
+ require 'json'
101
+ JSON.parse(json) # fail fast on a non-JSON / corrupt trace
102
+ rescue JSON::ParserError => e
103
+ warn "#{PROG}: #{input} is not valid JSON: #{e.message}"
104
+ exit 1
105
+ end
106
+
107
+ html = Capybara::Simulated::Trace.render_viewer(json)
108
+
109
+ if out == '-'
110
+ $stdout.write(html)
111
+ return
112
+ end
113
+ # Default output: a temp file named after the trace (re-runs overwrite).
114
+ out ||= File.join(Dir.tmpdir, File.basename(input).sub(/\.json\z/, '') + '.html')
115
+ File.write(out, html)
116
+ warn "wrote #{out}"
117
+ open_in_browser(File.expand_path(out)) unless open == false
118
+ end
119
+
120
+ def main(argv)
121
+ case argv.first
122
+ when 'trace'
123
+ cmd_trace(argv.drop(1))
124
+ when '-v', '--version'
125
+ require 'capybara/simulated/version'
126
+ puts Capybara::Simulated::VERSION
127
+ when nil, '-h', '--help'
128
+ puts <<~USAGE
129
+ Usage: #{PROG} <command> [options]
130
+
131
+ Commands:
132
+ trace TRACE.json render a trace JSON into a self-contained HTML viewer
133
+
134
+ Run `#{PROG} trace --help` for command options.
135
+ USAGE
136
+ else
137
+ warn "#{PROG}: unknown command: #{argv.first}"
138
+ warn "Run `#{PROG} --help`."
139
+ exit 1
140
+ end
141
+ end
142
+
143
+ main(ARGV)