Module: Capybara::Simulated::RuntimeShared

Defined in:
lib/capybara/simulated/runtime_shared.rb

Overview

Bits common to ‘V8Runtime` and `QuickJSRuntime` — JS asset paths, the host-fn table that bridge.js reaches back through, the error-swallowing wrapper. Each engine plugs the table into its own attach API (rusty_racer’s ‘Context#attach` vs quickjs.rb’s ‘Quickjs::VM#define_function`).

Constant Summary collapse

BRIDGE_JS =
File.expand_path('js/bridge.bundle.js',                 __dir__).freeze
SNAPSHOT_STUBS_JS =
File.expand_path('js/snapshot_stubs.js',                __dir__).freeze
VENDOR_BUNDLE_JS =
File.expand_path('../../../vendor/js/vendor.bundle.js', __dir__).freeze
BROWSER_HOST_FNS =

Host fns whose body touches ‘Browser` — wrap with `safe_call` so a Ruby-side bug in the Browser path doesn’t propagate as a JS exception that crashes the whole script chain. Bodies take ‘(browser, *js_args)` and return whatever the JS caller expects.

{
  '__rackFetch'                => ->(b, *a) { b.rack_fetch(a[0], a[1], a[2], a[3], a[4]) },
  '__csimExternalAsset'        => ->(b, *a) { b.external_asset_source(a[0]) },
  '__locationAssign'           => ->(b, *a) { b.location_assign(a[0]); nil },
  '__locationReload'           => ->(b, *_) { b.location_reload; nil },
  '__setTimersActive'          => ->(b, *a) { b.timers_active = !!a[0]; nil },
  '__setCurrentUrl'            => ->(b, *a) { b.history_state(a[0], a[1]); nil },
  '__pushHistoryEntry'         => ->(b, *a) { b.history_push(a[0], a[1]); nil },
  '__historyGo'                => ->(b, *a) { b.history_go(a[0]); nil },
  '__historyLength'            => ->(b, *_) { b.history_length },
  '__csimReadFilePick'         => ->(b, *a) { b.read_file_pick(a[0], a[1], a[2], a[3]) },
  '__getDocumentCookie'        => ->(b, *_) { b.document_cookie },
  '__setDocumentCookie'        => ->(b, *a) { b.write_document_cookie(a[0].to_s); nil },
  '__getDocumentReferrer'      => ->(b, *_) { b.current_referer },
  '__csim_storageGet'          => ->(b, *a) { b.storage_get(a[0], a[1]) },
  '__csim_storageSet'          => ->(b, *a) { b.storage_set(a[0], a[1], a[2]); nil },
  '__csim_storageRemove'       => ->(b, *a) { b.storage_remove(a[0], a[1]); nil },
  '__csim_storageClear'        => ->(b, *a) { b.storage_clear(a[0]); nil },
  '__csim_storageKey'          => ->(b, *a) { b.storage_key(a[0], a[1]) },
  '__csim_storageLength'       => ->(b, *a) { b.storage_length(a[0]) },
  '__csimGeolocationState'     => ->(b, *_) { b.geolocation_state_json },
  '__modalDialog'              => ->(b, *a) { b.handle_modal(a[0], a[1], a[2]) },
  '__csim_pushImportmap'       => ->(b, *a) { b.set_importmap(a[0]); nil },
  '__csim_logConsole'          => ->(b, *a) { b.log_console(a[0], a[1]); nil },
  '__csim_eventSourceOpen'     => ->(b, *a) { b.event_source_open(a[0]) },
  '__csim_eventSourceClose'    => ->(b, *a) { b.event_source_close(a[0]); nil },
  '__csim_rackFetchAsync'      => ->(b, *a) { b.rack_fetch_async(a[0], a[1], a[2], a[3]) },
  '__csim_rackFetchAsyncAbort' => ->(b, *a) { b.rack_fetch_async_abort(a[0]); nil },
  '__csim_workerSpawn'         => ->(b, *a) { b.worker_spawn(a[0]) },
  '__csim_workerPostToWorker'  => ->(b, *a) { b.worker_post_to_worker(a[0], a[1]); nil },
  '__csim_workerTerminate'     => ->(b, *a) { b.worker_terminate(a[0]); nil },
  '__csim_decodeImage'         => ->(b, *a) { b.decode_image(a[0], a[1], a[2]) },
  '__csim_blobRegister'        => ->(b, *a) { b.blob_register(a[0], a[1]); nil },
  '__csim_blobResolve'         => ->(b, *a) { b.blob_resolve(a[0]) },
  '__csim_blobUnregister'      => ->(b, *a) { b.blob_unregister(a[0]); nil },
  '__csim_transferStash'       => ->(b, *a) { b.transfer_buffer_stash(a[0]) },
  '__csim_transferFetch'       => ->(b, *a) { b.transfer_buffer_fetch_for_js(a[0]) },
  '__csim_decodeVideoFrame'    => ->(b, *a) { b.decode_video_frame(a[0]) },
  '__csim_encodeImage'         => ->(b, *a) { b.encode_image(a[0], a[1], a[2], a[3], a[4]) },
  # WebAuthn create / get raise `WebauthnState::Error` carrying
  # the DOMException name (`InvalidStateError`, …); rescue here
  # so the JS shim sees `{error:, name:}` instead of the
  # `safe_call`-flattened nil that would collapse every failure
  # to a generic NotAllowedError.
  '__csimWebauthnCreate' => ->(b, *a) {
    begin b.webauthn.create(a[0]); rescue WebauthnState::Error => e
      {'error' => e.message, 'name' => e.webauthn_name}
    end
  },
  '__csimWebauthnGet' => ->(b, *a) {
    begin b.webauthn.get(a[0]); rescue WebauthnState::Error => e
      {'error' => e.message, 'name' => e.webauthn_name}
    end
  },
  '__csimWebauthnAddVirtualAuthenticator'    => ->(b, *a) { b.webauthn.add_virtual_authenticator(a[0]) },
  '__csimWebauthnRemoveVirtualAuthenticator' => ->(b, *a) { b.webauthn.remove_virtual_authenticator(a[0]); nil },
  '__csimWebauthnAddCredential'              => ->(b, *a) { b.webauthn.add_credential(a[0], a[1]); nil },
  '__csimWebauthnRemoveCredential'           => ->(b, *a) { b.webauthn.remove_credential(a[0], a[1]); nil },
  '__csimWebauthnGetCredentials'             => ->(b, *a) { b.webauthn.get_credentials(a[0]) },
  '__csimWebauthnSetUserVerified'            => ->(b, *a) { b.webauthn.set_user_verified(a[0], a[1]); nil }
}.freeze
CASCADE_RULE_CACHE =

