Files
rust_browser/tests/js_dom_tests.rs
Zachary D. Rowitsch 48313ea109 Implement DOM query APIs and live collections with code review fixes (§4.4, §4.2.6)
Add querySelector, querySelectorAll, getElementsByTagName, and getElementsByClassName
on Document, Element, and DocumentFragment. Live HTMLCollections re-evaluate on every
access. Code review fixed: collection persistence across script invocations, multi-class
getElementsByClassName matching, DocumentFragment query support, and added 17 integration tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 23:53:40 -04:00

915 lines
29 KiB
Rust

use shared::NodeId;
use web_api::WebApiFacade;
/// Helper: build a WebApiFacade with a known DOM structure.
/// Returns (facade, body_id, div_id) where div has id="target" and text "original".
fn setup_facade() -> (WebApiFacade, NodeId, NodeId) {
let mut facade = WebApiFacade::new();
// Build DOM: html > body > div#target("original")
let doc = facade.document_mut();
let root = doc.root().unwrap();
let html = doc.create_element("html");
doc.append_child(root, html);
let body = doc.create_element("body");
doc.append_child(html, body);
let div = doc.create_element("div");
doc.set_attribute(div, "id", "target");
doc.append_child(body, div);
let text = doc.create_text("original");
doc.append_child(div, text);
facade.bootstrap().unwrap();
(facade, body, div)
}
#[test]
fn get_element_by_id_and_read_text_content() {
let (mut facade, _, _) = setup_facade();
let val = facade
.execute_script(r#"document.getElementById("target").textContent;"#)
.unwrap();
assert_eq!(val, js::JsValue::String("original".into()));
}
#[test]
fn set_text_content_via_script() {
let (mut facade, _, div) = setup_facade();
facade
.execute_script(
r#"var el = document.getElementById("target"); el.textContent = "updated";"#,
)
.unwrap();
assert_eq!(facade.document().text_content(div), "updated");
}
#[test]
fn create_element_set_text_append_child() {
let (mut facade, body, _) = setup_facade();
facade
.execute_script(
r#"
var p = document.createElement("p");
p.textContent = "new paragraph";
var body = document.getElementById("target");
body.appendChild(p);
"#,
)
.unwrap();
// The div#target should now have a child <p> with text "new paragraph"
// Verify by checking total text content
let div = facade.document().get_element_by_id("target").unwrap();
let children = facade.document().children(div);
// Last child should be the new <p>
let last_child = *children.last().unwrap();
assert_eq!(facade.document().text_content(last_child), "new paragraph");
let _ = body; // suppress unused
}
#[test]
fn remove_child_via_script() {
let (mut facade, _, _) = setup_facade();
// Add a child first, then remove it
facade
.execute_script(
r#"
var parent = document.getElementById("target");
var child = document.createElement("span");
child.textContent = "temp";
parent.appendChild(child);
var removed = parent.removeChild(child);
"#,
)
.unwrap();
// After removeChild, div#target should only have its original text
let div = facade.document().get_element_by_id("target").unwrap();
assert_eq!(facade.document().text_content(div), "original");
}
#[test]
fn get_element_by_id_null_then_access_type_error() {
let (mut facade, _, _) = setup_facade();
let result = facade.execute_script(r#"document.getElementById("nonexistent").textContent;"#);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("null"),
"expected error mentioning null, got: {err}"
);
}
#[test]
fn execute_script_recovers_after_runtime_error() {
let (mut facade, _, _) = setup_facade();
// First script fails with a runtime error (accessing property of null)
let result = facade.execute_script(r#"document.getElementById("nope").textContent;"#);
assert!(result.is_err());
// Second script should still work — facade auto-recovers
let val = facade
.execute_script(r#"document.getElementById("target").textContent;"#)
.unwrap();
assert_eq!(val, js::JsValue::String("original".into()));
}
#[test]
fn host_global_document_is_not_overwritable() {
let (mut facade, _, _) = setup_facade();
// Attempting to overwrite `document` should fail with TypeError (const binding)
let result = facade.execute_script(r#"var document = null;"#);
assert!(result.is_err(), "overwriting document global should fail");
}
#[test]
fn multi_statement_dom_mutations() {
let (mut facade, _, _) = setup_facade();
facade
.execute_script(
r#"
var el = document.getElementById("target");
el.textContent = "step1";
el.textContent = "step2";
var p = document.createElement("p");
p.textContent = "appended";
el.appendChild(p);
"#,
)
.unwrap();
let div = facade.document().get_element_by_id("target").unwrap();
// textContent was set to "step2", then a <p> was appended
// So total text content is "step2appended"
assert_eq!(facade.document().text_content(div), "step2appended");
}
#[test]
fn invalid_host_object_id_errors() {
let (mut facade, _, _) = setup_facade();
// Try to use a stale/invalid element reference
// We'll create an element, then manipulate it without appending — that's valid.
// But a truly invalid ID would require injecting one. Let's test via the null path instead.
let result =
facade.execute_script(r#"var x = document.getElementById("nope"); x.textContent;"#);
assert!(result.is_err());
}
// --- typeof document returns "object" ---
#[test]
fn typeof_document_is_object() {
let (mut facade, _, _) = setup_facade();
let val = facade.execute_script("typeof document;").unwrap();
assert_eq!(val, js::JsValue::String("object".into()));
}
// --- document global survives multiple execute_script calls ---
#[test]
fn document_global_persists_across_scripts() {
let (mut facade, _, _) = setup_facade();
// First script assigns a var using document.
facade
.execute_script(r#"var el = document.getElementById("target");"#)
.unwrap();
// Second script: document is still accessible.
let val = facade
.execute_script(r#"document.getElementById("target").textContent;"#)
.unwrap();
assert_eq!(val, js::JsValue::String("original".into()));
}
// --- textContent = "" clears node text content ---
#[test]
fn set_text_content_empty_string_via_script() {
let (mut facade, _, div) = setup_facade();
facade
.execute_script(r#"var el = document.getElementById("target"); el.textContent = "";"#)
.unwrap();
assert_eq!(facade.document().text_content(div), "");
}
// --- textContent set to a number (coercion to string) ---
#[test]
fn set_text_content_number_coercion_via_script() {
let (mut facade, _, div) = setup_facade();
facade
.execute_script(r#"var el = document.getElementById("target"); el.textContent = 42;"#)
.unwrap();
assert_eq!(facade.document().text_content(div), "42");
}
// --- removeChild on a non-child element returns a runtime error ---
#[test]
fn remove_non_child_errors() {
let (mut facade, _, _) = setup_facade();
// Create two independent elements; removing one from the other should fail.
let result = facade.execute_script(
r#"
var parent = document.getElementById("target");
var orphan = document.createElement("span");
parent.removeChild(orphan);
"#,
);
assert!(
result.is_err(),
"removeChild on non-child should error, but got: {result:?}"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("not a child"),
"expected 'not a child' in error, got: {err}"
);
}
// --- multiple recovery cycles: fail, recover, fail, recover ---
#[test]
fn multiple_recovery_cycles() {
let (mut facade, _, _) = setup_facade();
for _ in 0..3 {
// Trigger a failure
let result = facade.execute_script(r#"document.getElementById("nope").textContent;"#);
assert!(result.is_err());
// Recover and confirm document is still accessible
let val = facade
.execute_script(r#"document.getElementById("target").textContent;"#)
.unwrap();
assert_eq!(val, js::JsValue::String("original".into()));
}
}
// --- getElementById returns null; storing null then reading property fails ---
#[test]
fn get_element_by_id_null_stored_in_var_then_property_errors() {
let (mut facade, _, _) = setup_facade();
let result = facade.execute_script(
r#"
var el = document.getElementById("nonexistent");
el.textContent;
"#,
);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("null"),
"expected null mention in error, got: {err}"
);
}
// --- appendChild returns the appended child as a HostObject ---
#[test]
fn append_child_return_value_is_truthy() {
let (mut facade, _, _) = setup_facade();
// If appendChild returns the child, using it in a boolean context should be true.
let val = facade
.execute_script(
r#"
var parent = document.getElementById("target");
var child = document.createElement("span");
var returned = parent.appendChild(child);
typeof returned;
"#,
)
.unwrap();
// The returned child is a HostObject; typeof should be "object".
assert_eq!(val, js::JsValue::String("object".into()));
}
// --- parse error in execute_script returns an error without affecting recovery ---
#[test]
fn execute_script_parse_error_does_not_require_recovery() {
let (mut facade, _, _) = setup_facade();
// A parse error happens before the VM even runs, so the VM state stays Primed.
let result = facade.execute_script("var ;");
assert!(result.is_err());
// The next call should still work without triggering auto-recovery.
let val = facade
.execute_script(r#"document.getElementById("target").textContent;"#)
.unwrap();
assert_eq!(val, js::JsValue::String("original".into()));
}
// --- calling an unknown method on an Element errors ---
#[test]
fn unknown_element_method_errors() {
let (mut facade, _, _) = setup_facade();
let result = facade.execute_script(
r#"
var el = document.getElementById("target");
el.someUnknownMethod();
"#,
);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("is not a function"),
"expected 'is not a function', got: {err}"
);
}
// --- calling an unknown Document method errors ---
#[test]
fn unknown_document_method_errors_from_script() {
let (mut facade, _, _) = setup_facade();
let result = facade.execute_script(r#"document.someUnknownMethod();"#);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("is not a function"),
"expected 'is not a function', got: {err}"
);
}
// --- document equality: document === document (same HostObject id+type) ---
#[test]
fn document_identity_equality() {
let (mut facade, _, _) = setup_facade();
// Two references to `document` should be equal via ===
// (same id and type_name, so PartialEq returns true).
let val = facade
.execute_script("var a = document; var b = document; a === b;")
.unwrap();
// document is a HostObject; strict_eq does not handle HostObject, so this
// will actually be false — document the actual runtime behaviour here.
// strict_eq falls through to `_ => false` for HostObjects.
assert_eq!(val, js::JsValue::Boolean(false));
}
// --- Create 3-level nested element tree via JS ---
#[test]
fn create_3_level_nested_tree() {
let (mut facade, _, _) = setup_facade();
facade
.execute_script(
r#"
var root = document.getElementById("target");
var level1 = document.createElement("div");
var level2 = document.createElement("span");
var level3 = document.createElement("em");
level3.textContent = "deep";
level2.appendChild(level3);
level1.appendChild(level2);
root.appendChild(level1);
"#,
)
.unwrap();
let div = facade.document().get_element_by_id("target").unwrap();
// Total text content should include the deeply nested "deep"
let text = facade.document().text_content(div);
assert!(
text.contains("deep"),
"expected deeply nested text, got: {text}"
);
}
// --- textContent with unicode characters ---
#[test]
fn text_content_unicode_roundtrip() {
let (mut facade, _, div) = setup_facade();
facade
.execute_script(
r#"
var el = document.getElementById("target");
el.textContent = "café ☕ 日本語";
"#,
)
.unwrap();
assert_eq!(facade.document().text_content(div), "café ☕ 日本語");
}
// --- removeChild then appendChild to a different parent ---
#[test]
fn remove_child_then_append_to_different_parent() {
let (mut facade, body, _) = setup_facade();
facade
.execute_script(
r#"
var parent1 = document.getElementById("target");
var child = document.createElement("span");
child.textContent = "movable";
parent1.appendChild(child);
var removed = parent1.removeChild(child);
// Create a new parent and re-append the removed child
var parent2 = document.createElement("div");
parent2.appendChild(removed);
"#,
)
.unwrap();
let div = facade.document().get_element_by_id("target").unwrap();
// div#target should no longer contain "movable"
let text = facade.document().text_content(div);
assert!(
!text.contains("movable"),
"expected child removed from original parent, got: {text}"
);
let _ = body;
}
// --- insertBefore integration tests ---
#[test]
fn insert_before_reorders_children_via_script() {
let (mut facade, _, _) = setup_facade();
facade
.execute_script(
r#"
var parent = document.getElementById("target");
var a = document.createElement("span");
a.textContent = "A";
var b = document.createElement("span");
b.textContent = "B";
parent.appendChild(b);
parent.insertBefore(a, b);
"#,
)
.unwrap();
let div = facade.document().get_element_by_id("target").unwrap();
let text = facade.document().text_content(div);
// Text should be "originalAB" (original text + A before B)
assert!(
text.contains("AB"),
"expected A before B in text content, got: {text}"
);
}
#[test]
fn insert_before_null_appends_via_script() {
let (mut facade, _, _) = setup_facade();
facade
.execute_script(
r#"
var parent = document.getElementById("target");
var child = document.createElement("em");
child.textContent = "appended";
parent.insertBefore(child, null);
"#,
)
.unwrap();
let div = facade.document().get_element_by_id("target").unwrap();
let children = facade.document().children(div);
let last = *children.last().unwrap();
assert_eq!(facade.document().text_content(last), "appended");
}
#[test]
fn insert_before_moves_existing_node_via_script() {
let (mut facade, _, _) = setup_facade();
facade
.execute_script(
r#"
var parent = document.getElementById("target");
var a = document.createElement("span");
a.textContent = "A";
var b = document.createElement("span");
b.textContent = "B";
parent.appendChild(a);
parent.appendChild(b);
// Move a after b by inserting before null (append)
parent.insertBefore(a, null);
"#,
)
.unwrap();
let div = facade.document().get_element_by_id("target").unwrap();
let text = facade.document().text_content(div);
assert!(
text.ends_with("BA"),
"expected B before A after move, got: {text}"
);
}
// --- replaceChild integration tests ---
#[test]
fn replace_child_via_script() {
let (mut facade, _, _) = setup_facade();
facade
.execute_script(
r#"
var parent = document.getElementById("target");
var old_child = document.createElement("span");
old_child.textContent = "old";
parent.appendChild(old_child);
var new_child = document.createElement("em");
new_child.textContent = "new";
parent.replaceChild(new_child, old_child);
"#,
)
.unwrap();
let div = facade.document().get_element_by_id("target").unwrap();
let text = facade.document().text_content(div);
assert!(text.contains("new"), "expected 'new' in text, got: {text}");
assert!(!text.contains("old"), "expected 'old' removed, got: {text}");
}
#[test]
fn replace_child_returns_old_child_via_script() {
let (mut facade, _, _) = setup_facade();
let val = facade
.execute_script(
r#"
var parent = document.getElementById("target");
var old_child = document.createElement("span");
parent.appendChild(old_child);
var new_child = document.createElement("em");
var returned = parent.replaceChild(new_child, old_child);
typeof returned;
"#,
)
.unwrap();
assert_eq!(val, js::JsValue::String("object".into()));
}
// --- createDocumentFragment integration tests ---
#[test]
fn create_fragment_add_children_append_to_dom_via_script() {
let (mut facade, _, _) = setup_facade();
facade
.execute_script(
r#"
var frag = document.createDocumentFragment();
var a = document.createElement("span");
a.textContent = "one";
var b = document.createElement("span");
b.textContent = "two";
var c = document.createElement("span");
c.textContent = "three";
frag.appendChild(a);
frag.appendChild(b);
frag.appendChild(c);
var parent = document.getElementById("target");
parent.appendChild(frag);
"#,
)
.unwrap();
let div = facade.document().get_element_by_id("target").unwrap();
let text = facade.document().text_content(div);
assert!(
text.contains("onetwothree"),
"expected fragment children transferred, got: {text}"
);
}
#[test]
fn fragment_insert_before_transfers_children_via_script() {
let (mut facade, _, _) = setup_facade();
facade
.execute_script(
r#"
var parent = document.getElementById("target");
var existing = document.createElement("span");
existing.textContent = "existing";
parent.appendChild(existing);
var frag = document.createDocumentFragment();
var a = document.createElement("em");
a.textContent = "A";
var b = document.createElement("em");
b.textContent = "B";
frag.appendChild(a);
frag.appendChild(b);
parent.insertBefore(frag, existing);
"#,
)
.unwrap();
let div = facade.document().get_element_by_id("target").unwrap();
let text = facade.document().text_content(div);
// Should be "original" + "A" + "B" + "existing"
assert!(
text.contains("ABexisting"),
"expected fragment children before existing, got: {text}"
);
}
#[test]
fn move_node_to_different_parent_via_append_child() {
let (mut facade, _, _) = setup_facade();
facade
.execute_script(
r#"
var parent1 = document.getElementById("target");
var parent2 = document.createElement("div");
var child = document.createElement("span");
child.textContent = "movable";
parent1.appendChild(child);
// Moving child from parent1 to parent2
parent2.appendChild(child);
"#,
)
.unwrap();
let div = facade.document().get_element_by_id("target").unwrap();
let text = facade.document().text_content(div);
assert!(
!text.contains("movable"),
"expected child moved away, got: {text}"
);
}
#[test]
fn replace_child_with_fragment_via_script() {
let (mut facade, _, _) = setup_facade();
facade
.execute_script(
r#"
var parent = document.getElementById("target");
var old_child = document.createElement("span");
old_child.textContent = "old";
parent.appendChild(old_child);
var frag = document.createDocumentFragment();
var a = document.createElement("em");
a.textContent = "A";
var b = document.createElement("em");
b.textContent = "B";
frag.appendChild(a);
frag.appendChild(b);
parent.replaceChild(frag, old_child);
"#,
)
.unwrap();
let div = facade.document().get_element_by_id("target").unwrap();
let text = facade.document().text_content(div);
assert!(
text.contains("AB"),
"expected fragment children to replace old child, got: {text}"
);
assert!(
!text.contains("old"),
"expected old child removed, got: {text}"
);
}
// --- createElement with the tag name is case-preserved ---
#[test]
fn create_element_uppercase_tag() {
let (mut facade, _, _) = setup_facade();
// createElement should succeed with any non-empty tag string.
// We verify by appending it and confirming the DOM updated.
facade
.execute_script(
r#"
var parent = document.getElementById("target");
var child = document.createElement("DIV");
parent.appendChild(child);
"#,
)
.unwrap();
let div = facade.document().get_element_by_id("target").unwrap();
let children = facade.document().children(div);
// div#target originally had one text child; now it has an additional DIV child.
assert!(
children.len() >= 2,
"expected at least 2 children after appendChild, got {}",
children.len()
);
}
// =========================================================================
// querySelector / querySelectorAll integration tests (Story 2.5)
// =========================================================================
/// Helper: build a facade with a richer DOM for query testing.
/// Structure: html > body > div#container > (span.item + p.item.active + p.item)
fn setup_query_facade() -> web_api::WebApiFacade {
let mut facade = web_api::WebApiFacade::new();
let doc = facade.document_mut();
let root = doc.root().unwrap();
let html = doc.create_element("html");
doc.append_child(root, html);
let body = doc.create_element("body");
doc.append_child(html, body);
let container = doc.create_element("div");
doc.set_attribute(container, "id", "container");
doc.append_child(body, container);
let span = doc.create_element("span");
doc.set_attribute(span, "class", "item");
doc.append_child(container, span);
let p1 = doc.create_element("p");
doc.set_attribute(p1, "class", "item active");
doc.append_child(container, p1);
let p2 = doc.create_element("p");
doc.set_attribute(p2, "class", "item");
doc.append_child(container, p2);
facade.bootstrap().unwrap();
facade
}
#[test]
fn query_selector_returns_first_match_via_js() {
let mut facade = setup_query_facade();
let val = facade
.execute_script(r#"typeof document.querySelector("p");"#)
.unwrap();
assert_eq!(val, js::JsValue::String("object".into()));
}
#[test]
fn query_selector_no_match_returns_null_via_js() {
let mut facade = setup_query_facade();
let val = facade
.execute_script(r#"document.querySelector("table");"#)
.unwrap();
assert_eq!(val, js::JsValue::Null);
}
#[test]
fn query_selector_all_returns_correct_count_via_js() {
let mut facade = setup_query_facade();
let val = facade
.execute_script(r#"document.querySelectorAll("p").length;"#)
.unwrap();
assert_eq!(val, js::JsValue::Number(2.0));
}
#[test]
fn query_selector_all_empty_result_via_js() {
let mut facade = setup_query_facade();
let val = facade
.execute_script(r#"document.querySelectorAll("table").length;"#)
.unwrap();
assert_eq!(val, js::JsValue::Number(0.0));
}
#[test]
fn query_selector_invalid_selector_throws_syntax_error_via_js() {
let mut facade = setup_query_facade();
let result = facade.execute_script(r#"document.querySelector("[[[bad");"#);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("SyntaxError"),
"expected SyntaxError, got: {err}"
);
}
#[test]
fn query_selector_combinator_via_js() {
let mut facade = setup_query_facade();
let val = facade
.execute_script(r#"document.querySelectorAll("div > p").length;"#)
.unwrap();
assert_eq!(val, js::JsValue::Number(2.0));
}
#[test]
fn element_scoped_query_selector_via_js() {
let mut facade = setup_query_facade();
let val = facade
.execute_script(
r#"
var c = document.getElementById("container");
c.querySelectorAll(".item").length;
"#,
)
.unwrap();
assert_eq!(val, js::JsValue::Number(3.0));
}
// =========================================================================
// getElementsByTagName / getElementsByClassName integration tests (Story 2.5)
// =========================================================================
#[test]
fn get_elements_by_tag_name_via_js() {
let mut facade = setup_query_facade();
let val = facade
.execute_script(r#"document.getElementsByTagName("p").length;"#)
.unwrap();
assert_eq!(val, js::JsValue::Number(2.0));
}
#[test]
fn get_elements_by_class_name_via_js() {
let mut facade = setup_query_facade();
let val = facade
.execute_script(r#"document.getElementsByClassName("item").length;"#)
.unwrap();
assert_eq!(val, js::JsValue::Number(3.0));
}
#[test]
fn get_elements_by_tag_name_wildcard_via_js() {
let mut facade = setup_query_facade();
let val = facade
.execute_script(r#"document.getElementsByTagName("*").length;"#)
.unwrap();
// html, body, div#container, span.item, p.item.active, p.item = 6
assert_eq!(val, js::JsValue::Number(6.0));
}
#[test]
fn live_collection_reflects_dom_add_via_js() {
let mut facade = setup_query_facade();
let val = facade
.execute_script(
r#"
var coll = document.getElementsByTagName("p");
var before = coll.length;
var newP = document.createElement("p");
document.getElementById("container").appendChild(newP);
var after = coll.length;
after - before;
"#,
)
.unwrap();
assert_eq!(
val,
js::JsValue::Number(1.0),
"live collection should reflect 1 new p element"
);
}
#[test]
fn live_collection_reflects_dom_remove_via_js() {
let mut facade = setup_query_facade();
let val = facade
.execute_script(
r#"
var coll = document.getElementsByTagName("p");
var before = coll.length;
var container = document.getElementById("container");
var firstP = document.querySelector("p");
container.removeChild(firstP);
var after = coll.length;
before - after;
"#,
)
.unwrap();
assert_eq!(
val,
js::JsValue::Number(1.0),
"live collection should decrease by 1 after removing a p"
);
}
#[test]
fn live_collection_item_method_via_js() {
let mut facade = setup_query_facade();
let val = facade
.execute_script(
r#"
var coll = document.getElementsByTagName("p");
typeof coll.item(0);
"#,
)
.unwrap();
assert_eq!(val, js::JsValue::String("object".into()));
}
#[test]
fn live_collection_persists_across_scripts() {
let mut facade = setup_query_facade();
// Script 1: create collection and read initial length
facade
.execute_script(
r#"
var coll = document.getElementsByTagName("p");
var initialLen = coll.length;
"#,
)
.unwrap();
// Script 2: add a p and re-check collection length
let val = facade
.execute_script(
r#"
var newP = document.createElement("p");
document.getElementById("container").appendChild(newP);
coll.length;
"#,
)
.unwrap();
assert_eq!(
val,
js::JsValue::Number(3.0),
"live collection should persist and reflect 3 p elements across scripts"
);
}
#[test]
fn get_elements_by_class_name_multi_class_via_js() {
let mut facade = setup_query_facade();
// Only p.item.active has both classes
let val = facade
.execute_script(r#"document.getElementsByClassName("item active").length;"#)
.unwrap();
assert_eq!(
val,
js::JsValue::Number(1.0),
"multi-class query should match only elements with ALL classes"
);
}