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.
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "fileutils"
5
+ require "tmpdir"
6
+
7
+ module Unmagic
8
+ class Browser
9
+ module Console
10
+ # Drawing an image in the terminal, over the [kitty graphics
11
+ # protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/).
12
+ #
13
+ # The terminal is handed the PNG bytes themselves, base64'd, wrapped in an
14
+ # APC escape sequence. That's the transmission mode every implementing
15
+ # terminal supports — the alternative, pointing at a file on disk, is
16
+ # faster but only works when the terminal is on the same machine and
17
+ # allowed to read it.
18
+ module Graphics
19
+ # The protocol caps a single escape sequence's payload, so anything
20
+ # bigger goes out as a run of chunks flagged `m=1` until the last.
21
+ CHUNK = 4096
22
+
23
+ # Leave a little room so an image drawn at the full width doesn't wrap.
24
+ MARGIN = 2
25
+
26
+ # How much of the terminal's width a drawn image may take.
27
+ #
28
+ # A screenshot in a console is for glancing at — "did the page load,
29
+ # did the button move" — and a 1280px capture drawn edge to edge pushes
30
+ # the command that produced it off the screen. Half leaves the
31
+ # conversation readable, and the full-size file on disk is there when
32
+ # you want to look properly.
33
+ DEFAULT_SCALE = 0.5
34
+
35
+ # Where this process starts numbering its images.
36
+ #
37
+ # Every transmission needs an id the terminal hasn't seen, or it may
38
+ # treat the new bytes as a redraw of an image it already has and leave
39
+ # the old one on screen — a screenshot that never seems to change.
40
+ # Untagged transmissions all land on the same implicit id, which is
41
+ # exactly that trap, so ids are seeded from the pid to keep two consoles
42
+ # sharing a terminal out of each other's way.
43
+ ID_BASE = (Process.pid % 10_000) * 100_000
44
+
45
+ module_function
46
+
47
+ # @return [Integer] an id no image drawn from this process has used
48
+ def next_id
49
+ @drawn = (@drawn || 0) + 1
50
+ ID_BASE + @drawn
51
+ end
52
+
53
+ # @return [Float] the fraction of the terminal's width an image may take
54
+ def scale
55
+ @scale ||= DEFAULT_SCALE
56
+ end
57
+
58
+ # Draw images larger or smaller — `Graphics.scale = 1.0` for full width.
59
+ #
60
+ # @param fraction [Float] a fraction of the terminal's width
61
+ # @return [void]
62
+ def scale=(fraction)
63
+ @scale = fraction
64
+ end
65
+
66
+ # Write the image somewhere durable and draw it if the terminal can.
67
+ #
68
+ # It's written either way: half the point of a screenshot in a console is
69
+ # being able to open it in something that isn't a terminal.
70
+ #
71
+ # @param image [Image] the PNG bytes
72
+ # @return [String] the path it was written to
73
+ def show(image)
74
+ path = save(image)
75
+ draw(image) if Terminal.graphics?
76
+ path
77
+ end
78
+
79
+ # @param image [Tagged] the bytes to write
80
+ # @return [String] the path written to
81
+ def save(image)
82
+ directory = File.join(Dir.tmpdir, "unmagic-browser")
83
+ FileUtils.mkdir_p(directory)
84
+ path = File.join(directory, "#{image.name}-#{timestamp}.#{image.extension}")
85
+ File.binwrite(path, image)
86
+ path
87
+ end
88
+
89
+ # @param image [Image] the PNG bytes to draw
90
+ # @return [void]
91
+ def draw(image)
92
+ payload = Base64.strict_encode64(image).scan(/.{1,#{CHUNK}}/)
93
+
94
+ payload.each_with_index do |chunk, index|
95
+ more = index == payload.size - 1 ? 0 : 1
96
+ # Only the first chunk carries the full control data; the rest just
97
+ # say whether more is coming.
98
+ control = index.zero? ? "#{settings(image)},m=#{more}" : "m=#{more}"
99
+ $stdout.write("\e_G#{control};#{chunk}\e\\")
100
+ end
101
+
102
+ $stdout.write("\n")
103
+ $stdout.flush
104
+ end
105
+
106
+ # `a=T` transmits and displays in one go; `f=100` says the payload is a
107
+ # PNG, so the terminal does the decoding; `i=` gives this image an id of
108
+ # its own; and `q=2` tells the terminal to keep its acknowledgement to
109
+ # itself — it would otherwise write `OK` back down the tty, where the
110
+ # console is reading keystrokes and would take it for typing.
111
+ #
112
+ # @param image [Image]
113
+ # @return [String] the protocol's control data
114
+ def settings(image)
115
+ control = "a=T,f=100,i=#{next_id},q=2"
116
+ columns = scaled_columns(image)
117
+ # Given a width and no height, the terminal keeps the aspect ratio.
118
+ # Left off entirely, the image draws at its true pixel size.
119
+ control += ",c=#{columns}" if columns
120
+ control
121
+ end
122
+
123
+ # The width to draw at: {#scale} of the terminal, but never wider than
124
+ # the image really is. Scaling *up* would turn a screenshot of one
125
+ # button into a blurry banner — the cap is a ceiling, not a target.
126
+ #
127
+ # @param image [Image]
128
+ # @return [Integer, nil] the width to scale to, or nil to draw at native size
129
+ def scaled_columns(image)
130
+ width, = image.dimensions
131
+ return nil unless width
132
+
133
+ ceiling = [((Terminal.columns - MARGIN) * scale).round, 1].max
134
+ natural = (width.to_f / Terminal.cell.first).ceil
135
+ natural > ceiling ? ceiling : nil
136
+ end
137
+
138
+ # @return [String] a sortable, collision-resistant filename fragment
139
+ def timestamp
140
+ Time.now.strftime("%Y%m%d-%H%M%S-%L")
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,325 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ class Browser
5
+ module Console
6
+ # The verbs, as bare words.
7
+ #
8
+ # A console is the one place where `open "https://example.com"` beats
9
+ # `session = browser.open("https://example.com")` — you're typing, not
10
+ # writing code that has to survive. Every verb here drives one current
11
+ # session, opened by {#open} and remembered until you {#close} it, so the
12
+ # reads and the actions all know what page they're on without being told.
13
+ #
14
+ # The four verbs that work on a whole page — {#markdown}, {#html},
15
+ # {#screenshot} and {#pdf} — take an optional URL. With one they're a
16
+ # one-off grab that opens nothing; without one they read the current
17
+ # session.
18
+ #
19
+ # {Console.start} mixes this into a console's `main`. Mix it in yourself
20
+ # to get the same verbs somewhere else.
21
+ module Helpers
22
+ # ---------------------------------------------------------------- setup
23
+
24
+ # @return [Unmagic::Browser] the browser the verbs run against
25
+ def browser
26
+ Console.browser
27
+ end
28
+
29
+ # Switch drivers, closing whatever's open.
30
+ #
31
+ # @param driver [Symbol, Unmagic::Browser::Driver::Base] `:local`, `:remote`,
32
+ # `:cloudflare`, or an instance
33
+ # @return [Unmagic::Browser] the new browser
34
+ def use(driver)
35
+ Console.reset(driver)
36
+ browser
37
+ end
38
+
39
+ # @return [Unmagic::Browser::Session, nil] the session the verbs are driving
40
+ def session
41
+ Console.session
42
+ end
43
+
44
+ # ------------------------------------------------------------- navigate
45
+
46
+ # Open a page and make it the one every other verb drives. Closes
47
+ # whatever was open first — one session at a time is what a console
48
+ # wants.
49
+ #
50
+ # @param url [String, nil] the page to open, or nil for a blank one
51
+ # @param options [Hash] see {Unmagic::Browser#open}
52
+ # @return [Unmagic::Browser::Session]
53
+ def open(url = nil, **options)
54
+ Console.session&.close
55
+ Console.session = browser.open(url, **options)
56
+ end
57
+
58
+ # @param url [String] where to go, in the session that's already open
59
+ # @return [Unmagic::Browser::Session]
60
+ def visit(url)
61
+ current.visit(url)
62
+ end
63
+
64
+ # @return [Unmagic::Browser::Session]
65
+ def reload
66
+ current.reload
67
+ end
68
+
69
+ # @return [Unmagic::Browser::Session]
70
+ def back
71
+ current.go_back
72
+ end
73
+
74
+ # @return [Unmagic::Browser::Session]
75
+ def forward
76
+ current.go_forward
77
+ end
78
+
79
+ # @return [String] the URL currently shown
80
+ def url
81
+ current.current_url
82
+ end
83
+
84
+ # @return [String] the page's title
85
+ def title
86
+ current.title
87
+ end
88
+
89
+ # ----------------------------------------------------------------- act
90
+
91
+ # @param query [String, nil] the field's label, placeholder, name or id
92
+ # @param with [String] what to type
93
+ # @param options [Hash] see {Unmagic::Browser::Locator.build}
94
+ # @return [Unmagic::Browser::Session]
95
+ def fill_in(query = nil, with:, **options)
96
+ current.fill_in(query, with: with, **options)
97
+ end
98
+
99
+ # @param query [String, nil] see {Unmagic::Browser::Locator.build}
100
+ # @param options [Hash] see {Unmagic::Browser::Locator.build}
101
+ # @return [Unmagic::Browser::Session]
102
+ def click(query = nil, **options)
103
+ current.click(query, **options)
104
+ end
105
+
106
+ # @param query [String, nil] the button's text, value or name
107
+ # @param options [Hash] see {Unmagic::Browser::Locator.build}
108
+ # @return [Unmagic::Browser::Session]
109
+ def click_button(query = nil, **options)
110
+ current.click_button(query, **options)
111
+ end
112
+
113
+ # @param query [String, nil] the link's text
114
+ # @param options [Hash] see {Unmagic::Browser::Locator.build}
115
+ # @return [Unmagic::Browser::Session]
116
+ def click_link(query = nil, **options)
117
+ current.click_link(query, **options)
118
+ end
119
+
120
+ # @param option [String] the option's visible label, or its value
121
+ # @param from [String, nil] the select's label or name
122
+ # @param options [Hash] see {Unmagic::Browser::Locator.build}
123
+ # @return [Unmagic::Browser::Session]
124
+ def select(option, from: nil, **options)
125
+ current.select(option, from: from, **options)
126
+ end
127
+
128
+ # @param query [String, nil] the checkbox's label or name
129
+ # @param options [Hash] see {Unmagic::Browser::Locator.build}
130
+ # @return [Unmagic::Browser::Session]
131
+ def check(query = nil, **options)
132
+ current.check(query, **options)
133
+ end
134
+
135
+ # @param query [String, nil] the checkbox's label or name
136
+ # @param options [Hash] see {Unmagic::Browser::Locator.build}
137
+ # @return [Unmagic::Browser::Session]
138
+ def uncheck(query = nil, **options)
139
+ current.uncheck(query, **options)
140
+ end
141
+
142
+ # @param query [String, nil] the radio button's label or name
143
+ # @param options [Hash] see {Unmagic::Browser::Locator.build}
144
+ # @return [Unmagic::Browser::Session]
145
+ def choose(query = nil, **options)
146
+ current.choose(query, **options)
147
+ end
148
+
149
+ # @param query [String, nil] the file field's label or name
150
+ # @param paths [Array<String>] the files to attach
151
+ # @param options [Hash] see {Unmagic::Browser::Locator.build}
152
+ # @return [Unmagic::Browser::Session]
153
+ def attach_file(query = nil, *paths, **options)
154
+ current.attach_file(query, *paths, **options)
155
+ end
156
+
157
+ # @param key [Symbol, String] e.g. `:enter`
158
+ # @param options [Hash] see {Unmagic::Browser::Session#press}
159
+ # @return [Unmagic::Browser::Session]
160
+ def press(key, **options)
161
+ current.press(key, **options)
162
+ end
163
+
164
+ # @param query [String, nil] see {Unmagic::Browser::Locator.build}
165
+ # @param options [Hash] see {Unmagic::Browser::Locator.build}
166
+ # @return [Unmagic::Browser::Session]
167
+ def hover(query = nil, **options)
168
+ current.hover(query, **options)
169
+ end
170
+
171
+ # ---------------------------------------------------------------- read
172
+
173
+ # @return [String] the visible text of the page
174
+ def text
175
+ current.text
176
+ end
177
+
178
+ # @param target [String, nil] a URL to grab, or nil to read the open session
179
+ # @return [Code] the HTML, syntax-highlighted when shown
180
+ def html(target = nil)
181
+ Code.new(target ? browser.html(target) : current.html, language: "html", name: "page")
182
+ end
183
+
184
+ # @param target [String, nil] a URL to grab, or nil to read the open session
185
+ # @return [Code] the Markdown, syntax-highlighted when shown
186
+ def markdown(target = nil)
187
+ Code.new(target ? browser.markdown(target) : current.markdown, language: "markdown", name: "page")
188
+ end
189
+
190
+ # @param target [String, nil] a URL to capture, or nil to shoot the open session
191
+ # @param full_page [Boolean] capture the whole scrollable page
192
+ # @param selector [String, nil] capture just this element
193
+ # @return [Image] PNG bytes, drawn in the terminal when shown
194
+ def screenshot(target = nil, full_page: false, selector: nil)
195
+ bytes = if target
196
+ browser.screenshot(target, full_page: full_page, selector: selector)
197
+ else
198
+ current.screenshot(full_page: full_page, selector: selector)
199
+ end
200
+ Image.new(bytes)
201
+ end
202
+
203
+ # @param target [String, nil] a URL to print, or nil to print the open session
204
+ # @return [Document] PDF bytes, written to a file when shown
205
+ def pdf(target = nil)
206
+ Document.new(target ? browser.pdf(target) : current.pdf, name: "page")
207
+ end
208
+
209
+ # @param query [String, nil] the field's label, placeholder, name or id
210
+ # @param options [Hash] see {Unmagic::Browser::Locator.build}
211
+ # @return [String, nil] what's in the field
212
+ def value(query = nil, **options)
213
+ current.value(query, **options)
214
+ end
215
+
216
+ # @param script [String] an expression, or a function like `"() => document.title"`
217
+ # @param args [Array] arguments for the function form
218
+ # @return [Object] whatever it returns
219
+ def js(script, *args)
220
+ current.evaluate(script, *args)
221
+ end
222
+ alias_method :evaluate, :js
223
+
224
+ # ---------------------------------------------------------- wait & ask
225
+
226
+ # @param options [Hash] `text:` or `selector:`, see {Unmagic::Browser::Session#wait_for}
227
+ # @return [Unmagic::Browser::Session]
228
+ def wait_for(**options)
229
+ current.wait_for(**options)
230
+ end
231
+
232
+ # @param string [String] the text to look for
233
+ # @return [Boolean] whether it's on the page right now
234
+ def has_text?(string)
235
+ current.has_text?(string)
236
+ end
237
+
238
+ # @param query [String, nil] see {Unmagic::Browser::Locator.build}
239
+ # @param options [Hash] see {Unmagic::Browser::Locator.build}
240
+ # @return [Boolean] whether such a button is on the page right now
241
+ def has_button?(query = nil, **options)
242
+ current.has_button?(query, **options)
243
+ end
244
+
245
+ # @param query [String, nil] see {Unmagic::Browser::Locator.build}
246
+ # @param options [Hash] see {Unmagic::Browser::Locator.build}
247
+ # @return [Boolean] whether such a link is on the page right now
248
+ def has_link?(query = nil, **options)
249
+ current.has_link?(query, **options)
250
+ end
251
+
252
+ # @param query [String, nil] see {Unmagic::Browser::Locator.build}
253
+ # @param options [Hash] see {Unmagic::Browser::Locator.build}
254
+ # @return [Boolean] whether such a field is on the page right now
255
+ def has_field?(query = nil, **options)
256
+ current.has_field?(query, **options)
257
+ end
258
+
259
+ # @param css [String] a CSS selector
260
+ # @return [Boolean] whether anything matches it right now
261
+ def has_selector?(css)
262
+ current.has_selector?(css)
263
+ end
264
+
265
+ # --------------------------------------------------------------- scope
266
+
267
+ # @param query [String, nil] see {Unmagic::Browser::Locator.build}
268
+ # @param options [Hash] see {Unmagic::Browser::Locator.build}
269
+ # @return [Unmagic::Browser::Element]
270
+ def find(query = nil, **options)
271
+ current.find(query, **options)
272
+ end
273
+
274
+ # @param query [String, nil] see {Unmagic::Browser::Locator.build}
275
+ # @param options [Hash] see {Unmagic::Browser::Locator.build}
276
+ # @return [Array<Unmagic::Browser::Element>]
277
+ def all(query = nil, **options)
278
+ current.all(query, **options)
279
+ end
280
+
281
+ # Narrow every verb inside the block to one part of the page.
282
+ #
283
+ # @param query [String, nil] see {Unmagic::Browser::Locator.build}
284
+ # @param options [Hash] see {Unmagic::Browser::Locator.build}
285
+ # @yield with the scope in force
286
+ # @return [Object] whatever the block returns
287
+ def within(query = nil, **options, &block)
288
+ current.within(query, **options) { block.call }
289
+ end
290
+
291
+ # ----------------------------------------------------------- lifecycle
292
+
293
+ # @param config [Unmagic::Browser::Emulation, Hash, nil] a whole configuration
294
+ # @param knobs [Hash] individual settings, layered over what's already emulated
295
+ # @return [Unmagic::Browser::Emulation] what's being emulated now
296
+ def emulate(config = nil, **knobs)
297
+ current.emulate(config, **knobs)
298
+ end
299
+
300
+ # @return [nil]
301
+ def close
302
+ Console.session&.close
303
+ Console.session = nil
304
+ end
305
+
306
+ # What you can type, and what it does.
307
+ #
308
+ # @return [nil]
309
+ def help
310
+ puts(Banner.help)
311
+ nil
312
+ end
313
+
314
+ private
315
+
316
+ # The session the verbs drive, or a nudge towards opening one — a bare
317
+ # NoMethodError on nil would say nothing about what to do next.
318
+ def current
319
+ Console.session ||
320
+ raise(Console::NothingOpen, "Nothing's open. Start with `open \"https://example.com\"`.")
321
+ end
322
+ end
323
+ end
324
+ end
325
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ class Browser
5
+ module Console
6
+ # An opt-in prompt that says what's open.
7
+ #
8
+ # `browser> ` until there's a page, and then the same green dot and title
9
+ # the renderer already shows for a session:
10
+ #
11
+ # ● Sign in · jobs.town> click "Email"
12
+ #
13
+ # Off by default — the console prompts with a plain `browser> `. Ask for
14
+ # it with `Unmagic::Browser::Console.start(prompt: :page)`.
15
+ #
16
+ # IRB asks its context for the prompt every time it redraws the line —
17
+ # once per keystroke, not once per statement — so nothing here may talk to
18
+ # the browser. The page is read once after each statement instead, and
19
+ # what it said is held until the next one.
20
+ module Prompt
21
+ # What to say when nothing's open.
22
+ IDLE = "browser"
23
+
24
+ # Long enough to tell two pages apart, short enough to leave room to
25
+ # type. Titles run long — "Sign in · jobs.town" is a short one.
26
+ MAX_LABEL = 32
27
+
28
+ # How much of a narrow terminal the label may take before that limit
29
+ # comes down to meet it.
30
+ SHARE = 3
31
+
32
+ # Below this there's no room for a title worth reading anyway.
33
+ MIN_LABEL = 12
34
+
35
+ # Control characters would tear the line apart — a title with a newline
36
+ # in it is rare, but it only has to happen once.
37
+ CONTROL = "\x00-\x1F"
38
+
39
+ @live = false
40
+ @label = IDLE
41
+ @width = IDLE.length + 2
42
+
43
+ class << self
44
+ # Give a context a prompt that follows the browser around.
45
+ #
46
+ # IRB reads `prompt_i` and friends off its context every time it
47
+ # draws, so overriding them there is all it takes; the formats in
48
+ # `IRB.conf` stay as they are for anyone who switches prompt modes.
49
+ #
50
+ # @param context [IRB::Context]
51
+ # @return [void]
52
+ def install(context)
53
+ context.define_singleton_method(:prompt_i) { Prompt.render }
54
+ context.define_singleton_method(:prompt_s) { Prompt.continuation("%l") }
55
+ context.define_singleton_method(:prompt_c) { Prompt.continuation("*") }
56
+
57
+ # Any statement can have moved the page — a click that follows a
58
+ # link says nothing about it, and neither does `js "location = ..."`
59
+ # — so the page gets asked after every one of them rather than after
60
+ # the verbs we could think of.
61
+ context.define_singleton_method(:evaluate) do |*args, **options, &block|
62
+ super(*args, **options, &block)
63
+ ensure
64
+ Prompt.refresh
65
+ end
66
+
67
+ refresh
68
+ end
69
+
70
+ # Ask the page where it is. Everything that could raise or block
71
+ # happens here, so that drawing the prompt is only string work.
72
+ #
73
+ # @return [void]
74
+ def refresh
75
+ @live, @label = read
76
+ @width = (@live ? 2 : 0) + columns_of(@label) + 2
77
+ end
78
+
79
+ # @return [String] the prompt
80
+ def render
81
+ "#{marker}#{escape(@label)}> "
82
+ end
83
+
84
+ # Lined up under {render}, so a multi-line statement reads as one
85
+ # block however long the title is.
86
+ #
87
+ # @param mark [String] one visible character — IRB's `%l`, or a `*`
88
+ # @return [String]
89
+ def continuation(mark)
90
+ "#{" " * (@width - 2)}#{mark} "
91
+ end
92
+
93
+ private
94
+
95
+ # @return [Array(Boolean, String)] whether a page is open, and what to
96
+ # call it
97
+ def read
98
+ session = Console.session
99
+ return [false, IDLE] if session.nil? || session.closed?
100
+
101
+ [true, name(session)]
102
+ rescue StandardError
103
+ # A page that's gone answers nothing. Saying so quietly beats a
104
+ # backtrace across the prompt every time it's drawn.
105
+ [false, IDLE]
106
+ end
107
+
108
+ # Plenty of pages have no <title> — the one you land on after a
109
+ # redirect to a login screen, often enough — and a blank prompt reads
110
+ # as something having broken. The host is always something.
111
+ def name(session)
112
+ title = tidy(session.title)
113
+ title = tidy(host(session.current_url)) if title.empty?
114
+ title.empty? ? IDLE : truncate(title)
115
+ end
116
+
117
+ def tidy(text)
118
+ text.to_s.tr(CONTROL, " ").squeeze(" ").strip
119
+ end
120
+
121
+ def host(url)
122
+ url.to_s[%r{\A[a-z][\w+.-]*://([^/]+)}i, 1]
123
+ end
124
+
125
+ def truncate(text)
126
+ limit = [[Terminal.columns / SHARE, MAX_LABEL].min, MIN_LABEL].max
127
+ return text if columns_of(text) <= limit
128
+
129
+ "#{text[0, limit - 1].rstrip}…"
130
+ end
131
+
132
+ def marker
133
+ @live ? "#{Terminal.paint(Terminal::GREEN, "●")} " : ""
134
+ end
135
+
136
+ # IRB runs the prompt through its own `%` formatting on the way out,
137
+ # where a title like "100% Cotton" would lose the `% C`.
138
+ def escape(text)
139
+ text.gsub("%", "%%")
140
+ end
141
+
142
+ # Reline measures the prompt this way too, so the padding matches what
143
+ # it will actually draw: an emoji in a title is two columns wide, not
144
+ # one character.
145
+ def columns_of(text)
146
+ defined?(Reline::Unicode) ? Reline::Unicode.calculate_width(text) : text.length
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end