unmagic-browser 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1f442f73a1d9529c04c844c02cae4d3baeef123ae4ed73abe666f2de077d555c
4
+ data.tar.gz: b3b0f32e366f5c361fff8f883ace120af199b30d0ac8c6354d42993fe5a1f9e8
5
+ SHA512:
6
+ metadata.gz: 0bcd7c33a2504e7f91e565ae10099f040eb2f764f29cafa5d8cd7f890ca6d2241031a7ee820b0bc530766d94d4ba173ecbdc3afd660a55bef813dfb4f4663d6b
7
+ data.tar.gz: 916197bf8151333633b6a7a66682393dda30ccd01cc321ba330b9cf4ba6ad27b33b047821d6c20cbc8c1dbbaf2dc0ac1067242293040fa92aacab04f25f262eb
data/CHANGELOG.md ADDED
@@ -0,0 +1,51 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0]
11
+
12
+ ### Added
13
+ - **One-off page grabs** — `markdown`, `html`, `screenshot` and `pdf` straight on
14
+ the browser, each taking the cheapest path its driver offers: Cloudflare's REST
15
+ rendering API where there is one, a throwaway page on a shared browser otherwise.
16
+ - **Sessions** — `browser.open(url)` for a live page driven step by step, with
17
+ Capybara-flavoured verbs (`fill_in`, `click_button`, `select`, `check`,
18
+ `within`, `wait_for`, …) that auto-wait for their element.
19
+ - **Smart locators** — a plain string is resolved by label, then visible text,
20
+ then ARIA role, falling back to CSS, so a caller that has never seen the page's
21
+ markup can still drive it. `css:`, `text:`, `label:` and `role:` name a
22
+ strategy explicitly.
23
+ - **Sessions that outlive one call** — `open` without a block hands the session
24
+ back to keep, so an agent can open a page in one turn and act on it in the next.
25
+ Operations are serialised, and `#lock` makes a whole sequence atomic.
26
+ - **Emulation** — colour scheme, reduced motion, viewport, device, user agent,
27
+ locale, timezone, geolocation and media type in one `emulate:` config, settable
28
+ when a session opens, live with `session.emulate`, or scoped to a block. What
29
+ you leave out renders at a fixed default rather than at the host machine's
30
+ preference, so a page looks the same on a laptop and on CI.
31
+ - **Three drivers** — `Local` (Chrome on this machine, `headless: false` to watch
32
+ it), `Remote` (any CDP websocket — browserless, your own pool) and `Cloudflare`
33
+ (Browser Rendering, so no browser runs on your servers).
34
+ - **Typed errors** — `TimedOut`, `Unreachable`, `RenderFailed`, `RateLimited`,
35
+ `Misconfigured`, `NotFound` and `SessionClosed`, so failures are classified by
36
+ rescuing rather than by parsing a message.
37
+ - **An escape hatch** — `browser.driver` and `session.driver` hand back the
38
+ underlying driver and the live `Puppeteer::Page` for anything the curated API
39
+ doesn't wrap.
40
+ - **A console** — `require "unmagic/browser/console"` and
41
+ `Unmagic::Browser::Console.start` for a browser you can type at: the verbs as
42
+ bare words (`open "https://example.com"; click "Sign in"; screenshot`) driving
43
+ one current session. Results are rendered rather than inspected — source
44
+ syntax-highlighted with [rouge](https://github.com/rouge-ruby/rouge),
45
+ screenshots drawn in the terminal over the [kitty graphics
46
+ protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/) at half its
47
+ width (`Graphics.scale` to change that) and written full-size to /tmp,
48
+ sessions and elements summarised. `start(prompt: :page)` for a prompt that
49
+ says what's open — a green dot and the page's title — instead of `browser>`.
50
+ Not required by default, and neither rouge nor a graphics-capable terminal is
51
+ a dependency — without them it runs monochrome and prints paths.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Keith Pitt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,372 @@
1
+ # Unmagic::Browser
2
+
3
+ A real browser you can drive from Ruby — for one-off page grabs *and* multi-step
4
+ automation. It runs locally in development and against [Cloudflare Browser
5
+ Rendering](https://developers.cloudflare.com/browser-rendering/) (or any remote Puppeteer
6
+ endpoint) in production, behind one API. Built so an LLM agent — or plain Ruby code — can
7
+ browse the web the way a person does.
8
+
9
+ ```ruby
10
+ browser = Unmagic::Browser.new
11
+
12
+ # one-off: grab a JS-rendered page as Markdown
13
+ browser.markdown("https://example.com")
14
+
15
+ # multi-step: log in, search, read the result
16
+ browser.open("https://news.ycombinator.com/login") do |session|
17
+ session.fill_in "acct", with: "pg"
18
+ session.fill_in "pw", with: ENV["HN_PASSWORD"]
19
+ session.click_button "login"
20
+ session.wait_for text: "logout"
21
+ session.screenshot
22
+ end
23
+ ```
24
+
25
+ ## Installation
26
+
27
+ ```ruby
28
+ gem "unmagic-browser"
29
+ ```
30
+
31
+ Bundler requires it for you. Outside Bundler, the gem's name maps to its path in the usual
32
+ way:
33
+
34
+ ```ruby
35
+ require "unmagic/browser"
36
+ ```
37
+
38
+ ## Drivers
39
+
40
+ The same API runs against three drivers. Pick one when you construct the browser; nothing
41
+ else in your code changes. A symbol builds the driver with its defaults — when a driver
42
+ needs options, construct it yourself and pass the instance.
43
+
44
+ ```ruby
45
+ # A symbol builds the driver with no arguments…
46
+ Unmagic::Browser.new(driver: :local) # same as driver: Unmagic::Browser::Driver::Local.new
47
+ Unmagic::Browser.new(driver: :remote)
48
+ Unmagic::Browser.new(driver: :cloudflare)
49
+
50
+ # …pass an instance to hand a driver options.
51
+
52
+ # Local Chrome — great for development. headless: false opens a window you can watch.
53
+ Unmagic::Browser.new(driver: Unmagic::Browser::Driver::Local.new(headless: false))
54
+
55
+ # Any remote Puppeteer / CDP websocket — browserless, your own pool, etc.
56
+ Unmagic::Browser.new(driver: Unmagic::Browser::Driver::Remote.new(ws: "ws://browserless.internal:3000"))
57
+
58
+ # Cloudflare Browser Rendering — no browser runs on your servers.
59
+ Unmagic::Browser.new(driver: Unmagic::Browser::Driver::Cloudflare.new(
60
+ account_id: ENV["CF_ACCOUNT_ID"],
61
+ token: ENV["CF_BROWSER_TOKEN"],
62
+ ))
63
+ ```
64
+
65
+ With no arguments, `Unmagic::Browser.new` uses `:local` in development and the configured
66
+ [`default_driver`](#configuration) everywhere else.
67
+
68
+ ## One-off page grabs
69
+
70
+ For "just get me this page", call straight on the browser. Each call takes the cheapest path
71
+ its driver offers: the REST rendering API on Cloudflare (no browser to hold open), a
72
+ throwaway page on local and remote.
73
+
74
+ ```ruby
75
+ browser.markdown("https://example.com") # => String (clean text)
76
+ browser.html("https://example.com") # => String (fully JS-rendered HTML)
77
+ browser.screenshot("https://example.com") # => PNG bytes
78
+ browser.screenshot("https://example.com",
79
+ full_page: true, # the whole scrollable page
80
+ selector: "#main") # or a single element
81
+ browser.pdf("https://example.com") # => PDF bytes
82
+ ```
83
+
84
+ One-offs always render with default settings. To capture a page in dark mode, on a phone
85
+ viewport, or in another locale, open a session and use
86
+ [emulation](#emulation-dark-mode-viewport-devices-locale).
87
+
88
+ On local and remote the throwaway pages share one browser, started on first use and shut
89
+ down when the process exits. `browser.close` lets it go sooner; sessions are unaffected,
90
+ because each holds its own.
91
+
92
+ ## Sessions
93
+
94
+ A **session** is a live browser page you drive step by step. `browser.open` works like
95
+ `File.open`: give it a URL and a block, and you get a session — already on that page —
96
+ that's cleaned up when the block ends.
97
+
98
+ ```ruby
99
+ browser.open("https://store.example.com") do |session|
100
+ session.fill_in "Search", with: "espresso"
101
+ session.click_button "Go"
102
+ session.wait_for text: "results"
103
+
104
+ session.all(".product").map { |el| el.text }
105
+ end
106
+ ```
107
+
108
+ `Unmagic::Browser.open(url)` is shorthand for `Unmagic::Browser.new.open(url)`.
109
+
110
+ Call `open` **without** a block and it returns the session for you to keep — see
111
+ [sessions that outlive one call](#sessions-that-outlive-one-call).
112
+
113
+ ### Verbs
114
+
115
+ The API reads like [Capybara](https://github.com/teamcapybara/capybara), so it's instantly
116
+ familiar.
117
+
118
+ ```ruby
119
+ # navigate
120
+ session.visit(url)
121
+ session.reload
122
+ session.go_back
123
+ session.go_forward
124
+ session.current_url
125
+ session.title
126
+
127
+ # act
128
+ session.fill_in("Email", with: "me@example.com")
129
+ session.click_button("Sign in")
130
+ session.click_link("Forgot password?")
131
+ session.click("Anything at all")
132
+ session.select("Australia", from: "Country")
133
+ session.check("Remember me")
134
+ session.uncheck("Send me email")
135
+ session.choose("Express shipping")
136
+ session.attach_file("Résumé", "/path/to/cv.pdf")
137
+ session.press(:enter)
138
+ session.hover("Profile")
139
+
140
+ # read
141
+ session.text
142
+ session.html
143
+ session.markdown
144
+ session.value("Email")
145
+ session.screenshot
146
+ session.pdf
147
+ session.evaluate("document.title")
148
+
149
+ # wait & ask
150
+ session.wait_for(text: "Welcome")
151
+ session.wait_for(selector: ".loaded")
152
+ session.has_text?("Welcome")
153
+ session.has_button?("Sign in")
154
+ session.has_link?("Log out")
155
+ session.has_field?("Email")
156
+ session.has_selector?(".row")
157
+
158
+ # scope
159
+ session.find(".cart")
160
+ session.all(".product")
161
+ session.within(".checkout") { session.click_button("Pay") }
162
+ ```
163
+
164
+ Verbs **auto-wait**: `click_button "Sign in"` keeps looking until the button appears and is
165
+ actionable, up to a timeout — so you rarely need an explicit `wait_for`.
166
+
167
+ Questions don't. `has_button?` answers about the page as it stands and returns straight
168
+ away, because a caller asking whether a challenge appeared wants "no" now, not in ten
169
+ seconds. When you *mean* to wait, say so with `wait_for`.
170
+
171
+ ### Smart locators
172
+
173
+ You don't have to hand-write CSS. A plain string is resolved by **label**, then visible
174
+ **text**, then ARIA **role**, falling back to CSS — whichever matches. That's what keeps the
175
+ API working when the caller (an LLM, say) doesn't know the page's markup.
176
+
177
+ ```ruby
178
+ session.fill_in "Email", with: "me@example.com" # finds <label>Email</label> → its input
179
+ session.click_button "Sign in" # finds the button reading "Sign in"
180
+ ```
181
+
182
+ Be explicit when you want to be:
183
+
184
+ ```ruby
185
+ session.fill_in css: "#user_email", with: "me@example.com"
186
+ session.click_button text: "Sign in"
187
+ session.find label: "Date of birth"
188
+ ```
189
+
190
+ ### Emulation (dark mode, viewport, devices, locale…)
191
+
192
+ Everything about *how the page renders* — colour scheme, viewport, device, locale, timezone —
193
+ lives in one `emulate:` config, set in exactly two places: passed to `open`, or changed live
194
+ with `session.emulate`.
195
+
196
+ ```ruby
197
+ browser.open("https://example.com",
198
+ emulate: { color_scheme: :dark,
199
+ viewport: { width: 1440, height: 900 } }) do |session|
200
+ session.screenshot # dark mode, at 1440×900
201
+ end
202
+ ```
203
+
204
+ The full set of knobs — valid as keys in any `emulate:` hash, or as a reusable object:
205
+
206
+ ```ruby
207
+ emulation = Unmagic::Browser::Emulation.new(
208
+ color_scheme: :dark, # :light | :dark | :no_preference
209
+ reduced_motion: :reduce, # :reduce | :no_preference
210
+ viewport: { width: 390, height: 844,
211
+ scale: 2, mobile: true, touch: true }, # scale = device pixel ratio
212
+ device: "iPhone 13", # shortcut: sets viewport + UA + touch
213
+ user_agent: "MyBot/1.0",
214
+ locale: "en-AU", # Accept-Language
215
+ timezone: "Australia/Sydney",
216
+ geolocation: { latitude: -33.87, longitude: 151.21 },
217
+ media: :print, # :screen | :print (handy before #pdf)
218
+ )
219
+
220
+ # build it once, reuse it across sessions
221
+ browser.open(url, emulate: emulation) { |session| ... }
222
+
223
+ # change it live — applies to everything after
224
+ session.emulate(color_scheme: :light)
225
+ session.emulate(viewport: { width: 1280, height: 800 })
226
+
227
+ # or scope a change to a block — reverts when the block ends
228
+ session.emulate(color_scheme: :dark) do
229
+ session.screenshot # dark…
230
+ end
231
+ session.screenshot # …light again
232
+ ```
233
+
234
+ Whatever you leave out renders at a fixed default — desktop viewport, light, screen media —
235
+ not at whatever the machine underneath prefers. Headless Chrome inherits the host's
236
+ appearance if you let it, which would mean the same page rendering dark on a laptop after
237
+ sunset and light on CI. Pass `color_scheme: :no_preference` if you actually want the host's.
238
+
239
+ Niche emulation (CPU/network throttling, offline, vision-deficiency simulation) isn't
240
+ wrapped — reach it through the [escape hatch](#escape-hatch).
241
+
242
+ ## Sessions that outlive one call
243
+
244
+ A block session is perfect inside a single method. An LLM agent, though, works *turn by
245
+ turn* — open a page in one call, act on it in the next, let a human step in, then continue.
246
+ So `open` without a block hands you the session to keep. It's a plain object; hold it
247
+ wherever your app keeps state.
248
+
249
+ ```ruby
250
+ session = browser.open("https://example.com/login")
251
+ session.fill_in "Email", with: "me@example.com"
252
+ session.click_button "Continue"
253
+
254
+ # ...later, same object, same live browser...
255
+ session.fill_in "Password", with: secret
256
+ session.click_button "Sign in"
257
+ session.text
258
+
259
+ session.close
260
+ ```
261
+
262
+ A kept session holds a real browser — and on Cloudflare, one of a limited number of
263
+ concurrent slots — until you `close` it.
264
+
265
+ Operations on a session are serialized, so two concurrent callers can't interleave a
266
+ keystroke. When a *sequence* must be atomic against other callers, wrap it:
267
+
268
+ ```ruby
269
+ session.lock do
270
+ session.fill_in "Amount", with: "100"
271
+ session.click_button "Transfer"
272
+ end
273
+ ```
274
+
275
+ ### Human in the loop (captcha / 2FA)
276
+
277
+ Because a kept session stays live between calls, a human can step in mid-flow: submit the
278
+ login, detect a challenge, show the person a screenshot, and resume once they've answered —
279
+ all against the same session.
280
+
281
+ ```ruby
282
+ session = browser.open("https://example.com/login")
283
+ session.fill_in "Email", with: "me@example.com"
284
+ session.fill_in "Password", with: secret
285
+ session.click_button "Sign in"
286
+
287
+ if session.has_text?("Enter the code we texted you")
288
+ # ...show session.screenshot to a person, collect the code...
289
+ end
290
+
291
+ # later, once the human has answered — same session, still live:
292
+ session.fill_in "Code", with: human_supplied_code
293
+ session.click_button "Verify"
294
+ ```
295
+
296
+ For visual captchas, use a local driver with `headless: false` and let the person click the
297
+ real window, then keep driving.
298
+
299
+ ## Errors
300
+
301
+ Failures are typed, so you classify them by rescuing — never by parsing a message.
302
+
303
+ ```ruby
304
+ begin
305
+ browser.markdown(url)
306
+ rescue Unmagic::Browser::Error::TimedOut # the page took too long
307
+ rescue Unmagic::Browser::Error::Unreachable # couldn't reach the page or the rendering service
308
+ rescue Unmagic::Browser::Error::RenderFailed # the service answered but couldn't render
309
+ rescue Unmagic::Browser::Error::RateLimited # hit a concurrency / rate limit
310
+ rescue Unmagic::Browser::Error::Misconfigured # missing credentials / config
311
+ rescue Unmagic::Browser::Error::NotFound # nothing on the page matched a locator
312
+ rescue Unmagic::Browser::Error::SessionClosed # the session has been closed
313
+ rescue Unmagic::Browser::Error # base class for all of the above
314
+ end
315
+ ```
316
+
317
+ `NotFound` is a `TimedOut`, because locators auto-wait: "not found" always means "still not
318
+ there when we ran out of patience".
319
+
320
+ ## Configuration
321
+
322
+ ```ruby
323
+ Unmagic::Browser.configure do |config|
324
+ # used when Unmagic::Browser.new is called without a driver — a symbol or an instance
325
+ config.default_driver = Unmagic::Browser::Driver::Cloudflare.new(
326
+ account_id: ENV["CF_ACCOUNT_ID"],
327
+ token: ENV["CF_BROWSER_TOKEN"],
328
+ )
329
+
330
+ # how long a locator keeps looking, and a `wait_for` waits, before giving up
331
+ config.timeout = 10
332
+
333
+ # how long a single navigation is given — longer, because a cold page on a
334
+ # slow network legitimately takes a while and there's nothing to retry against
335
+ config.navigation_timeout = 30
336
+ end
337
+ ```
338
+
339
+ Either timeout can be overridden per browser (`Unmagic::Browser.new(timeout: 5)`), per
340
+ session (`browser.open(url, timeout: 5)`), or per call (`session.find(".row", wait: 2)`).
341
+
342
+ The Cloudflare driver falls back to `CLOUDFLARE_ACCOUNT_ID` and, for the token,
343
+ `CLOUDFLARE_BROWSER_RENDERING_TOKEN` then `CLOUDFLARE_API_TOKEN` — so a machine already set
344
+ up for Cloudflare needs nothing, and a token scoped to just Browser Rendering still wins
345
+ over the account-wide one. The remote driver falls back to `BROWSER_WS_ENDPOINT`. In a
346
+ container you can usually skip the block entirely.
347
+
348
+ ## Escape hatch
349
+
350
+ The curated API stays small on purpose. When you need something it doesn't wrap (network
351
+ interception, tracing, the full Puppeteer surface), drop down to the object doing the real
352
+ work. It's the mirror of the `driver:` option: you pick a driver when you construct the
353
+ browser, and `driver` hands the underlying instance back.
354
+
355
+ ```ruby
356
+ browser.driver # => the driver instance (e.g. Unmagic::Browser::Driver::Cloudflare)
357
+
358
+ browser.open("https://example.com") do |session|
359
+ puppeteer_page = session.driver # => the live Puppeteer::Page this session is driving
360
+ puppeteer_page.request_interception = true
361
+ # ...full puppeteer-ruby API...
362
+ end
363
+ ```
364
+
365
+ Every driver drives the browser over CDP through
366
+ [puppeteer-ruby](https://github.com/YusukeIwaki/puppeteer-ruby), so `session.driver` is
367
+ always a `Puppeteer::Page`. Session operations stay serialized as usual; wrap multi-step
368
+ native work in `session.lock` when it must be atomic against other callers.
369
+
370
+ ## License
371
+
372
+ MIT © Keith Pitt
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Unmagic
6
+ class Browser
7
+ # Global defaults, set once with {Unmagic::Browser.configure}. Anything a
8
+ # caller passes to {Unmagic::Browser#initialize} or to a session wins over
9
+ # what's here.
10
+ class Configuration
11
+ # The driver used when {Unmagic::Browser.new} is called without one, in
12
+ # every environment except development. A symbol (`:local`, `:remote`,
13
+ # `:cloudflare`) or a driver instance.
14
+ attr_accessor :default_driver
15
+
16
+ # Seconds a locator keeps looking, and a {Session#wait_for} waits, before
17
+ # giving up. Deliberately short: a verb that can't find its element is
18
+ # usually wrong rather than early.
19
+ attr_accessor :timeout
20
+
21
+ # Seconds a single navigation is given. Longer than {#timeout} because a
22
+ # cold page on a slow network legitimately takes a while, and there's
23
+ # nothing to retry against in the meantime.
24
+ attr_accessor :navigation_timeout
25
+
26
+ attr_writer :logger
27
+
28
+ def initialize
29
+ @default_driver = :cloudflare
30
+ @timeout = 10
31
+ @navigation_timeout = 30
32
+ @logger = nil
33
+ end
34
+
35
+ # Where the gem narrates what it's doing. Defaults to `Rails.logger` in a
36
+ # Rails app, and to a silent logger everywhere else — a library shouldn't
37
+ # write to a host app's stdout unasked.
38
+ #
39
+ # @return [Logger]
40
+ def logger
41
+ @logger ||= default_logger
42
+ end
43
+
44
+ private
45
+
46
+ def default_logger
47
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
48
+ Rails.logger
49
+ else
50
+ Logger.new(IO::NULL)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ class Browser
5
+ module Console
6
+ # What the console says for itself on the way in, and when asked for `help`.
7
+ module Banner
8
+ # The verb table, by heading: each row is what to type and what it does.
9
+ SECTIONS = {
10
+ "open" => [
11
+ ['open "https://example.com"', "open a page — every other verb drives it"],
12
+ ["visit url", "go somewhere else in the same session"],
13
+ ["back / forward / reload", "the browser buttons"],
14
+ ["close", "let the browser go"],
15
+ ["use :cloudflare", "switch drivers (:local, :remote, :cloudflare)"],
16
+ ],
17
+ "act" => [
18
+ ['fill_in "Email", with: "me@example.com"', "by label, placeholder, name or id"],
19
+ ['click "Sign in"', "anything — button, link, div"],
20
+ ['click_button "Go" / click_link "More"', "when you want to be specific"],
21
+ ['select "Australia", from: "Country"', "by the label a reader sees"],
22
+ ["check / uncheck / choose", "boxes and radios"],
23
+ ["press :enter", "at whatever has focus"],
24
+ ],
25
+ "read" => [
26
+ ["text / title / url", "the page as prose"],
27
+ ["html / markdown", "the page as source, highlighted"],
28
+ ["screenshot", "drawn right here, and saved to /tmp"],
29
+ ["screenshot full_page: true", "the whole scrollable page"],
30
+ ["pdf", "written to /tmp"],
31
+ ['js "document.title"', "run JavaScript in the page"],
32
+ ],
33
+ "find" => [
34
+ ['find ".cart"', "one element — waits for it"],
35
+ ['all ".product"', "every match, right now"],
36
+ ['within(".checkout") { click "Pay" }', "scope the verbs inside"],
37
+ ['wait_for text: "Welcome"', "block until it shows up"],
38
+ ['has_text? "Welcome"', "ask, and get an answer now"],
39
+ ],
40
+ "emulate" => [
41
+ ["emulate color_scheme: :dark", "and screenshot it"],
42
+ ['emulate device: "iPhone 13"', "viewport, user agent and touch"],
43
+ ["emulate viewport: { width: 1440, height: 900 }", ""],
44
+ ],
45
+ }.freeze
46
+
47
+ # The four verbs that take a URL work without a session too, which is
48
+ # worth saying once rather than five times in the table.
49
+ ONE_OFFS = "markdown, html, screenshot and pdf take a URL for a one-off grab: " \
50
+ 'markdown "https://example.com"'
51
+
52
+ module_function
53
+
54
+ # @return [String] the banner shown at startup
55
+ def render
56
+ [
57
+ "",
58
+ " #{Terminal.paint(Terminal::BOLD, "unmagic-browser")} #{Terminal.dim(Unmagic::Browser::VERSION)}",
59
+ " #{Terminal.dim("a browser you can type at · `help` for the verbs")}",
60
+ "",
61
+ ].join("\n")
62
+ end
63
+
64
+ # @return [String] the full list of verbs
65
+ def help
66
+ lines = [""]
67
+ SECTIONS.each do |heading, rows|
68
+ lines << " #{Terminal.paint(Terminal::GREEN, heading)}"
69
+ width = rows.map { |verb, _| verb.length }.max
70
+ rows.each do |verb, note|
71
+ lines << " #{Terminal.paint(Terminal::BLUE, verb.ljust(width))} #{Terminal.dim(note)}".rstrip
72
+ end
73
+ lines << ""
74
+ end
75
+ lines << " #{Terminal.dim(ONE_OFFS)}"
76
+ lines << ""
77
+ lines.join("\n")
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end