Class: Dommy::Browser

Inherits:
Object
  • Object
show all
Includes:
Interaction::Driver
Defined in:
lib/dommy/browser.rb

Overview

A lightweight test browser: parse HTML, build window/document, run its classic <script> tags (inline + external via a resources adapter), fire DOMContentLoaded/load, and collect JS errors / console output. For standalone HTML + JS (bundled SPA, fixture HTML); the Rack/Rails entry point is Dommy::Rack::Session (a later phase).

Dommy::Browser.open(html, resources: Dommy::Resources.static("/app.js" => "...")) do |b|
b.settle
b.evaluate('document.querySelector("h1").textContent')
end

JS errors are not swallowed: in strict mode (default) any unhandled rejection or uncaught script error fails at the next checkpoint (after boot, after settle, at dispose). Wrap intentional errors in allow_js_errors { … }.

Defined Under Namespace

Classes: JsError

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Interaction::Driver

#all, #all_by_role, #attach_file, #check, #choose, #click, #debug, #field_interactor, #fill_in, #find, #find_by_role, #finder, #has_button?, #has_css?, #has_field?, #has_link?, #has_no_css?, #has_no_role?, #has_no_text?, #has_role?, #has_text?, #ime_input, #scope_root, #scope_text, #select, #send_keys, #send_keys_to, #uncheck, #unselect, #with_scope, #within

Constructor Details

#initialize(html, url: "http://localhost/", resources: nil, execute_scripts: true, strict: true, settle: true, wasm_memory_shim: false, backend: nil, navigable: false, same_origin: false) ⇒ Browser

Returns a new instance of Browser.



71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/dommy/browser.rb', line 71

def initialize(html, url: "http://localhost/", resources: nil, execute_scripts: true, strict: true, settle: true,
  wasm_memory_shim: false, backend: nil, navigable: false, same_origin: false)
  @resources = resources
  @same_origin = same_origin
  @strict = strict
  @backend = backend
  @execute_scripts = execute_scripts
  @settle_after_boot = settle
  @wasm_memory_shim = wasm_memory_shim
  @navigable = navigable
  @js_errors = []
  @console = []
  @acknowledged = 0
  @allow_errors = false
  @disposed = false
  @pending_navigation = nil
  @runtime = nil

  @window = Dommy.parse(html)
  @window.location.__internal_set_url__(url) if url
  install_runtime(@window)

  if navigable
    @fetcher = Navigation::Fetcher.new(@resources, same_origin: @same_origin)
    @history = Navigation::JointHistory.new
    @window.navigation_delegate = self
    @history.push(current_url, window: @window, windex: @window.history.__internal_index__)
  end
  check_js_errors!
end

Instance Attribute Details

#consoleObject (readonly)

Returns the value of attribute console.



43
44
45
# File 'lib/dommy/browser.rb', line 43

def console
  @console
end

#historyObject (readonly)

The joint (tab) history of a navigable browser, or nil for a plain single-document browser.



112
113
114
# File 'lib/dommy/browser.rb', line 112

def history
  @history
end

#js_errorsObject (readonly)

Returns the value of attribute js_errors.



43
44
45
# File 'lib/dommy/browser.rb', line 43

def js_errors
  @js_errors
end

#runtimeObject (readonly)

Returns the value of attribute runtime.



43
44
45
# File 'lib/dommy/browser.rb', line 43

def runtime
  @runtime
end

#windowObject (readonly)

Returns the value of attribute window.



43
44
45
# File 'lib/dommy/browser.rb', line 43

def window
  @window
end

Class Method Details

.open(html, **opts) ⇒ Object

Build a browser and (unless execute_scripts: false) boot its scripts. In block form the browser is yielded and disposed afterward, returning the block value.



48
49
50
51
52
53
54
55
56
57
# File 'lib/dommy/browser.rb', line 48

def self.open(html, **opts)
  browser = new(html, **opts)
  return browser unless block_given?

  begin
    yield browser
  ensure
    browser.dispose
  end
end

.visit(url, resources:, **opts) ⇒ Object

