web-api-cat 0.7.4

Bindings between boa-cat (JS engine) and the DOM (html-cat tree) plus fetch (net-cat). v0.7.4 ships the `EventTarget` mixin: `addEventListener(type, callback)` queues handlers under a lazy `__listeners__` slot; `removeEventListener` drops them by `Value::PartialEq`; `dispatchEvent(event)` walks the bubble chain via the v0.6.8 `__parent__` backref and invokes each handler through boa-cat 0.7.1's now-public `expression::call_function`. Listener throws are swallowed at the dispatch boundary per DOM spec. Seventh sub-crate of a Servo-replacement webview runtime targeting Tauri.
//! Inline `style="..."` parsing + back-prop (v0.7.2).

use boa_cat::Value;
use boa_cat::env::Env;
use boa_cat::evaluate_program_with;
use boa_cat::fuel::Fuel;
use boa_cat::heap::Heap;
use boa_cat::value::Cell;
use dom_cat::{Arena, ElementData, NodeId};
use ecma_lex_cat::lex;
use ecma_parse_cat::parse_script;
use web_api_cat::document::build as build_document;
use web_api_cat::{Error, extract_document};

fn fail(_msg: &'static str) -> Error {
    Error::Engine(boa_cat::Error::Unsupported { feature: "test" })
}

fn run_script(html: &str, script: &str) -> Result<Value, Error> {
    let html_doc = html_cat::parse(html)?;
    let (env, heap) = web_api_cat::install(Env::empty(), Heap::new(), &html_doc);
    let tokens = lex(script).map_err(boa_cat::Error::from)?;
    let program = parse_script(&tokens).map_err(boa_cat::Error::from)?;
    let (value, _heap) =
        evaluate_program_with(&program, env, heap, Fuel::new(200_000)).map_err(Error::from)?;
    Ok(value)
}

fn install_with_document_handle(html: &str) -> Result<(Env, Heap, Value), Error> {
    let html_doc = html_cat::parse(html)?;
    let (document_value, _root, heap) = build_document(&html_doc, Heap::new());
    let (env, heap) = web_api_cat::install(Env::empty(), heap, &html_doc);
    let (cell_id, heap) = heap.alloc_cell(Cell::new(document_value.clone(), false));
    let env = env.extend_cell("document", cell_id);
    Ok((env, heap, document_value))
}

fn run_and_extract(html: &str, script: &str) -> Result<dom_cat::Document, Error> {
    let (env, heap, document_value) = install_with_document_handle(html)?;
    let tokens = lex(script).map_err(boa_cat::Error::from)?;
    let program = parse_script(&tokens).map_err(boa_cat::Error::from)?;
    let (_value, heap) =
        evaluate_program_with(&program, env, heap, Fuel::new(200_000)).map_err(Error::from)?;
    extract_document(&document_value, &heap).ok_or_else(|| fail("extract_document returned None"))
}

fn find_element<'a>(
    arena: &'a Arena,
    root: NodeId,
    tag: &str,
) -> Option<(&'a ElementData, NodeId)> {
    let node = arena.get(root)?;
    let element_match = node
        .as_element()
        .filter(|element| element.name().eq_ignore_ascii_case(tag))
        .map(|element| (element, root));
    element_match.or_else(|| {
        node.children()
            .iter()
            .find_map(|child| find_element(arena, *child, tag))
    })
}

fn style_attribute(element: &ElementData) -> Option<&str> {
    element
        .attributes()
        .iter()
        .find(|(name, _)| name.eq_ignore_ascii_case("style"))
        .map(|(_, value)| value.as_str())
}

#[test]
fn parsed_style_attribute_populates_style_object_camelcase() -> Result<(), Error> {
    let value = run_script(
        "<html><body><div id='host' style='color: red'></div></body></html>",
        "document.getElementById('host').style.color",
    )?;
    matches!(value, Value::String(ref s) if s == "red")
        .then_some(())
        .ok_or_else(|| fail("expected style.color to be 'red' from parsed style attribute"))
}

#[test]
fn parsed_style_kebab_attribute_becomes_camelcase_property() -> Result<(), Error> {
    let value = run_script(
        "<html><body><div id='host' style='font-size: 14px'></div></body></html>",
        "document.getElementById('host').style.fontSize",
    )?;
    matches!(value, Value::String(ref s) if s == "14px")
        .then_some(())
        .ok_or_else(|| fail("expected font-size to be readable as style.fontSize"))
}

