///|
/// Set a custom JS runtime for script execution (e.g. V8 for native).
/// Must be called before any script execution (before navigate/load_html).
pub fn Browser::set_js_runtime(
self : Browser,
runtime : &@js.JsRuntime,
) -> Unit {
self.js_runtime_override = Some(runtime)
self.enable_js = true
}
///|
fn decode_js_string_result(value : String) -> String? {
let parsed = @json.parse(value) catch { _ => return None }
match parsed {
String(text) => Some(text)
_ => None
}
}
///|
/// Inject crater's real layout into the JS realm so page-JS DOM measurement
/// (`getBoundingClientRect` / `offset*` / `getComputedStyle`) reads it instead
/// of the inline-style heuristic. Computes (or reuses the cached) render node +
/// layout for the current viewport and evaluates the bridge payload (see
/// `renderer/vrt` `layout_bridge_init_js`) in the page realm before scripts run,
/// defining `globalThis.__craterLayoutBoxes` / `__craterComputedStyles`. This is
/// the "initial-layout" path of the dynamic-rendering JS bridge: reads before a
/// mutation see real geometry; reads after a mutation see the last injected
/// layout until the next injection. Best-effort — any failure leaves the mock
/// DOM on its existing fallback behavior.
fn Browser::inject_layout_bridge(self : Browser) -> Unit {
if !self.enable_js || self.html_content.length() == 0 {
return
}
// Skip recomputing/re-injecting when the DOM is unchanged since the last
// injection: the layout is identical, so the globals already in the realm are
// still correct. This elides the redundant full re-layout on idle / no-DOM-
// mutation run-loop iterations (true dirty-subtree incremental reflow on a
// mutation is a separate follow-up — see the design memo). The key includes the
// color scheme so toggling dark mode (which changes layout / computed style via
// prefers-color-scheme / light-dark()) forces a re-injection.
let memo_key = (if self.dark_mode { "d:" } else { "l:" }) + self.html_content
if memo_key == self.bridge_injected_html {
return
}
match self.script_executor {
Some(executor) => {
// Build with the browser's current color scheme so page JS measures the
// same scheme the page renders in (not hard-coded light).
let ctx = @browser_helpers.create_render_context(
self.viewport_width,
self.viewport_height,
self.dark_mode,
)
// Compute layout directly (do NOT use the cached graphics node/layout):
// the graphics cache is keyed for the paint path and may be populated
// with an image provider installed; reusing it here would either pollute
// or read a paint-specific cache.
let (node, layout) = match self.parsed_doc {
Some(doc) =>
self.render_node_and_layout_from_document(doc, ctx, self.dark_mode)
None =>
@renderer.render_to_node_and_layout_with_external_css(
self.html_content,
ctx,
self.external_css,
)
}
let js = @vrt.layout_bridge_init_js(
@vrt.collect_layout_boxes(layout),
@vrt.collect_computed_styles(node),
)
try {
let _ = executor.execute_source(js)
// Remember the DOM + color-scheme revision we injected for, so an
// unchanged one skips the next recompute. Only on success — a failure
// retries next time.
self.bridge_injected_html = memo_key
} catch {
_ => ()
}
}
None => ()
}
}
///|
fn Browser::flush_js_logs(self : Browser) -> Unit {
match self.script_executor {
Some(executor) => {
for log in executor.get_logs() {
println("[JS] " + log)
}
executor.clear_logs()
}
None => ()
}
}
///|
fn escape_js_string(s : String) -> String {
let buf = StringBuilder::new()
for c in s {
match c {
'\\' => buf.write_string("\\\\")
'\'' => buf.write_string("\\'")
'\n' => buf.write_string("\\n")
'\r' => buf.write_string("\\r")
'\t' => buf.write_string("\\t")
_ => buf.write_char(c)
}
}
buf.to_string()
}
///|
fn Browser::execute_inline_js(self : Browser, source : String) -> String? {
if !self.enable_js {
return None
}
if self.dom_tree is None {
self.init_js_execution()
}
match self.script_executor {
Some(executor) =>
try {
self.inject_layout_bridge()
let result = executor.execute_source(source)
self.flush_js_logs()
let _ = self.drain_pending_form_submission_navigation_sync()
Some(result.value)
} catch {
err => {
println("[JS] Error: " + err.to_string())
self.flush_js_logs()
None
}
}
None => None
}
}
///|
pub async fn Browser::execute_inline_js_async(
self : Browser,
source : String,
) -> String? raise @http.HttpError {
let result = self.execute_inline_js(source)
let navigated = self.drain_pending_form_submission_navigation()
if !navigated {
let _ = self.sync_render_state_from_dom_tree()
}
result
}
///|
fn Browser::process_pending_script_tasks(self : Browser) -> Int {
let ready_tasks = self.scheduler.poll_ready()
// Make crater's real layout visible to page JS before the batch runs.
if ready_tasks.length() > 0 {
self.inject_layout_bridge()
}
let mut processed = 0
for task in ready_tasks {
match task.action {
@scheduler.ExecuteScript(_) =>
match self.script_executor {
Some(executor) => {
let result = executor.execute(task)
let _ = self.scheduler.complete(task.id, result)
processed = processed + 1
}
None => ()
}
_ => ()
}
}
processed
}
///|
pub fn Browser::tick_js(self : Browser) -> Bool {
if !self.enable_js {
return false
}
if self.dom_tree is None {
self.init_js_execution()
}
let processed = self.process_pending_script_tasks()
if processed > 0 {
self.flush_js_logs()
if self.drain_pending_form_submission_navigation_sync() {
return true
}
return self.sync_render_state_from_dom_tree()
}
// Refresh the injected layout before timer / rAF callbacks run so they can
// measure the geometry produced by the previous iteration's mutations.
self.inject_layout_bridge()
let ticked = match self.script_executor {
Some(executor) =>
executor.tick() catch {
err => {
println("[JS] Error: " + err.to_string())
false
}
}
None => false
}
if !ticked {
return false
}
self.flush_js_logs()
if self.drain_pending_form_submission_navigation_sync() {
return true
}
self.sync_render_state_from_dom_tree()
}
///|
/// Drive the JS event loop to quiescence: repeatedly run pending script / event
/// tasks, drain microtasks, apply the batched DOM ops, re-render, and run one
/// timer + requestAnimationFrame callback per iteration — looping until nothing
/// is left to do (no pending tasks and no timer/rAF fired) or `max_iterations`
/// is hit. The layout bridge is refreshed each iteration so timer / rAF
/// callbacks measure the geometry of the latest render (`setState -> re-render`,
/// effects, and animation steps run without an external pump).
///
/// Returns `true` when the loop settled, `false` when it stopped at the
/// iteration cap (a runaway timer chain). A navigation triggered mid-loop ends
/// the loop and reports settled.
pub fn Browser::run_event_loop(
self : Browser,
max_iterations? : Int = 1000,
) -> Bool {
if !self.enable_js {
return true
}
if self.dom_tree is None {
self.init_js_execution()
}
for i = 0; i < max_iterations; i = i + 1 {
let mut worked = false
// 1. Pending script / event-handler tasks (injects the bridge internally).
let processed = self.process_pending_script_tasks()
if processed > 0 {
worked = true
self.flush_js_logs()
if self.drain_pending_form_submission_navigation_sync() {
return true
}
let _ = self.sync_render_state_from_dom_tree()
}
// 2. One macrotask (timer) + rAF + microtask drain, with fresh geometry.
self.inject_layout_bridge()
let ticked = match self.script_executor {
Some(executor) => executor.tick() catch { _ => false }
None => false
}
if ticked {
worked = true
self.flush_js_logs()
if self.drain_pending_form_submission_navigation_sync() {
return true
}
let _ = self.sync_render_state_from_dom_tree()
}
if !worked {
return true
}
}
false
}
///|
/// Execute scripts from the page (inline only, sync version)
/// Requires enable_js to be set to true (use --enable-js flag)
pub fn Browser::execute_scripts(self : Browser) -> Int {
// Check if JS execution is enabled
if !self.enable_js {
return 0
}
// Ensure JS execution is initialized
if self.dom_tree is None {
self.init_js_execution()
}
// Extract scripts from HTML
let scripts = extract_scripts(self.html_content)
if scripts.length() == 0 {
return 0
}
println("Found " + scripts.length().to_string() + " script(s)")
let mut executed = 0
for script in scripts {
// Skip external scripts (use execute_scripts_async for external)
if script.src.length() > 0 {
println(" [SKIP] External script: " + script.src)
continue
}
// Queue script for execution
let blocking = !script.is_async && !script.is_defer
let task_id = @js.enqueue_script(self.scheduler, script.source, blocking)
println(
" [QUEUED] Inline script (task " +
task_id.to_string() +
", blocking=" +
blocking.to_string() +
")",
)
}
// Process queued scripts
executed = self.process_pending_script_tasks()
if executed > 0 {
if self.drain_pending_form_submission_navigation_sync() {
self.flush_js_logs()
println("Executed " + executed.to_string() + " script(s)")
return executed
}
let _ = self.sync_render_state_from_dom_tree()
}
self.flush_js_logs()
println("Executed " + executed.to_string() + " script(s)")
executed
}
///|
/// Execute scripts from the page (including external scripts, async version)
/// Requires enable_js to be set to true (use --enable-js flag)
async fn Browser::execute_scripts_async(self : Browser) -> Int {
// Check if JS execution is enabled
if !self.enable_js {
return 0
}
// Ensure JS execution is initialized
if self.dom_tree is None {
self.init_js_execution()
}
// Extract scripts from HTML
let scripts = extract_scripts(self.html_content)
if scripts.length() == 0 {
return 0
}
println("Found " + scripts.length().to_string() + " script(s)")
let mut executed = 0
for script in scripts {
// Skip non-JavaScript scripts (e.g., type="application/json")
if !is_executable_script_type(script.script_type) {
println(" [SKIP] Non-JS script type: " + script.script_type)
continue
}
// Determine script source
let mut source = ""
let mut script_kind = "Inline"
if script.src.length() > 0 {
match self.fetch_external_script_source(script.src) {
Some(fetched_source) => {
source = fetched_source
script_kind = "External"
}
None => continue
}
} else {
source = script.source
}
// Skip empty scripts
if source.length() == 0 {
continue
}
// Queue script for execution
let blocking = !script.is_async && !script.is_defer
let task_id = @js.enqueue_script(self.scheduler, source, blocking)
println(
" [QUEUED] " +
script_kind +
" script (task " +
task_id.to_string() +
", blocking=" +
blocking.to_string() +
")",
)
}
// Process queued scripts
executed = self.process_pending_script_tasks()
if executed > 0 {
let navigated = self.drain_pending_form_submission_navigation() catch {
_ => false
}
if !navigated {
let _ = self.sync_render_state_from_dom_tree()
}
}
self.flush_js_logs()
println("Executed " + executed.to_string() + " script(s)")
executed
}