From 882cc623968a9fed6299a1c0b2ababbe0fb3fc94 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Thu, 22 Oct 2026 07:25:55 +0800 Subject: [PATCH] fix(dom): convert MutationObserver options through WebIDL --- .../context_bootstrap/observer_template.rs | 2 +- moli-renderer-v8/src/observer_runtime/mod.rs | 201 ++++++++--------- .../src/script_vm/tests/observer_callbacks.rs | 100 +++++++++ .../tests/fixtures/mutation-observer-init.js | 203 ++++++++++++++++++ .../idl/mutationobserver-idlharness.html | 2 +- 5 files changed, 395 insertions(+), 113 deletions(-) create mode 100644 moli-renderer-v8/tests/fixtures/mutation-observer-init.js diff --git a/moli-renderer-v8/src/context_bootstrap/observer_template.rs b/moli-renderer-v8/src/context_bootstrap/observer_template.rs index 7fa69de2c6..7d9261ec3b 100644 --- a/moli-renderer-v8/src/context_bootstrap/observer_template.rs +++ b/moli-renderer-v8/src/context_bootstrap/observer_template.rs @@ -18,7 +18,7 @@ use moli_webapi_declare::WebApiFunctionTemplate; #[derive(WebApiFunctionTemplate)] #[webapi(interface = web_api_interfaces::MutationObserver, enumerable, receiver)] struct MutationObserverTemplateMethodsDeclaration { - #[webapi(method, length = 2, callback = observer_runtime::mutation_observer_observe_callback)] + #[webapi(method, length = 1, callback = observer_runtime::mutation_observer_observe_callback)] observe: (), #[webapi( diff --git a/moli-renderer-v8/src/observer_runtime/mod.rs b/moli-renderer-v8/src/observer_runtime/mod.rs index cb94c60bf4..99896be61e 100644 --- a/moli-renderer-v8/src/observer_runtime/mod.rs +++ b/moli-renderer-v8/src/observer_runtime/mod.rs @@ -312,6 +312,54 @@ pub(super) struct MutationObserverOptions { attribute_filter: Option>, } +impl TryFrom for MutationObserverOptions { + type Error = &'static str; + + fn try_from(init: MutationObserverInit) -> Result { + let attributes = init + .attributes + .unwrap_or(init.attribute_old_value.is_some() || init.attribute_filter.is_some()); + let character_data = init + .character_data + .unwrap_or(init.character_data_old_value.is_some()); + let attribute_old_value = init.attribute_old_value.unwrap_or(false); + let character_data_old_value = init.character_data_old_value.unwrap_or(false); + + if !init.child_list && !attributes && !character_data { + return Err( + "Failed to execute 'observe' on 'MutationObserver': at least one of childList, attributes, or characterData must be true.", + ); + } + if !attributes && (attribute_old_value || init.attribute_filter.is_some()) { + return Err( + "Failed to execute 'observe' on 'MutationObserver': attributeOldValue and attributeFilter require attributes to be true.", + ); + } + if !character_data && character_data_old_value { + return Err( + "Failed to execute 'observe' on 'MutationObserver': characterDataOldValue requires characterData to be true.", + ); + } + + Ok(Self { + child_list: init.child_list, + attributes, + character_data, + subtree: init.subtree, + attribute_old_value, + character_data_old_value, + // Native mutation local names are UTF-8. Ill-formed UTF-16 entries + // cannot match them; retain the filter even if no entries can match. + attribute_filter: init.attribute_filter.map(|names| { + names + .into_iter() + .filter_map(|name| String::from_utf16(&name.0).ok()) + .collect() + }), + }) + } +} + #[derive(Clone)] struct QueuedMutationRecord { target: NativeNodeId, @@ -400,6 +448,30 @@ struct MutationObserverConstructorArgs { callback: webidl::WebIdlCallbackFunction, } +#[derive(Default, webidl::WebIdlDictionary)] +#[webidl(prefix = "MutationObserverInit")] +struct MutationObserverInit { + #[webidl(sequence, converter = "raw")] + attribute_filter: Option>, + attribute_old_value: Option, + attributes: Option, + character_data: Option, + character_data_old_value: Option, + #[webidl(default = false)] + child_list: bool, + #[webidl(default = false)] + subtree: bool, +} + +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "MutationObserver.observe")] +struct MutationObserverObserveArgs<'s> { + #[webidl(required, interface = web_api_interfaces::Node)] + target: v8::Local<'s, v8::Object>, + #[webidl(dictionary, default = MutationObserverInit::default())] + options: MutationObserverInit, +} + #[derive(webidl::WebIdlDictionary)] #[webidl(prefix = "IntersectionObserverInit")] struct IntersectionObserverInitMembers { @@ -987,8 +1059,7 @@ impl MutationObserverRegistration { return false; } self.options.attribute_filter.as_ref().is_none_or(|filter| { - filter.contains(mutation.local_name()) - || filter.contains(&mutation.local_name().to_ascii_lowercase()) + mutation.namespace().is_none() && filter.contains(mutation.local_name()) }) } DomMutationRecordKind::CharacterData { .. } => self.options.character_data, @@ -1058,21 +1129,25 @@ pub(super) fn mutation_observer_observe_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { - let raw_target = args.get(0); + let Some(parsed) = webidl::parse_args::(scope, &args) else { + return; + }; + let options = match MutationObserverOptions::try_from(parsed.options) { + Ok(options) => options, + Err(message) => { + throw_type_error(scope, message); + return; + } + }; + let raw_target = moli_webapi_declare::web_api_object_target(scope, parsed.target) + .unwrap_or(parsed.target) + .into(); let Some(target) = callback_value_dom_handle(scope, raw_target) .or_else(|| detached_native_target_handle(scope, raw_target)) else { if is_detached_node_value(scope, raw_target) { - match parse_mutation_observer_options(scope, args.get(1)) { - Ok(_) => { - rv.set_undefined(); - return; - } - Err(message) => { - throw_type_error(scope, message); - return; - } - } + rv.set_undefined(); + return; } throw_type_error( scope, @@ -1080,13 +1155,6 @@ pub(super) fn mutation_observer_observe_callback<'s>( ); return; }; - let options = match parse_mutation_observer_options(scope, args.get(1)) { - Ok(options) => options, - Err(message) => { - throw_type_error(scope, message); - return; - } - }; let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else { rv.set_undefined(); return; @@ -1498,67 +1566,6 @@ fn wrap_node_handle<'s>( .and_then(|value| v8::Local::::try_from(value).ok()) } -fn parse_mutation_observer_options( - scope: &mut v8::PinScope<'_, '_>, - value: v8::Local<'_, v8::Value>, -) -> Result { - let object = v8::Local::::try_from(value).map_err( - |_| "Failed to execute 'observe' on 'MutationObserver': parameter 2 is not an object.", - )?; - let attributes_specified = has_property(scope, object, "attributes"); - let attributes_enabled = bool_option(scope, object, "attributes"); - let character_data_specified = has_property(scope, object, "characterData"); - let character_data_enabled = bool_option(scope, object, "characterData"); - let mut options = MutationObserverOptions { - child_list: bool_option(scope, object, "childList"), - attributes: attributes_enabled, - character_data: character_data_enabled, - subtree: bool_option(scope, object, "subtree"), - attribute_old_value: bool_option(scope, object, "attributeOldValue"), - character_data_old_value: bool_option(scope, object, "characterDataOldValue"), - attribute_filter: string_set_option(scope, object, "attributeFilter"), - }; - - if attributes_specified - && !attributes_enabled - && (options.attribute_old_value || options.attribute_filter.is_some()) - { - return Err( - "Failed to execute 'observe' on 'MutationObserver': attributeOldValue and attributeFilter require attributes to be true.", - ); - } - if character_data_specified && !character_data_enabled && options.character_data_old_value { - return Err( - "Failed to execute 'observe' on 'MutationObserver': characterDataOldValue requires characterData to be true.", - ); - } - - if !attributes_specified && (options.attribute_old_value || options.attribute_filter.is_some()) - { - options.attributes = true; - } - if !character_data_specified && options.character_data_old_value { - options.character_data = true; - } - if !options.child_list && !options.attributes && !options.character_data { - return Err( - "Failed to execute 'observe' on 'MutationObserver': at least one of childList, attributes, or characterData must be true.", - ); - } - Ok(options) -} - -fn has_property( - scope: &mut v8::PinScope<'_, '_>, - object: v8::Local<'_, v8::Object>, - key: &str, -) -> bool { - let Some(key) = v8_string(scope, key) else { - return false; - }; - object.has(scope, key.into()).unwrap_or(false) -} - enum IntersectionObserverOptionsError { Syntax(&'static str), WebIdl(webidl::WebIdlError), @@ -1642,19 +1649,6 @@ fn parse_intersection_observer_options<'s>( Ok(options) } -fn bool_option( - scope: &mut v8::PinScope<'_, '_>, - object: v8::Local<'_, v8::Object>, - key: &str, -) -> bool { - let Some(key) = v8_string(scope, key) else { - return false; - }; - object - .get(scope, key.into()) - .is_some_and(|value| value.boolean_value(scope)) -} - fn intersection_observer_threshold_member<'s>( scope: &mut v8::PinScope<'s, '_>, object: v8::Local<'s, v8::Object>, @@ -1698,23 +1692,6 @@ fn normalize_intersection_thresholds( Ok(thresholds) } -fn string_set_option( - scope: &mut v8::PinScope<'_, '_>, - object: v8::Local<'_, v8::Object>, - key: &str, -) -> Option> { - let key = v8_string(scope, key)?; - let value = object.get(scope, key.into())?; - let array = v8::Local::::try_from(value).ok()?; - let mut result = HashSet::new(); - for index in 0..array.length() { - let entry = array.get_index(scope, index)?; - let text = entry.to_string(scope)?.to_rust_string_lossy(scope); - result.insert(text); - } - Some(result) -} - fn define_intersection_observer_slots( scope: &mut v8::PinScope<'_, '_>, host_ptr: *mut JsContextHost, @@ -2420,6 +2397,8 @@ fn mutation_observer_id_from_object( scope: &mut v8::PinScope<'_, '_>, object: v8::Local<'_, v8::Object>, ) -> Option { + let object = v8::Local::new(scope, object); + let object = moli_webapi_declare::web_api_object_target(scope, object)?; observer_id_from_private_slot(scope, object, MUTATION_OBSERVER_ID_SLOT) } diff --git a/moli-renderer-v8/src/script_vm/tests/observer_callbacks.rs b/moli-renderer-v8/src/script_vm/tests/observer_callbacks.rs index a84806f308..2f40891f9f 100644 --- a/moli-renderer-v8/src/script_vm/tests/observer_callbacks.rs +++ b/moli-renderer-v8/src/script_vm/tests/observer_callbacks.rs @@ -1,5 +1,105 @@ use super::*; +fn run_mutation_observer_init_checks(group: &str, total: usize) { + let mut vm = new_storage_html_test_vm("https://mutation-observer-init.test/"); + vm.eval(&format!( + "document.body.innerHTML=''; \ + globalThis.__mutationObserverGroup={group:?};" + )) + .unwrap(); + vm.eval(include_str!( + "../../../tests/fixtures/mutation-observer-init.js" + )) + .unwrap(); + assert_eq!(vm.eval("__uiEventResults.complete").unwrap(), "true"); + assert_eq!( + vm.eval("JSON.stringify(__uiEventResults.checks.filter(row=>!row.passed))") + .unwrap(), + "[]" + ); + assert_eq!( + vm.eval("__uiEventResults.total").unwrap(), + total.to_string() + ); + let names: Vec = serde_json::from_str( + &vm.eval("JSON.stringify(__uiEventResults.checks.map(row=>row.name))") + .unwrap(), + ) + .unwrap(); + assert_eq!(names.len(), total); + assert_eq!( + names + .into_iter() + .collect::>() + .len(), + total + ); +} + +#[test] +fn mutation_observer_init_converts_iterables_and_preserves_dictionary_order_and_registration() { + run_mutation_observer_init_checks("conversion", 128); +} + +#[test] +fn mutation_observer_filters_match_exact_names_namespaces_and_utf16_identity() { + run_mutation_observer_init_checks("filters", 52); +} + +#[test] +fn mutation_observer_observe_accepts_registered_native_proxy_targets_and_receivers() { + fn register_proxy<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s>, + ) { + let target = v8::Local::::try_from(args.get(0)).unwrap(); + let handler = crate::util::new_null_prototype_object(scope); + let proxy = v8::Proxy::new(scope, target, handler).unwrap(); + moli_webapi_declare::register_web_api_proxy(scope, proxy).unwrap(); + rv.set(proxy.into()); + } + let mut vm = new_storage_html_test_vm("https://mutation-observer-native-proxy.test/"); + let context_ptr = &vm.page_default_runtime.context as *const _; + vm.with_context_scope_by_ptr_and_checkpoint_for_test(context_ptr, |scope, _| { + let global = scope.get_current_context().global(scope); + let function = v8::Function::new(scope, register_proxy).unwrap(); + let key = crate::util::v8str(scope, "registerNativeProxy"); + assert_eq!( + global.create_data_property(scope, key.into(), function.into()), + Some(true) + ); + Ok(()) + }) + .unwrap(); + assert_eq!(vm.eval(r#"(() => { + const observer = new MutationObserver(() => {}); + const target = document.createElement('div'); + const observerProxy = registerNativeProxy(observer); + const targetProxy = registerNativeProxy(target); + MutationObserver.prototype.observe.call(observerProxy, targetProxy, { + attributeFilter: new Set(['keep']) + }); + target.setAttribute('drop', 'x'); target.setAttribute('keep', 'y'); + const records = MutationObserver.prototype.takeRecords.call(observerProxy); + if (records.length !== 1 || records[0].target !== target || records[0].attributeName !== 'keep') + throw Error('registered native identity'); + let gets = 0, traps = 0; + for (const [receiver, node] of [ + [new Proxy(observerProxy, {get(){traps++;}}), targetProxy], + [observerProxy, new Proxy(targetProxy, {get(){traps++;}})] + ]) { + let error; + try {MutationObserver.prototype.observe.call(receiver, node, { + get childList(){gets++;return true;} + });} catch(caught) {error = caught;} + if (!(error instanceof TypeError)) throw Error('author Proxy was accepted'); + } + MutationObserver.prototype.disconnect.call(observerProxy); + return gets === 0 && traps === 0; + })()"#).unwrap(), "true"); +} + fn observer_callback_test_vm(url: &str) -> StandaloneScriptVmHarness { let mut vm = new_storage_test_vm(url); vm.eval( diff --git a/moli-renderer-v8/tests/fixtures/mutation-observer-init.js b/moli-renderer-v8/tests/fixtures/mutation-observer-init.js new file mode 100644 index 0000000000..4a3ac4e594 --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/mutation-observer-init.js @@ -0,0 +1,203 @@ +(() => { + const realms = [globalThis, document.getElementById('child').contentWindow]; + const checks = []; + const group = globalThis.__mutationObserverGroup || 'all'; + const facts = globalThis.__uiEventResults = {complete: false, group, checks}; + const assert = (ok, message) => {if (!ok) throw Error(message);}; + const equal = (a, b, message) => assert(JSON.stringify(a) === JSON.stringify(b), + message + ': ' + JSON.stringify(a) + ' != ' + JSON.stringify(b)); + function test(name, body) { + globalThis.__mutationObserverCurrentCase = name; + try {body(); checks.push({name, passed: true});} + catch (error) {checks.push({name, passed: false, detail: String(error)});} + } + for (const [ownerIndex, owner] of realms.entries()) { + for (const [calleeIndex, callee] of realms.entries()) { + const prefix = ownerIndex + ':' + calleeIndex + ':'; + const method = callee.MutationObserver.prototype.observe; + function withObserver(body, target) { + const observer = new owner.MutationObserver(() => {}); + target = target || owner.document.createElement('div'); + const observe = options => Reflect.apply(method, observer, [target, options]); + try {body(observer, target, observe);} finally {observer.disconnect();} + } + const names = observer => observer.takeRecords().map(record => record.attributeName); + const typeError = body => { + let error; + try {body();} catch (caught) {error = caught;} + assert(error instanceof callee.TypeError, 'callee TypeError'); + }; + if (group !== 'filters') { + test(prefix + 'observe.length', () => assert(method.length === 1, 'one required argument')); + for (const [label, make] of [ + ['Set', () => new Set(['keep'])], + ['generator', () => (function*() {yield 'keep';})()], + ['array custom iterator', () => Object.assign(['drop'], {[Symbol.iterator]: function*() {yield 'keep';}})], + ]) { + test(prefix + 'iterable ' + label, () => withObserver((observer, target, observe) => { + observe({attributes: true, attributeFilter: make()}); + target.setAttribute('drop', 'x'); target.setAttribute('keep', 'y'); + equal(names(observer), ['keep'], 'converted filter'); + })); + } + test(prefix + 'empty iterable remains an empty filter', () => withObserver((observer, target, observe) => { + observe({attributeFilter: new Set()}); target.setAttribute('drop', 'x'); + equal(names(observer), [], 'nothing matches an empty filter'); + })); + for (const [label, value] of [['null', null], ['string', 'keep'], ['number', 1], ['boolean', false], + ['plain object', {}], ['array-like', {0: 'keep', length: 1}], ['non-callable iterator', {[Symbol.iterator]: 1}]]) { + test(prefix + 'reject non-sequence ' + label, () => withObserver((observer, target, observe) => { + typeError(() => observe({attributes: true, attributeFilter: value})); + target.setAttribute('keep', 'x'); equal(names(observer), [], 'failure does not register'); + })); + } + for (const oldValue of [false, null]) { + test(prefix + 'attributeOldValue present ' + oldValue, () => withObserver((observer, target, observe) => { + observe({attributeOldValue: oldValue}); target.setAttribute('keep', 'x'); + const records = observer.takeRecords(); + assert(records.length === 1 && records[0].oldValue === null, 'presence enables attributes'); + })); + test(prefix + 'characterDataOldValue present ' + oldValue, () => { + const target = owner.document.createTextNode('before'); + withObserver((observer, node, observe) => { + observe({characterDataOldValue: oldValue}); node.data = 'after'; + const records = observer.takeRecords(); + assert(records.length === 1 && records[0].type === 'characterData' && records[0].oldValue === null, + 'presence enables characterData'); + }, target); + }); + } + test(prefix + 'undefined flags are absent', () => withObserver((observer, target, observe) => { + target.setAttribute('keep', 'before'); + observe({attributes: undefined, attributeOldValue: true}); + target.setAttribute('keep', 'after'); + const records = observer.takeRecords(); + assert(records.length === 1 && records[0].oldValue === 'before', 'undefined allows implicit attributes'); + })); + const keys = ['attributeFilter', 'attributeOldValue', 'attributes', 'characterData', + 'characterDataOldValue', 'childList', 'subtree']; + test(prefix + 'dictionary Get order without HasProperty', () => withObserver((observer, target, observe) => { + const log = [], options = new Proxy({attributeFilter: ['keep'], attributes: true}, { + get(object, key, receiver) {log.push(key); return Reflect.get(object, key, receiver);}, + has() {throw Error('unexpected HasProperty');}, + }); + observe(options); equal(log, keys, 'lexical dictionary order'); + })); + test(prefix + 'inherited members are read once in lexical order', () => withObserver((observer, target, observe) => { + const log = [], prototype = {}; + for (const key of keys) Object.defineProperty(prototype, key, {get() { + log.push(key); return key === 'attributes' ? true : undefined; + }}); + observe(Object.create(prototype)); equal(log, keys, 'inherited getter order'); + })); + for (const failKey of keys) { + test(prefix + 'getter abrupt completion at ' + failKey, () => withObserver((observer, target, observe) => { + const sentinel = {}, log = [], options = {}; + for (const key of keys) Object.defineProperty(options, key, {get() { + log.push(key); if (key === failKey) throw sentinel; + return key === 'attributeFilter' ? ['keep'] : key === 'attributes' ? true : undefined; + }}); + let error; try {observe(options);} catch (caught) {error = caught;} + assert(error === sentinel, 'original thrown value'); + equal(log, keys.slice(0, keys.indexOf(failKey) + 1), 'stop at failed member'); + target.setAttribute('keep', 'after'); equal(names(observer), [], 'no partial registration'); + })); + } + for (const failure of ['iterator', 'item']) { + test(prefix + 'failed reconfiguration preserves registration: ' + failure, () => withObserver((observer, target, observe) => { + target.setAttribute('keep', 'before'); + observe({attributes: true, attributeOldValue: true, attributeFilter: ['keep']}); + const sentinel = {}, filter = failure === 'iterator' ? { + *[Symbol.iterator]() {yield 'changed'; throw sentinel;} + } : [{toString() {throw sentinel;}}]; + let error, laterGets = 0; + try {observe({attributeFilter: filter, get attributes() {laterGets++; return true;}});} + catch (caught) {error = caught;} + assert(error === sentinel && laterGets === 0, 'conversion fails before later members'); + target.setAttribute('changed', 'x'); target.setAttribute('keep', 'after'); + const records = observer.takeRecords(); + assert(records.length === 1 && records[0].attributeName === 'keep' && records[0].oldValue === 'before', + 'old registration is intact'); + })); + } + test(prefix + 'iteration and DOMString conversion happen at member position', () => withObserver((observer, target, observe) => { + const log = [], iterable = { + get [Symbol.iterator]() {log.push('iterator'); return new Proxy(function*() { + log.push('iterate'); yield {toString() {log.push('string'); return 'keep';}}; + }, {});} + }; + observe({get attributeFilter() {log.push('filter'); return iterable;}, + get attributeOldValue() {log.push('oldValue'); return false;}, + get attributes() {log.push('attributes'); return true;}}); + equal(log, ['filter', 'iterator', 'iterate', 'string', 'oldValue', 'attributes'], 'conversion position'); + target.setAttribute('drop', 'x'); target.setAttribute('keep', 'after'); + equal(names(observer), ['keep'], 'resulting filter'); + })); + test(prefix + 'invalid target precedes options conversion', () => withObserver((observer, target) => { + const revoked = Proxy.revocable(target, {}); revoked.revoke(); + for (const invalid of [{}, Object.create(target), new Proxy(target, {}), revoked.proxy, null]) { + let gets = 0; + typeError(() => Reflect.apply(method, observer, [invalid, {get childList() {gets++; return true;}}])); + assert(gets === 0, 'invalid target stops dictionary getters'); + } + })); + test(prefix + 'invalid receiver precedes argument conversion', () => withObserver((observer, target) => { + for (const invalid of [{}, Object.create(observer), new Proxy(observer, {})]) { + let gets = 0; + typeError(() => Reflect.apply(method, invalid, [target, {get childList() {gets++; return true;}}])); + assert(gets === 0, 'receiver validation comes first'); + } + })); + test(prefix + 'explicit false stays false', () => withObserver((observer, target, observe) => { + observe({childList: true, attributes: false, attributeOldValue: false}); + target.setAttribute('keep', 'x'); target.appendChild(owner.document.createTextNode('text')); + const records = observer.takeRecords(); + assert(records.length === 1 && records[0].type === 'childList', 'no implicit attributes'); + typeError(() => observe({childList: true, attributes: false, attributeFilter: []})); + typeError(() => observe({childList: true, characterData: false, characterDataOldValue: true})); + })); + } + if (group !== 'conversion') { + for (const documentKind of ['live', 'html', 'xml']) { + const doc = documentKind === 'live' ? owner.document : documentKind === 'html' ? + owner.document.implementation.createHTMLDocument('') : + owner.document.implementation.createDocument('urn:fixture', 'root'); + test(prefix + documentKind + ':filter excludes non-null namespaces', () => withObserver((observer, target, observe) => { + observe({attributes: true, attributeFilter: ['keep']}); + target.setAttributeNS('urn:fixture', 'p:keep', 'namespaced'); + target.setAttribute('keep', 'plain'); + const records = observer.takeRecords(); + assert(records.length === 1 && records[0].attributeName === 'keep' && records[0].attributeNamespace === null, + 'only unnamespaced local name matches'); + }, doc.createElement('item'))); + test(prefix + documentKind + ':no filter retains namespaced mutations', () => withObserver((observer, target, observe) => { + observe({attributes: true}); target.setAttributeNS('urn:fixture', 'p:keep', 'value'); + const records = observer.takeRecords(); + assert(records.length === 1 && records[0].attributeNamespace === 'urn:fixture', 'unfiltered namespace'); + }, doc.createElement('item'))); + test(prefix + documentKind + ':filter preserves lone-surrogate identity', () => withObserver((observer, target, observe) => { + observe({attributes: true, attributeFilter: [String.fromCharCode(0xd800)]}); + target.setAttribute('\ufffd', 'value'); equal(names(observer), [], 'no replacement-character alias'); + }, doc.createElement('item'))); + test(prefix + documentKind + ':filter preserves well-formed Unicode', () => withObserver((observer, target, observe) => { + const name = String.fromCodePoint(0x10000); + observe({attributeFilter: [name]}); target.setAttribute(name, 'value'); + equal(names(observer), [name], 'non-BMP local name'); + }, doc.createElement('item'))); + } + test(prefix + 'XML attribute filters match case exactly', () => { + const doc = owner.document.implementation.createDocument('urn:fixture', 'root'); + withObserver((observer, target, observe) => { + observe({attributeFilter: ['lower']}); + target.setAttribute('LOWER', 'upper'); target.setAttribute('lower', 'lower'); + equal(names(observer), ['lower'], 'case-sensitive local names'); + }, doc.documentElement); + }); + } + } + } + facts.total = checks.length; + facts.passed = checks.filter(check => check.passed).length; + facts.complete = true; + return facts.passed === facts.total; +})() diff --git a/moli-wpt-compat/fixtures/wpt/ported/idl/mutationobserver-idlharness.html b/moli-wpt-compat/fixtures/wpt/ported/idl/mutationobserver-idlharness.html index d4391eb2d7..8953daeee1 100644 --- a/moli-wpt-compat/fixtures/wpt/ported/idl/mutationobserver-idlharness.html +++ b/moli-wpt-compat/fixtures/wpt/ported/idl/mutationobserver-idlharness.html @@ -12,7 +12,7 @@ callback MutationCallback = undefined (sequence mutations, Mutat interface MutationObserver { constructor(MutationCallback callback); - undefined observe(Node target, MutationObserverInit options); + undefined observe(Node target, optional MutationObserverInit options = {}); undefined disconnect(); sequence takeRecords(); };