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 +7 -0
- data/CHANGELOG.md +51 -0
- data/LICENSE +21 -0
- data/README.md +372 -0
- data/lib/unmagic/browser/configuration.rb +55 -0
- data/lib/unmagic/browser/console/banner.rb +82 -0
- data/lib/unmagic/browser/console/graphics.rb +145 -0
- data/lib/unmagic/browser/console/helpers.rb +325 -0
- data/lib/unmagic/browser/console/prompt.rb +152 -0
- data/lib/unmagic/browser/console/renderer.rb +205 -0
- data/lib/unmagic/browser/console/terminal.rb +108 -0
- data/lib/unmagic/browser/console/values.rb +78 -0
- data/lib/unmagic/browser/console.rb +161 -0
- data/lib/unmagic/browser/driver/base.rb +146 -0
- data/lib/unmagic/browser/driver/cloudflare/transport.rb +67 -0
- data/lib/unmagic/browser/driver/cloudflare.rb +214 -0
- data/lib/unmagic/browser/driver/connection.rb +75 -0
- data/lib/unmagic/browser/driver/local.rb +87 -0
- data/lib/unmagic/browser/driver/remote.rb +45 -0
- data/lib/unmagic/browser/driver.rb +75 -0
- data/lib/unmagic/browser/element.rb +257 -0
- data/lib/unmagic/browser/emulation.rb +248 -0
- data/lib/unmagic/browser/error.rb +44 -0
- data/lib/unmagic/browser/failures.rb +76 -0
- data/lib/unmagic/browser/locator.rb +256 -0
- data/lib/unmagic/browser/markdown.rb +54 -0
- data/lib/unmagic/browser/session.rb +588 -0
- data/lib/unmagic/browser/version.rb +8 -0
- data/lib/unmagic/browser.rb +214 -0
- metadata +105 -0
|
@@ -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
|