#[test]
fn parsed_style_multiple_declarations() -> Result<(), Error> {
    let value = run_script(
        "<html><body><div id='host' style='color: red; padding: 5px'></div></body></html>",
        "const host = document.getElementById('host');
        host.style.color + ',' + host.style.padding",
    )?;
    matches!(value, Value::String(ref s) if s == "red,5px")
        .then_some(())
        .ok_or_else(|| fail("expected both style props to be readable"))
}

#[test]
fn parsed_style_tolerates_whitespace_and_trailing_semicolon() -> Result<(), Error> {
    let value = run_script(
        "<html><body><div id='host' style='  color :  red ; '></div></body></html>",
        "document.getElementById('host').style.color",
    )?;
    matches!(value, Value::String(ref s) if s == "red")
        .then_some(())
        .ok_or_else(|| fail("expected whitespace/trailing-semicolon to be tolerated"))
}

#[test]
fn parsed_style_compound_property_round_trip() -> Result<(), Error> {
    let value = run_script(
        "<html><body><div id='host' style='border-bottom-color: blue'></div></body></html>",
        "document.getElementById('host').style.borderBottomColor",
    )?;
    matches!(value, Value::String(ref s) if s == "blue")
        .then_some(())
        .ok_or_else(|| fail("expected three-segment kebab to camelCase correctly"))
}

#[test]
fn extract_serializes_style_object_back_to_kebab() -> Result<(), Error> {
    let doc = run_and_extract(
        "<html><body><div id='host'></div></body></html>",
        "const host = document.getElementById('host');
        host.style.color = 'red';
        host.style.fontSize = '14px';",
    )?;
    let (element, _) =
        find_element(doc.arena(), doc.root(), "div").ok_or_else(|| fail("div not found"))?;
    let style = style_attribute(element).ok_or_else(|| fail("style attribute missing"))?;
    style
        .contains("color: red")
        .then_some(())
        .ok_or_else(|| fail("expected 'color: red' in serialised style"))?;
    style
        .contains("font-size: 14px")
        .then_some(())
        .ok_or_else(|| fail("expected 'font-size: 14px' in serialised style"))
}

#[test]
fn extract_drops_style_attribute_when_style_object_is_empty() -> Result<(), Error> {
    let doc = run_and_extract(
        "<html><body><div id='host'></div></body></html>",
        "/* no style writes */",
    )?;
    let (element, _) =
        find_element(doc.arena(), doc.root(), "div").ok_or_else(|| fail("div not found"))?;
    style_attribute(element)
        .is_none()
        .then_some(())
        .ok_or_else(|| fail("expected no style attribute when style Object is empty"))
}

#[test]
fn extract_preserves_parsed_style_through_round_trip() -> Result<(), Error> {
    let doc = run_and_extract(
        "<html><body><div id='host' style='color: red'></div></body></html>",
        "/* read-only */",
    )?;
    let (element, _) =
        find_element(doc.arena(), doc.root(), "div").ok_or_else(|| fail("div not found"))?;
    let style = style_attribute(element).ok_or_else(|| fail("style attribute missing"))?;
    (style == "color: red")
        .then_some(())
        .ok_or_else(|| fail("expected 'color: red' to round-trip unchanged"))
}

#[test]
fn extract_reflects_js_overwrites_of_parsed_style() -> Result<(), Error> {
    let doc = run_and_extract(
        "<html><body><div id='host' style='color: red'></div></body></html>",
        "document.getElementById('host').style.color = 'blue';",
    )?;
    let (element, _) =
        find_element(doc.arena(), doc.root(), "div").ok_or_else(|| fail("div not found"))?;
    let style = style_attribute(element).ok_or_else(|| fail("style attribute missing"))?;
    (style == "color: blue")
        .then_some(())
        .ok_or_else(|| fail("expected JS write to override parsed style on extract"))
}

#[test]
fn extract_appends_new_style_props_to_existing_parse() -> Result<(), Error> {
    let doc = run_and_extract(
        "<html><body><div id='host' style='color: red'></div></body></html>",
        "document.getElementById('host').style.padding = '5px';",
    )?;
    let (element, _) =
        find_element(doc.arena(), doc.root(), "div").ok_or_else(|| fail("div not found"))?;
    let style = style_attribute(element).ok_or_else(|| fail("style attribute missing"))?;
    style
        .contains("color: red")
        .then_some(())
        .ok_or_else(|| fail("expected parsed 'color: red' to persist"))?;
    style
        .contains("padding: 5px")
        .then_some(())
        .ok_or_else(|| fail("expected appended 'padding: 5px' to appear"))
}