Host fns that route to pure stdlib — no Browser surface, nothing to safe_call, no allocation needed for the wrap. Skip the rescue overhead on every per-find / per-event invocation. Process-wide cascade-rule cache (mirrors the script bytecode cache). The built layout rules are deterministic per (stylesheet-set, viewport), so the JS side caches the serialized rules keyed by a digest of the sheet sources and skips the ~12-15 ms css-tree parse + per-rule specificity + terminalKey rebuild on every per-visit VM rebuild. Lives in Ruby (not the VM) so it survives ‘rebuild_ctx`. Key space is tiny (one app ships one stylesheet set), so the map stays small; no eviction needed.

{}
CASCADE_RULE_CACHE_MUTEX =
Mutex.new
SHEET_PARSE_CACHE =

Process-wide PER-SHEET parse cache (companion to CASCADE_RULE_CACHE). The built whole-cascade is cached above, but it misses whenever a page’s inline ‘<style>` changes (Avo injects per-page styles), forcing a rebuild that re-parses every sheet — including unchanged linked bundles (avo.base.css). `parseSheet` is pure, so the JS side caches its serialized `hide,layout` keyed by (cssText hash, viewport) here, surviving the per-visit VM rebuild that wipes the in-VM `__sheetCache` — the CSS analogue of the JS bytecode cache. Keyed by content, so a content change yields a new key. Capped.