Start a navigable session by fetching the initial document from resources, rather than passing literal HTML. Links / forms / location then perform real cross-document navigation (fetch → replace Window + JS realm), with the browser handle (history, resources, error log) surviving. Dommy::Browser.visit("http://localhost/&quot;, resources: my_resources)



64
65
66
67
68
69
# File 'lib/dommy/browser.rb', line 64

def self.visit(url, resources:, **opts)
  browser = new("<!doctype html><html><head></head><body></body></html>",
    url: "about:blank", resources: resources, navigable: true, **opts)
  browser.visit(url, replace: true)
  browser
end

Instance Method Details

#advance_time(ms) ⇒ Object

Advance virtual time by ms, running timers that come due, then settle.



196
197
198
199
200
201
202
# File 'lib/dommy/browser.rb', line 196

def advance_time(ms)
  @window.scheduler.advance_time(ms)
  @runtime.drain_microtasks
  flush_navigation!
  check_js_errors!
  self
end

#after_interactionObject

An interaction's events have been dispatched (Ruby-side, synchronously invoking JS handlers); drain the runtime's microtasks so promise reactions land before the next line, then enforce strict mode.



207
208
209
210
211
# File 'lib/dommy/browser.rb', line 207

def after_interaction
  @runtime.drain_microtasks
  flush_navigation!
  check_js_errors!
end

#allow_js_errorsObject

Suppress strict-mode failure for JS errors raised inside the block (they stay collected in #js_errors for inspection). For tests that expect errors.



241
242
243
244
245
246
247
248
# File 'lib/dommy/browser.rb', line 241

def allow_js_errors
  prev = @allow_errors
  @allow_errors = true
  yield
ensure
  @allow_errors = prev
  @acknowledged = @js_errors.length
end

#backObject

Move back / forward one entry in the joint history. A same-document target (the entry's window is still live) traverses in place (popstate); a document-boundary target is re-fetched (no bfcache — D2).



133
# File 'lib/dommy/browser.rb', line 133

def back = traverse(-1)

#click_button(locator) ⇒ Object

Click a submit-capable button. The button's click event fires (JS may handle / preventDefault it); if it is an un-prevented submit button, the owning form's submission algorithm runs (a real SubmitEvent a SPA can intercept, then the delegate navigation). In a navigable browser that follows the submit for real; otherwise the delegate just records it.



218
219
220
221
222
223
224
225
226
# File 'lib/dommy/browser.rb', line 218

def click_button(locator)
  button = finder.find_button(locator)
  # An un-prevented click runs the button's activation behavior — a submit
  # button submits its owning form (real SubmitEvent + delegate navigation),
  # so a navigable browser follows the submit for real.
  Dommy::Interaction::EventSynthesis.click(button)
  after_interaction
  button
end

Click a link, firing its click event so SPA JS (Turbo, React Router, …) can intercept. An un-prevented click runs the anchor's activation behavior (follow-the-hyperlink); in a navigable browser that navigates for real, otherwise the delegate records it.



232
233
234
235
236
237
# File 'lib/dommy/browser.rb', line 232

def click_link(locator)
  link = finder.find_link(locator)
  Dommy::Interaction::EventSynthesis.click(link)
  after_interaction
  link
end

#current_urlObject

The current document's URL (the address bar).



108
# File 'lib/dommy/browser.rb', line 108

def current_url = @window.location.href

#disposeObject

Raises:



250
251
252
253
254
255
256
257
# File 'lib/dommy/browser.rb', line 250

def dispose
  return if @disposed

  @disposed = true
  pending = unacknowledged
  @runtime&.dispose
  raise JsError, pending if @strict && !pending.empty?
end

#documentObject



102
# File 'lib/dommy/browser.rb', line 102

def document = @window.document

#evaluate(js) ⇒ Object

Evaluate an expression / statement body and return the decoded value.



172
173
174
175
176
# File 'lib/dommy/browser.rb', line 172

def evaluate(js)
  result = @runtime.evaluate(js)
  check_js_errors!
  result
end

#execute(js) ⇒ Object

Run JS for side effects.



179
180
181
182
183
# File 'lib/dommy/browser.rb', line 179

def execute(js)
  @runtime.execute(js)
  check_js_errors!
  nil
end

#forwardObject



134
# File 'lib/dommy/browser.rb', line 134

def forward = traverse(1)

#htmlObject

Current document HTML (serialized).



105
# File 'lib/dommy/browser.rb', line 105

def html = @window.document.document_element&.outer_html

A cross-document navigation intent (link / form / location). Navigation is a task: rather than swap the Window + JS realm synchronously (which may run while the outgoing realm's JS is still on the stack — e.g. location.href = inside a script), record it and perform the fetch + swap at the next drain boundary (settle / after_interaction / advance_time). Ruby-initiated visits flush immediately since no JS is on the stack.



144
145
146
147
148
149
150
# File 'lib/dommy/browser.rb', line 144

def navigate(url:, source:, method: "GET", body: nil, params: nil, enctype: nil, headers: {}, replace: false)
  @pending_navigation = {
    url: url, method: method, body: body, params: params, enctype: enctype,
    headers: headers, replace: replace, source: source
  }
  nil
end

#reloadObject

Reload the current document (re-fetch, replace the current history entry).



126
127
128
# File 'lib/dommy/browser.rb', line 126

def reload
  visit(current_url, replace: true)
end

#settleObject

Settle the work ready at the current virtual time: drain microtasks, run due-now timers, flush requestAnimationFrame. Does NOT fire a future setTimeout(300) — use advance_time(300) for debounce/throttle.



188
189
190
191
192
193
# File 'lib/dommy/browser.rb', line 188

def settle
  @runtime.settle
  flush_navigation!
  check_js_errors!
  self
end

#traverse(delta) ⇒ Object

A cross-document history traversal. Ruby-initiated (back / forward), so it runs immediately: a same-document target (its window is still live) traverses in place (popstate); a document-boundary target is re-fetched.



155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/dommy/browser.rb', line 155

def traverse(delta)
  return self unless @navigable

  entry = delta.negative? ? @history.back : @history.forward
  return self unless entry

  if entry.window && entry.window.equal?(@window)
    @window.history.__internal_go_to__(entry.windex)
    @runtime.drain_microtasks
    check_js_errors!
  else
    perform_navigation!({url: entry.url, method: "GET", source: :traverse}, rebind: true)
  end
  self
end

#visit(url, replace: false) ⇒ Object

Programmatically navigate to url (a Ruby-initiated visit). Only meaningful for a navigable browser; performs the fetch + document swap immediately (there is no JS on the stack).



117
118
119
120
121
122
123
# File 'lib/dommy/browser.rb', line 117

def visit(url, replace: false)
  raise "browser is not navigable (use Browser.visit or navigable: true)" unless @navigable

  @pending_navigation = {url: url.to_s, method: "GET", source: :visit, replace: replace}
  flush_navigation!
  self
end