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,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ class Browser
5
+ # One element on the page, found by a {Locator}.
6
+ #
7
+ # Everything a {Session} verb does to a single element goes through here, so
8
+ # `session.click_button "Go"` and `session.find("#go").click` take exactly
9
+ # the same path. Like a session, an element serialises its operations
10
+ # against the session it came from.
11
+ #
12
+ # @example
13
+ # session.all(".product").map { |element| element.text }
14
+ class Element
15
+ # @param session [Session] the session this element was found in
16
+ # @param handle [Puppeteer::ElementHandle] the live handle in the page
17
+ def initialize(session, handle)
18
+ @session = session
19
+ @handle = handle
20
+ end
21
+
22
+ # The live Puppeteer handle, for work this API doesn't wrap.
23
+ #
24
+ # @return [Puppeteer::ElementHandle]
25
+ attr_reader :handle
26
+ alias_method :driver, :handle
27
+
28
+ # @return [String] the element's visible text, whitespace collapsed
29
+ def text
30
+ evaluate("element => (element.innerText === undefined ? element.textContent : element.innerText)")
31
+ .to_s.gsub(/\s+/, " ").strip
32
+ end
33
+
34
+ # @return [String] the element's own HTML, tag included
35
+ def html
36
+ evaluate("element => element.outerHTML")
37
+ end
38
+
39
+ # @return [String] the element's inner HTML
40
+ def inner_html
41
+ evaluate("element => element.innerHTML")
42
+ end
43
+
44
+ # @return [String] the lowercased tag name, e.g. `"input"`
45
+ def tag_name
46
+ evaluate("element => element.tagName.toLowerCase()")
47
+ end
48
+
49
+ # The element's current value — what's typed into a field, what's selected
50
+ # in a `<select>`, or nil for anything without one.
51
+ #
52
+ # @return [String, nil]
53
+ def value
54
+ evaluate("element => ('value' in element ? element.value : null)")
55
+ end
56
+
57
+ # @param name [String, Symbol] the attribute name
58
+ # @return [String, nil] the attribute's value, or nil when it isn't set
59
+ def attribute(name)
60
+ evaluate("(element, name) => element.getAttribute(name)", name.to_s)
61
+ end
62
+ alias_method :[], :attribute
63
+
64
+ # @return [Boolean] whether the element is rendered and not hidden
65
+ def visible?
66
+ evaluate(<<~JAVASCRIPT)
67
+ (element) => {
68
+ if (element.hidden) return false;
69
+ const style = getComputedStyle(element);
70
+ if (style.visibility === "hidden" || style.display === "none") return false;
71
+ return element.getClientRects().length > 0;
72
+ }
73
+ JAVASCRIPT
74
+ end
75
+
76
+ # @return [Boolean] whether a checkbox or radio is ticked
77
+ def checked?
78
+ !!evaluate("element => !!element.checked")
79
+ end
80
+
81
+ # @return [Boolean] whether the element is disabled
82
+ def disabled?
83
+ !!evaluate("element => !!element.disabled")
84
+ end
85
+
86
+ # Click the element, scrolling it into view first.
87
+ #
88
+ # @return [self]
89
+ def click
90
+ act do
91
+ @handle.scroll_into_view_if_needed
92
+ @handle.click
93
+ end
94
+ end
95
+
96
+ # Move the pointer over the element.
97
+ #
98
+ # @return [self]
99
+ def hover
100
+ act do
101
+ @handle.scroll_into_view_if_needed
102
+ @handle.hover
103
+ end
104
+ end
105
+
106
+ # Replace whatever's in the field with +value+.
107
+ #
108
+ # The existing content is selected and deleted rather than assigned over,
109
+ # so frameworks that watch for real key events (React and friends) see the
110
+ # change.
111
+ #
112
+ # @param value [String] the text to type
113
+ # @return [self]
114
+ def fill_in(value)
115
+ act do
116
+ @handle.scroll_into_view_if_needed
117
+ @handle.focus
118
+ @handle.evaluate(<<~JAVASCRIPT)
119
+ (element) => {
120
+ if (element.select) { element.select(); return; }
121
+ const range = document.createRange();
122
+ range.selectNodeContents(element);
123
+ const selection = getSelection();
124
+ selection.removeAllRanges();
125
+ selection.addRange(range);
126
+ }
127
+ JAVASCRIPT
128
+ @handle.press("Backspace")
129
+ @handle.type_text(value.to_s) unless value.to_s.empty?
130
+ end
131
+ end
132
+
133
+ # Pick an option in a `<select>` by its visible label, or failing that its
134
+ # value — a caller reading the page sees labels, not values.
135
+ #
136
+ # @param option [String] the option's label or value
137
+ # @return [self]
138
+ # @raise [Error::NotFound] when no option matches
139
+ def select_option(option)
140
+ act do
141
+ found = @handle.evaluate(<<~JAVASCRIPT, option.to_s)
142
+ (element, wanted) => {
143
+ if (element.tagName.toLowerCase() !== "select") throw new Error("not a <select>");
144
+ const norm = (value) => (value || "").replace(/\\s+/g, " ").trim().toLowerCase();
145
+ const needle = norm(wanted);
146
+ const options = Array.from(element.options);
147
+ const match = options.find((option) => norm(option.label || option.textContent) === needle) ||
148
+ options.find((option) => norm(option.value) === needle) ||
149
+ options.find((option) => norm(option.label || option.textContent).indexOf(needle) !== -1);
150
+ if (!match) return false;
151
+ if (element.multiple) { match.selected = true; } else { element.value = match.value; }
152
+ element.dispatchEvent(new Event("input", { bubbles: true }));
153
+ element.dispatchEvent(new Event("change", { bubbles: true }));
154
+ return true;
155
+ }
156
+ JAVASCRIPT
157
+ raise Error::NotFound, "No option matching #{option.inspect} in this select." unless found
158
+ end
159
+ end
160
+
161
+ # Tick a checkbox, or leave it ticked if it already is.
162
+ #
163
+ # @return [self]
164
+ def check
165
+ set_checked(true)
166
+ end
167
+
168
+ # Untick a checkbox, or leave it unticked if it already is.
169
+ #
170
+ # @return [self]
171
+ def uncheck
172
+ set_checked(false)
173
+ end
174
+
175
+ # Attach files to a file input.
176
+ #
177
+ # @param paths [Array<String>] paths to the files
178
+ # @return [self]
179
+ def attach(*paths)
180
+ act { @handle.upload_file(*paths.flatten.map(&:to_s)) }
181
+ end
182
+
183
+ # Press a key with this element focused.
184
+ #
185
+ # @param key [String, Symbol] a key name, e.g. `:enter` or `"ArrowDown"`
186
+ # @return [self]
187
+ def press(key)
188
+ act { @handle.press(Session.key_name(key)) }
189
+ end
190
+
191
+ # @return [String] PNG bytes of just this element
192
+ def screenshot
193
+ @session.synchronize do
194
+ @handle.scroll_into_view_if_needed
195
+ @handle.screenshot(type: "png")
196
+ end
197
+ end
198
+
199
+ # Find one element inside this one. Same locator rules as {Session#find}.
200
+ #
201
+ # @param query [String, nil] see {Locator.build}
202
+ # @param options [Hash] see {Locator.build}
203
+ # @return [Element]
204
+ # @raise [Error::NotFound] when nothing matches in time
205
+ def find(query = nil, **options)
206
+ @session.find(query, scope: self, **options)
207
+ end
208
+
209
+ # Find every element inside this one. Same locator rules as {Session#all}.
210
+ #
211
+ # @param query [String, nil] see {Locator.build}
212
+ # @param options [Hash] see {Locator.build}
213
+ # @return [Array<Element>]
214
+ def all(query = nil, **options)
215
+ @session.all(query, scope: self, **options)
216
+ end
217
+
218
+ # Run JavaScript with this element as the first argument.
219
+ #
220
+ # @param script [String] a JavaScript function body, e.g. `"el => el.dataset.id"`
221
+ # @param args [Array] extra arguments passed after the element
222
+ # @return [Object] whatever the script returns, as a Ruby value
223
+ def evaluate(script, *args)
224
+ @session.synchronize { @handle.evaluate(script, *args) }
225
+ end
226
+
227
+ # Release the handle. Elements found by a session are disposed with the
228
+ # page, so this is only worth calling in a long loop.
229
+ #
230
+ # @return [void]
231
+ def dispose
232
+ @session.synchronize { @handle.dispose }
233
+ end
234
+
235
+ # @return [String] the element and the handle behind it
236
+ def inspect
237
+ "#<#{self.class.name} #{@handle.inspect}>"
238
+ end
239
+
240
+ private
241
+
242
+ def set_checked(wanted)
243
+ act do
244
+ @handle.scroll_into_view_if_needed
245
+ @handle.click if @handle.evaluate("element => !!element.checked") != wanted
246
+ end
247
+ end
248
+
249
+ # Every action returns the element so calls chain, and every action holds
250
+ # the session lock so two callers can't interleave a keystroke.
251
+ def act
252
+ @session.synchronize { yield }
253
+ self
254
+ end
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "puppeteer"
4
+
5
+ module Unmagic
6
+ class Browser
7
+ # Everything about *how* a page renders — colour scheme, viewport, device,
8
+ # locale, timezone, geolocation, media type — in one immutable value object.
9
+ #
10
+ # It's the only place emulation lives, and it's set in exactly two places:
11
+ # passed to {Unmagic::Browser#open}, or changed live with
12
+ # {Session#emulate}.
13
+ #
14
+ # Anything left unset renders at a *fixed* default — a plain desktop
15
+ # viewport, light, screen media — rather than at whatever the machine
16
+ # underneath happens to prefer. That's what makes re-applying an older
17
+ # Emulation a clean revert rather than a patch, and what stops the same page
18
+ # rendering differently on a laptop and on CI.
19
+ #
20
+ # @example
21
+ # Unmagic::Browser::Emulation.new(color_scheme: :dark, device: "iPhone 13")
22
+ class Emulation
23
+ # What a page looks like with nothing emulated. Applied for any viewport
24
+ # key the caller leaves out, so reverting is always total.
25
+ DEFAULT_VIEWPORT = {
26
+ width: 1280,
27
+ height: 800,
28
+ scale: 1,
29
+ mobile: false,
30
+ touch: false,
31
+ }.freeze
32
+
33
+ # What `prefers-color-scheme` can be told to report.
34
+ COLOR_SCHEMES = [:light, :dark, :no_preference].freeze
35
+
36
+ # What `prefers-reduced-motion` can be told to report.
37
+ REDUCED_MOTIONS = [:reduce, :no_preference].freeze
38
+
39
+ # What the page can be told it's being rendered for.
40
+ MEDIA_TYPES = [:screen, :print].freeze
41
+
42
+ # Present as a current desktop Chrome rather than as a headless build:
43
+ # pages render the way a real visitor sees them, and modern-browser gates
44
+ # let us through.
45
+ DEFAULT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " \
46
+ "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
47
+
48
+ class << self
49
+ # Coerce whatever a caller passed as `emulate:` into an Emulation.
50
+ #
51
+ # @param value [Emulation, Hash, nil] an instance, a hash of knobs, or nothing
52
+ # @return [Emulation]
53
+ # @raise [ArgumentError] when +value+ is neither
54
+ def build(value)
55
+ case value
56
+ when nil then new
57
+ when Emulation then value
58
+ when Hash then new(**value)
59
+ else
60
+ raise ArgumentError, "emulate: expects an Unmagic::Browser::Emulation or a Hash, got #{value.class}"
61
+ end
62
+ end
63
+ end
64
+
65
+ attr_reader :color_scheme, :reduced_motion, :viewport, :device, :user_agent,
66
+ :locale, :timezone, :geolocation, :media
67
+
68
+ # @param color_scheme [Symbol, nil] `:light`, `:dark` or `:no_preference`
69
+ # @param reduced_motion [Symbol, nil] `:reduce` or `:no_preference`
70
+ # @param viewport [Hash, nil] `width:`, `height:`, `scale:`, `mobile:`, `touch:`
71
+ # @param device [String, nil] a Puppeteer device name, e.g. `"iPhone 13"`
72
+ # @param user_agent [String, nil] overrides the device's own user agent
73
+ # @param locale [String, nil] a BCP 47 tag, sent as `Accept-Language`
74
+ # @param timezone [String, nil] an IANA zone, e.g. `"Australia/Sydney"`
75
+ # @param geolocation [Hash, nil] `latitude:`, `longitude:` and optionally `accuracy:`
76
+ # @param media [Symbol, nil] `:screen` or `:print`
77
+ def initialize(color_scheme: nil, reduced_motion: nil, viewport: nil, device: nil,
78
+ user_agent: nil, locale: nil, timezone: nil, geolocation: nil, media: nil)
79
+ @color_scheme = validate_symbol(:color_scheme, color_scheme, COLOR_SCHEMES)
80
+ @reduced_motion = validate_symbol(:reduced_motion, reduced_motion, REDUCED_MOTIONS)
81
+ @media = validate_symbol(:media, media, MEDIA_TYPES)
82
+ @viewport = viewport&.transform_keys(&:to_sym)&.freeze
83
+ @device = device
84
+ @user_agent = user_agent
85
+ @locale = locale
86
+ @timezone = timezone
87
+ @geolocation = geolocation&.transform_keys(&:to_sym)&.freeze
88
+ validate_viewport!
89
+ validate_device!
90
+ freeze
91
+ end
92
+
93
+ # A copy with +other+'s settings layered on top. Only keys the other side
94
+ # actually set are overridden, so `emulate(color_scheme: :dark)` on a
95
+ # session that's already emulating a phone keeps the phone.
96
+ #
97
+ # @param other [Emulation, Hash, nil]
98
+ # @return [Emulation]
99
+ def merge(other)
100
+ other = self.class.build(other)
101
+ self.class.new(**to_h.merge(other.to_h) { |_key, mine, theirs| theirs.nil? ? mine : theirs })
102
+ end
103
+
104
+ # @return [Hash] the knobs, unset ones included as nil
105
+ def to_h
106
+ {
107
+ color_scheme: @color_scheme,
108
+ reduced_motion: @reduced_motion,
109
+ viewport: @viewport,
110
+ device: @device,
111
+ user_agent: @user_agent,
112
+ locale: @locale,
113
+ timezone: @timezone,
114
+ geolocation: @geolocation,
115
+ media: @media,
116
+ }
117
+ end
118
+
119
+ # @return [Boolean] whether every knob is at Chrome's default
120
+ def default?
121
+ to_h.values.all?(&:nil?)
122
+ end
123
+
124
+ # @param other [Object]
125
+ # @return [Boolean] whether the other emulation has the same settings
126
+ def ==(other)
127
+ other.is_a?(Emulation) && other.to_h == to_h
128
+ end
129
+ alias_method :eql?, :==
130
+
131
+ # @return [Integer] a hash of the settings, so an emulation works as a hash key
132
+ def hash
133
+ to_h.hash
134
+ end
135
+
136
+ # Push the whole configuration onto a live page. Every knob is sent every
137
+ # time — including the ones left unset, which are sent as their defaults —
138
+ # so applying an Emulation always lands the page in exactly this state,
139
+ # whatever it was in before.
140
+ #
141
+ # @param page [Puppeteer::Page]
142
+ # @return [void]
143
+ def apply(page)
144
+ page.viewport = resolved_viewport
145
+ page.user_agent = resolved_user_agent
146
+ page.emulate_locale(@locale)
147
+ page.emulate_timezone(@timezone)
148
+ apply_geolocation(page)
149
+ apply_media(page)
150
+ end
151
+
152
+ # The viewport actually sent to Chrome: the device's, with any explicit
153
+ # `viewport:` keys layered over it, over the plain-desktop default.
154
+ #
155
+ # @return [Puppeteer::Viewport]
156
+ def resolved_viewport
157
+ base = DEFAULT_VIEWPORT.merge(device_viewport).merge(@viewport || {})
158
+ Puppeteer::Viewport.new(
159
+ width: base[:width],
160
+ height: base[:height],
161
+ device_scale_factor: base[:scale] || 1,
162
+ is_mobile: !!base[:mobile],
163
+ has_touch: !!base[:touch],
164
+ is_landscape: base[:width] > base[:height],
165
+ )
166
+ end
167
+
168
+ # @return [String] the user agent sent to Chrome
169
+ def resolved_user_agent
170
+ @user_agent || puppeteer_device&.user_agent || DEFAULT_USER_AGENT
171
+ end
172
+
173
+ private
174
+
175
+ def apply_geolocation(page)
176
+ if @geolocation
177
+ page.geolocation = Puppeteer::Geolocation.new(**@geolocation)
178
+ else
179
+ page.client.send_message("Emulation.clearGeolocationOverride")
180
+ end
181
+ end
182
+
183
+ # Media type and media features share one CDP command, and each omitted
184
+ # argument resets the other — so they have to go out together rather than
185
+ # through Puppeteer's two separate setters.
186
+ #
187
+ # Unset features are pinned to a value rather than left off. Left off,
188
+ # Chrome falls back to the *host machine's* appearance, so the same page
189
+ # would render light on CI and dark on a Mac after sunset. A rendering
190
+ # library that does that isn't one you can screenshot with.
191
+ def apply_media(page)
192
+ page.client.send_message(
193
+ "Emulation.setEmulatedMedia",
194
+ media: (@media || :screen).to_s,
195
+ features: [
196
+ feature("prefers-color-scheme", @color_scheme || :light),
197
+ feature("prefers-reduced-motion", @reduced_motion || :no_preference),
198
+ ],
199
+ )
200
+ end
201
+
202
+ def feature(name, value)
203
+ { name: name, value: value.to_s.tr("_", "-") }
204
+ end
205
+
206
+ def device_viewport
207
+ viewport = puppeteer_device&.viewport
208
+ return {} unless viewport
209
+
210
+ {
211
+ width: viewport.width,
212
+ height: viewport.height,
213
+ scale: viewport.device_scale_factor,
214
+ mobile: viewport.mobile?,
215
+ touch: viewport.has_touch?,
216
+ }
217
+ end
218
+
219
+ def puppeteer_device
220
+ return nil unless @device
221
+
222
+ Puppeteer::DEVICES[@device]
223
+ end
224
+
225
+ def validate_symbol(name, value, allowed)
226
+ return nil if value.nil?
227
+
228
+ symbol = value.to_sym
229
+ return symbol if allowed.include?(symbol)
230
+
231
+ raise ArgumentError, "#{name}: expects one of #{allowed.map(&:inspect).join(", ")}, got #{value.inspect}"
232
+ end
233
+
234
+ def validate_viewport!
235
+ return if @viewport.nil?
236
+
237
+ unknown = @viewport.keys - DEFAULT_VIEWPORT.keys
238
+ raise ArgumentError, "viewport: unknown key(s) #{unknown.map(&:inspect).join(", ")}" if unknown.any?
239
+ end
240
+
241
+ def validate_device!
242
+ return if @device.nil? || Puppeteer::DEVICES.key?(@device)
243
+
244
+ raise ArgumentError, "device: #{@device.inspect} isn't a device Puppeteer knows about"
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ class Browser
5
+ # The base class every failure in this gem inherits from.
6
+ #
7
+ # Failures are typed so callers classify them by rescuing, never by parsing
8
+ # a message — messages carry target URLs and service internals that aren't
9
+ # a stable contract.
10
+ #
11
+ # @example
12
+ # begin
13
+ # browser.markdown(url)
14
+ # rescue Unmagic::Browser::Error::TimedOut
15
+ # # the page took too long
16
+ # rescue Unmagic::Browser::Error
17
+ # # anything else
18
+ # end
19
+ class Error < StandardError
20
+ # The page, or an element on it, took too long.
21
+ class TimedOut < Error; end
22
+
23
+ # Couldn't reach the page or the rendering service.
24
+ class Unreachable < Error; end
25
+
26
+ # The service answered, but couldn't render the page.
27
+ class RenderFailed < Error; end
28
+
29
+ # A concurrency or rate limit was hit.
30
+ class RateLimited < Error; end
31
+
32
+ # Credentials or configuration are missing.
33
+ class Misconfigured < Error; end
34
+
35
+ # Nothing on the page matched a locator before it gave up. A subclass of
36
+ # {TimedOut} because locators auto-wait: "not found" always means "still
37
+ # not there when we ran out of patience".
38
+ class NotFound < TimedOut; end
39
+
40
+ # The session has been closed, so it can't be driven any more.
41
+ class SessionClosed < Error; end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "puppeteer"
4
+ require "socket"
5
+
6
+ module Unmagic
7
+ class Browser
8
+ # The one place a Puppeteer, socket or HTTP failure becomes an
9
+ # {Unmagic::Browser::Error}.
10
+ #
11
+ # Callers classify failures by rescuing, so every path out of this gem has
12
+ # to raise one of our types — including the ones that come out of
13
+ # puppeteer-ruby, three gems deep, as a bare `Puppeteer::Error`.
14
+ module Failures
15
+ # Chrome reports a failed navigation as a `net::ERR_*` protocol message
16
+ # rather than as a distinct error class, so the message is the only signal
17
+ # separating "couldn't reach the page" from "the browser broke".
18
+ UNREACHABLE_PATTERN = /net::ERR_|ERR_NAME_NOT_RESOLVED|ERR_CONNECTION|ERR_ADDRESS|ERR_INTERNET_DISCONNECTED/
19
+
20
+ # Anything that means the socket underneath went away.
21
+ TRANSPORT_ERRORS = [
22
+ IOError,
23
+ SocketError,
24
+ Errno::ECONNRESET,
25
+ Errno::ECONNREFUSED,
26
+ Errno::EPIPE,
27
+ Errno::EHOSTUNREACH,
28
+ Errno::ENETUNREACH,
29
+ Errno::ETIMEDOUT,
30
+ ].freeze
31
+
32
+ module_function
33
+
34
+ # Run +block+, re-raising anything it throws as a typed error.
35
+ #
36
+ # @param doing [String] what was being attempted, for the message
37
+ # @yield the work to guard
38
+ # @return [Object] whatever the block returns
39
+ # @raise [Unmagic::Browser::Error]
40
+ def wrap(doing)
41
+ yield
42
+ rescue Error
43
+ # Already one of ours — a locator that gave up, say. Leave it alone.
44
+ raise
45
+ rescue Puppeteer::TimeoutError => error
46
+ raise Error::TimedOut, "Timed out #{doing}: #{error.message}"
47
+ rescue *TRANSPORT_ERRORS => error
48
+ raise Error::Unreachable, "Couldn't reach the browser while #{doing}: #{error.message}"
49
+ rescue Puppeteer::Error => error
50
+ raise Error::Unreachable, "Couldn't reach the page while #{doing}: #{error.message}" if unreachable?(error)
51
+
52
+ raise Error::RenderFailed, "The browser failed while #{doing}: #{error.message}"
53
+ rescue StandardError => error
54
+ raise Error::RateLimited, "Hit a rate or concurrency limit while #{doing}." if rate_limited?(error)
55
+
56
+ raise
57
+ end
58
+
59
+ # @param error [Exception]
60
+ # @return [Boolean] whether the error is Chrome refusing to load a URL
61
+ def unreachable?(error)
62
+ UNREACHABLE_PATTERN.match?(error.message.to_s)
63
+ end
64
+
65
+ # A rejected WebSocket handshake carries its HTTP status on the exception
66
+ # rather than in the class, and 429 there means a quota — worth retrying
67
+ # on a cooldown — not a dead network.
68
+ #
69
+ # @param error [Exception]
70
+ # @return [Boolean] whether the error is a quota rejection
71
+ def rate_limited?(error)
72
+ error.respond_to?(:response) && error.response.respond_to?(:status) && error.response&.status == 429
73
+ end
74
+ end
75
+ end
76
+ end