{}
SHEET_PARSE_CACHE_MUTEX =
Mutex.new
SHEET_PARSE_CACHE_MAX =
2048
STDLIB_HOST_FNS =
{
  '__csimCascadeCacheGet' => ->(*a) { CASCADE_RULE_CACHE_MUTEX.synchronize { CASCADE_RULE_CACHE[a[0].to_s] } },
  '__csimCascadeCachePut' => lambda {|*a|
    CASCADE_RULE_CACHE_MUTEX.synchronize { CASCADE_RULE_CACHE[a[0].to_s] = a[1].to_s }
    nil
  },
  '__csimSheetCacheGet' => ->(*a) { SHEET_PARSE_CACHE_MUTEX.synchronize { SHEET_PARSE_CACHE[a[0].to_s] } },
  '__csimSheetCachePut' => lambda {|*a|
    SHEET_PARSE_CACHE_MUTEX.synchronize {
      SHEET_PARSE_CACHE.clear if SHEET_PARSE_CACHE.size >= SHEET_PARSE_CACHE_MAX
      SHEET_PARSE_CACHE[a[0].to_s] = a[1].to_s
    }
    nil
  },
  '__csim_randomUUID'   => ->(*_) { SecureRandom.uuid },
  '__csim_randomBytes'  => ->(*a) { SecureRandom.bytes(a[0].to_i).bytes },
  '__csim_atob'         => ->(*a) { Base64.decode64(a[0].to_s) },
  '__csim_btoa'         => ->(*a) { Base64.strict_encode64(a[0].to_s) },
  '__csim_utf8Encode'   => ->(*a) { a[0].to_s.b.bytes },
  '__csim_utf8Decode'   => ->(*a) { a[0].pack('C*').force_encoding('UTF-8') },
  # `__csim_parseUrl` is defined in JS now (js/src/url-parse.js, backed by
  # the vendored whatwg-url) — spec-correct + no V8↔Ruby boundary per parse.
  # Web Crypto SubtleCrypto.digest — algo is "SHA-1"/"SHA-256"/etc.
  # JS hands us the byte array; we return the digest as bytes.
  '__csim_subtleDigest' => lambda {|*a|
    algo  = a[0].to_s.upcase.tr('-', '')
    bytes = a[1].is_a?(Array) ? a[1].pack('C*') : a[1].to_s
    OpenSSL::Digest.new(algo).digest(bytes).bytes
  }
}.freeze

Class Method Summary collapse

Class Method Details

.bridge_srcObject



23
# File 'lib/capybara/simulated/runtime_shared.rb', line 23

def self.bridge_src         = File.read(BRIDGE_JS)

.safe_callObject



161
162
163
164
165
166
167
# File 'lib/capybara/simulated/runtime_shared.rb', line 161

def self.safe_call
  yield
rescue StandardError => e
  warn "[capybara-simulated] host fn error: #{e.class}: #{e.message[0, 200]}"
  warn "  at #{e.backtrace&.first(4)&.join("\n  at ")}" if ENV['CSIM_HOSTFN_TRACE'] == '1'
  nil
end

.snapshot_srcObject

Combined source baked into the V8 Snapshot / QuickJS bytecode. Order matters: stubs first (so bridge’s IIFE can reference the ‘globalThis.__rackFetch` etc. slots), then the vendor bundle (so bridge can reference `globalThis.__csimVendor.cssSelect` and `.xpathway`), then bridge proper — which installs the xpathway-backed `Document.prototype.evaluate` itself (see js/src/xpath.js). The standalone xpathway engine in the vendor blob replaces the old wgxpath.



33
34
35
36
37
# File 'lib/capybara/simulated/runtime_shared.rb', line 33

def self.snapshot_src
  snapshot_stubs_src +
    vendor_bundle_src + ";\n" +
    bridge_src
end

.snapshot_stubs_srcObject



22
# File 'lib/capybara/simulated/runtime_shared.rb', line 22

def self.snapshot_stubs_src = File.read(SNAPSHOT_STUBS_JS)

.utf8_text(s) ⇒ Object

Re-tag a text string for the Ruby→JS crossing. Marshalling is tag-driven: a BINARY-tagged String crosses as a Uint8Array, and a UTF-8-tagged string with invalid bytes raises — but Rack bodies, socket reads, and header values all arrive BINARY-tagged even when they ARE text. Decoding response bytes into text is the document layer’s job (csim owns the charset knowledge; the contract is UTF-8), so every text crossing funnels through here: re-tag as UTF-8, scrub only actually-invalid bytes.



177
178
179
180
# File 'lib/capybara/simulated/runtime_shared.rb', line 177

def self.utf8_text(s)
  s = s.dup.force_encoding(Encoding::UTF_8) unless s.encoding == Encoding::UTF_8
  s.valid_encoding? ? s : s.scrub
end

.vendor_bundle_srcObject



24
# File 'lib/capybara/simulated/runtime_shared.rb', line 24

def self.vendor_bundle_src  = File.read(VENDOR_BUNDLE_JS)