- Add line:col source locations in runtime error messages (PR 5) - Consolidate info! → debug! logging, add debug_span! for event loop, dispatch, and microtask drain (PR 6) - Add 14 integration tests across js_events, js_dom_tests, js_tests, and js_scheduling for edge cases (PR 7) - Add JS feature matrix and conformance report documentation (PR 8) - Update CLAUDE.md with JS262 harness commands and doc references - Mark Milestone 6 Phase 5 complete in plan doc Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
443 lines
14 KiB
Rust
443 lines
14 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.querySelectorAll("span");
|
|
"#,
|
|
);
|
|
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.querySelectorAll("div");"#);
|
|
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;
|
|
}
|
|
|
|
// --- 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()
|
|
);
|
|
}
|