diff --git a/moli-renderer-v8/src/context_bootstrap/event_document.rs b/moli-renderer-v8/src/context_bootstrap/event_document.rs index 5853bc401c..b3bc18e5aa 100644 --- a/moli-renderer-v8/src/context_bootstrap/event_document.rs +++ b/moli-renderer-v8/src/context_bootstrap/event_document.rs @@ -38,7 +38,7 @@ enum DocumentCreateEventKind { KeyboardEvent, MessageEvent, StorageEvent, - SubmitEvent, + TouchEvent, } impl DocumentCreateEventKind { @@ -49,17 +49,17 @@ impl DocumentCreateEventKind { DocumentCreateEventKind::CustomEvent => "CustomEvent", DocumentCreateEventKind::DeviceMotionEvent => "DeviceMotionEvent", DocumentCreateEventKind::DeviceOrientationEvent => "DeviceOrientationEvent", - DocumentCreateEventKind::DragEvent => "Event", + DocumentCreateEventKind::DragEvent => "DragEvent", DocumentCreateEventKind::UiEvent => "UIEvent", DocumentCreateEventKind::TextEvent => "TextEvent", DocumentCreateEventKind::CompositionEvent => "CompositionEvent", DocumentCreateEventKind::FocusEvent => "FocusEvent", - DocumentCreateEventKind::HashChangeEvent => "Event", + DocumentCreateEventKind::HashChangeEvent => "HashChangeEvent", DocumentCreateEventKind::MouseEvent => "MouseEvent", DocumentCreateEventKind::KeyboardEvent => "KeyboardEvent", DocumentCreateEventKind::MessageEvent => "MessageEvent", DocumentCreateEventKind::StorageEvent => "StorageEvent", - DocumentCreateEventKind::SubmitEvent => "SubmitEvent", + DocumentCreateEventKind::TouchEvent => "TouchEvent", } } } @@ -176,11 +176,14 @@ mod tests { .map(DocumentCreateEventKind::constructor_name), Ok("MouseEvent") ); - assert_eq!( - DocumentCreateEventKind::from_str("SubmitEvent") - .map(DocumentCreateEventKind::constructor_name), - Ok("SubmitEvent") - ); + for name in ["DragEvent", "HashChangeEvent", "TouchEvent"] { + assert_eq!( + DocumentCreateEventKind::from_str(name) + .map(DocumentCreateEventKind::constructor_name), + Ok(name) + ); + } + assert!(DocumentCreateEventKind::from_str("SubmitEvent").is_err()); assert!(DocumentCreateEventKind::from_str("PointerEvent").is_err()); } } diff --git a/moli-renderer-v8/src/context_bootstrap/event_template.rs b/moli-renderer-v8/src/context_bootstrap/event_template.rs index 9aeebf9658..ed747570c7 100644 --- a/moli-renderer-v8/src/context_bootstrap/event_template.rs +++ b/moli-renderer-v8/src/context_bootstrap/event_template.rs @@ -224,6 +224,15 @@ struct ToggleEventTemplateAccessorsDeclaration { source: (), } +#[derive(WebApiFunctionTemplate)] +#[webapi(interface = web_api_interfaces::HashChangeEvent, enumerable, receiver)] +struct HashChangeEventTemplateAccessorsDeclaration { + #[webapi(accessor_property = "oldURL", getter = event_value_attribute_getter, data = crate::util::v8str(scope, "oldURL"))] + old_url: (), + #[webapi(accessor_property = "newURL", getter = event_value_attribute_getter, data = crate::util::v8str(scope, "newURL"))] + new_url: (), +} + #[derive(WebApiFunctionTemplate)] #[webapi(interface = web_api_interfaces::UIEvent, enumerable, receiver)] struct UiEventTemplateMethodsDeclaration { @@ -673,6 +682,9 @@ fn event_template_declaration(interface: &str) -> Option() } "ToggleEvent" => EventTemplateDeclaration::new::(), + "HashChangeEvent" => { + EventTemplateDeclaration::new::() + } "EventTarget" => EventTemplateDeclaration::new::(), "Document" => EventTemplateDeclaration::new::(), _ => return None, diff --git a/moli-renderer-v8/src/context_bootstrap/touch_runtime.rs b/moli-renderer-v8/src/context_bootstrap/touch_runtime.rs index 28ea1cfe84..11f6e38c94 100644 --- a/moli-renderer-v8/src/context_bootstrap/touch_runtime.rs +++ b/moli-renderer-v8/src/context_bootstrap/touch_runtime.rs @@ -290,8 +290,8 @@ fn initialize_touch_ui_event<'s>( event: v8::Local<'s, v8::Object>, init: Option>, ) { - let view = touch_event_init_value(scope, init, "view") - .unwrap_or_else(|| scope.get_current_context().global(scope).into()); + let view = + touch_event_init_value(scope, init, "view").unwrap_or_else(|| v8::null(scope).into()); let detail = init .and_then(|object| object.get(scope, v8str(scope, "detail").into())) .filter(|value| !value.is_null_or_undefined()) diff --git a/moli-renderer-v8/src/script_vm/tests/document_create_event.js b/moli-renderer-v8/src/script_vm/tests/document_create_event.js new file mode 100644 index 0000000000..cfaba5d8f6 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/document_create_event.js @@ -0,0 +1,141 @@ +(() => { + const checks = []; + const check = (name, fn) => { + try { checks.push({name, passed: fn() === true}); } + catch (error) { checks.push({name, passed: false, error: String(error)}); } + }; + const realms = [window, document.querySelector('iframe').contentWindow]; + const aliases = [ + ['BeforeUnloadEvent', 'BeforeUnloadEvent'], ['CompositionEvent', 'CompositionEvent'], + ['CustomEvent', 'CustomEvent'], ['DeviceMotionEvent', 'DeviceMotionEvent'], + ['DeviceOrientationEvent', 'DeviceOrientationEvent'], ['DragEvent', 'DragEvent'], + ['Event', 'Event'], ['Events', 'Event'], ['FocusEvent', 'FocusEvent'], + ['HashChangeEvent', 'HashChangeEvent'], ['HTMLEvents', 'Event'], + ['KeyboardEvent', 'KeyboardEvent'], ['MessageEvent', 'MessageEvent'], + ['MouseEvent', 'MouseEvent'], ['MouseEvents', 'MouseEvent'], + ['StorageEvent', 'StorageEvent'], ['SVGEvents', 'Event'], ['TextEvent', 'TextEvent'], + ['TouchEvent', 'TouchEvent'], ['UIEvent', 'UIEvent'], ['UIEvents', 'UIEvent'] + ]; + const rejected = (realm, operation, name) => { + try { operation(); } + catch (error) { + return Object.getPrototypeOf(error) === realm.DOMException.prototype && + error.name === name; + } + return false; + }; + const typeError = (realm, operation) => { + try { operation(); } + catch (error) { return Object.getPrototypeOf(error) === realm.TypeError.prototype; } + return false; + }; + const defaults = event => event.type === '' && event.target === null && + event.currentTarget === null && event.eventPhase === 0 && !event.bubbles && + !event.cancelable && !event.composed && !event.defaultPrevented && + !event.isTrusted && Number.isFinite(event.timeStamp); + for (const [ownerIndex, owner] of realms.entries()) { + const docs = [owner.document, owner.document.implementation.createHTMLDocument(''), + owner.document.implementation.createDocument('urn:legacy-event', 'root'), + new owner.DOMParser().parseFromString('', 'application/xml')]; + for (const [docIndex, doc] of docs.entries()) { + for (const [calleeIndex, callee] of realms.entries()) { + const prefix = `${ownerIndex}/${docIndex}/${calleeIndex}/`; + const factory = callee.Document.prototype.createEvent; + const create = name => factory.call(doc, name); + const exposed = name => typeof owner[name] === 'function' && + (name !== 'TouchEvent' || 'ontouchstart' in doc); + check(prefix + 'factory metadata', () => { + const d = Object.getOwnPropertyDescriptor(callee.Document.prototype, 'createEvent'); + return d.enumerable && d.configurable && d.writable && + factory.name === 'createEvent' && factory.length === 1; + }); + for (const [alias, name] of aliases) { + for (const variant of [alias, alias.toLowerCase(), alias.toUpperCase()]) { + check(prefix + 'alias/defaults/' + variant, () => { + if (!exposed(name)) return rejected(callee, () => create(variant), 'NotSupportedError'); + const event = create(variant); + return Object.getPrototypeOf(event) === owner[name].prototype && defaults(event); + }); + } + } + for (const name of ['DragEvent', 'HashChangeEvent', 'TouchEvent']) { + const unavailable = () => rejected(callee, () => create(name), 'NotSupportedError'); + check(prefix + name + '/subclass state', () => { + if (!exposed(name)) return unavailable(); + const event = create(name); + if (name === 'HashChangeEvent') return event.oldURL === '' && event.newURL === ''; + if (name === 'DragEvent') return event.dataTransfer === null && event.view === null && + event.detail === 0 && event.clientX === 0 && event.clientY === 0 && !event.ctrlKey; + return event.touches.length === 0 && event.targetTouches.length === 0 && + event.changedTouches.length === 0 && event.view === null && event.detail === 0; + }); + check(prefix + name + '/native brand', () => { + if (!exposed(name)) return unavailable(); + const event = create(name), key = name === 'HashChangeEvent' ? 'oldURL' : + name === 'DragEvent' ? 'dataTransfer' : 'touches'; + const getter = Object.getOwnPropertyDescriptor(callee[name].prototype, key).get; + const value = getter.call(event); + return name === 'HashChangeEvent' ? value === '' : + name === 'DragEvent' ? value === null : value.length === 0; + }); + check(prefix + name + '/uninitialized dispatch rejected', () => { + if (!exposed(name)) return unavailable(); + return rejected(owner, () => new owner.EventTarget().dispatchEvent(create(name)), 'InvalidStateError'); + }); + check(prefix + name + '/initialize and dispatch', () => { + if (!exposed(name)) return unavailable(); + const event = create(name), target = new owner.EventTarget(); + let seen = 0, state = false; + target.addEventListener('legacy', current => { + seen++; + state = current === event && current.target === target && + current.currentTarget === target && current.eventPhase === 2; + current.preventDefault(); + }); + const initialized = callee.Event.prototype.initEvent.call(event, 'legacy', false, true); + return initialized === undefined && target.dispatchEvent(event) === false && + seen === 1 && state && event.defaultPrevented && event.currentTarget === null && + Object.getPrototypeOf(event) === owner[name].prototype; + }); + check(prefix + name + '/reinitialize retains subclass', () => { + if (!exposed(name)) return unavailable(); + const event = create(name), target = new owner.EventTarget(); + event.initEvent('first', true, true); + target.addEventListener('first', current => current.initEvent('during', false, false)); + target.dispatchEvent(event); + if (event.type !== 'first' || !event.bubbles || !event.cancelable) return false; + event.initEvent('second', false, false); + return event.type === 'second' && event.target === null && !event.bubbles && + !event.cancelable && !event.defaultPrevented && Object.getPrototypeOf(event) === owner[name].prototype; + }); + } + for (const name of ['', 'TimeEvent', 'SubmitEvent', 'PointerEvent', 'WheelEvent', + 'SVGEvent', 'DragEvents', 'HashChangeEvents', 'TouchEvents', 'UIEvent ', ' UIEvent', + 'U\u0130Event', 'U\u0131Event', 'ErrorEvent', 'FormDataEvent', 'AnimationEvent']) { + check(prefix + 'unsupported/' + name, () => rejected(callee, () => create(name), 'NotSupportedError')); + } + check(prefix + 'missing argument', () => typeError(callee, () => factory.call(doc))); + check(prefix + 'conversion once', () => { + let calls = 0; + const event = create({toString() { calls++; return 'HashChangeEvent'; }}); + return calls === 1 && Object.getPrototypeOf(event) === owner.HashChangeEvent.prototype; + }); + let traps = 0; + const proxy = new owner.Proxy(doc, {get() { traps++; throw 42; }, getPrototypeOf() { traps++; throw 42; }}); + const revoked = owner.Proxy.revocable(doc, {}); revoked.revoke(); + for (const [index, receiver] of [{}, Object.create(doc), Object.create(callee.Document.prototype), + proxy, revoked.proxy, doc.createElement('div')].entries()) { + check(prefix + 'receiver/' + index, () => { + let conversions = 0; + return typeError(callee, () => factory.call(receiver, {toString() { conversions++; throw 42; }})) && + conversions === 0 && traps === 0; + }); + } + } + } + } + globalThis.__documentCreateEventResults = {complete: true, total: checks.length, + passed: checks.filter(row => row.passed).length, checks}; + globalThis.__uiEventResults = globalThis.__documentCreateEventResults; + return true; +})() diff --git a/moli-renderer-v8/src/script_vm/tests/document_create_event.rs b/moli-renderer-v8/src/script_vm/tests/document_create_event.rs new file mode 100644 index 0000000000..26a18f81a8 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/document_create_event.rs @@ -0,0 +1,110 @@ +use super::*; + +#[test] +fn hash_change_event_payload_accessors_use_native_state_and_callee_receiver_checks() { + let mut vm = new_parsed_test_vm( + "https://hash-change-event-accessors.test/", + "", + ); + assert_eq!(vm.eval(r#"(() => { + const realms=[window,document.querySelector('iframe').contentWindow]; + for (const owner of realms) for (const callee of realms) { + const event=new owner.HashChangeEvent('hashchange',{ + oldURL:'https://example.test/#old',newURL:'https://example.test/#new' + }); + event.initEvent('initialized',false,false); + for (const [key,value] of [['oldURL','https://example.test/#old'],['newURL','https://example.test/#new']]) { + const descriptor=Object.getOwnPropertyDescriptor(callee.HashChangeEvent.prototype,key); + const getter=descriptor.get; + if (!descriptor.enumerable || !descriptor.configurable || descriptor.set!==undefined || + getter.length!==0 || getter.name!=='get '+key || Object.hasOwn(event,key) || getter.call(event)!==value) throw Error('payload descriptor'); + Object.defineProperty(event,key,{value:'author value',configurable:true}); + if (getter.call(event)!==value) throw Error('author field replaced native state'); + let traps=0; + const author=new Proxy(event,{get(){traps++;throw 42;},getPrototypeOf(){traps++;throw 42;}}); + const revoked=Proxy.revocable(event,{});revoked.revoke(); + for (const receiver of [{},new owner.Event('event'),Object.create(event),author,revoked.proxy]) { + let error; + try { getter.call(receiver); } catch(e) { error=e; } + if (!error || Object.getPrototypeOf(error)!==callee.TypeError.prototype || traps) throw Error('receiver accepted'); + } + } + } + return true; + })()"#).unwrap(), "true"); +} + +#[test] +fn document_create_event_preserves_legacy_interfaces_and_native_dispatch() { + let mut vm = new_parsed_test_vm( + "https://document-create-event.test/", + "", + ); + vm.eval(include_str!("document_create_event.js")).unwrap(); + assert_eq!( + vm.eval("__documentCreateEventResults.complete").unwrap(), + "true" + ); + assert_eq!( + vm.eval("__documentCreateEventResults.total").unwrap(), + "1648" + ); + assert_eq!( + vm.eval("JSON.stringify(__documentCreateEventResults.checks.filter(row => !row.passed))") + .unwrap(), + "[]" + ); +} + +#[test] +fn document_create_event_uses_intrinsics_and_registered_native_receivers() { + let mut vm = + new_storage_page_task_executor_test_vm("https://document-create-event-native.test/"); + vm.eval("document.body.innerHTML=''; globalThis.child=document.querySelector('iframe').contentWindow;") + .unwrap(); + let context_ptr = &vm.page_default_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 key = crate::util::v8str(scope, "document"); + let document = + v8::Local::::try_from(global.get(scope, key.into()).unwrap()).unwrap(); + let handler = crate::util::new_null_prototype_object(scope); + let proxy = v8::Proxy::new(scope, document, handler).unwrap(); + moli_webapi_declare::register_web_api_proxy(scope, proxy).unwrap(); + let key = crate::util::v8str(scope, "nativeDocument"); + assert_eq!( + global.create_data_property(scope, key.into(), proxy.into()), + Some(true) + ); + Ok(()) + }) + .unwrap(); + assert_eq!(vm.eval(r#"(() => { + const names=['DragEvent','HashChangeEvent','TouchEvent']; + const originals=names.map(name=>window[name]); + let constructorCalls=0; + try { + for (const name of names) window[name]=function(){constructorCalls++;throw 42;}; + for (const realm of [window,child]) { + const create=realm.Document.prototype.createEvent; + for (const [index,name] of names.entries()) { + const event=create.call(nativeDocument,name); + if (Object.getPrototypeOf(event)!==originals[index].prototype || event.type!=='' || event.isTrusted) throw Error('intrinsic creation'); + event.initEvent('native',false,false); + if (!new EventTarget().dispatchEvent(event)) throw Error('native dispatch'); + } + let conversions=0, traps=0; + const author=new Proxy(nativeDocument,{get(){traps++;throw 42;},getPrototypeOf(){traps++;throw 42;}}); + const revoked=Proxy.revocable(nativeDocument,{});revoked.revoke(); + for (const receiver of [author,revoked.proxy,Object.create(nativeDocument)]) { + let error; + try { create.call(receiver,{toString(){conversions++;throw 42;}}); } catch(e) { error=e; } + if (!error || Object.getPrototypeOf(error)!==realm.TypeError.prototype || conversions || traps) throw Error('author receiver accepted'); + } + } + return constructorCalls===0; + } finally { + names.forEach((name,index)=>window[name]=originals[index]); + } + })()"#).unwrap(), "true"); +} diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs index fa09628e55..57ddfb2ee5 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs @@ -3129,7 +3129,8 @@ fn submit_event_constructor_surface() { submitter: button }); const desc = Object.getOwnPropertyDescriptor(SubmitEvent.prototype, 'submitter'); - const created = document.createEvent('SubmitEvent'); + let creationError; + try { document.createEvent('SubmitEvent'); } catch (error) { creationError = error; } return [ typeof SubmitEvent, SubmitEvent.length, @@ -3146,8 +3147,8 @@ fn submit_event_constructor_surface() { Object.prototype.toString.call(second), typeof desc.get, desc.set === undefined, - created instanceof SubmitEvent, - created.submitter === null + creationError instanceof DOMException, + creationError.name === 'NotSupportedError' ].join('|'); })() "#, diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index dbd28da704..911b823e2a 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -2245,6 +2245,7 @@ mod device_events; mod gamepad_interfaces; +mod document_create_event; mod document_svg_root; mod dom_elements; mod dom_exception_proxy_identity; diff --git a/moli-wpt-compat/fixtures/wpt/manifest.toml b/moli-wpt-compat/fixtures/wpt/manifest.toml index ee03af6d2c..967c566c8c 100644 --- a/moli-wpt-compat/fixtures/wpt/manifest.toml +++ b/moli-wpt-compat/fixtures/wpt/manifest.toml @@ -1582,7 +1582,7 @@ wait_until = "load" timeout_ms = 5000 suite = "smoke" tags = ["forms", "html", "submit", "events"] -notes = "Manual compat port for SubmitEvent surface: constructor/getter shape, document.createEvent('SubmitEvent'), requestSubmit(submitter) preserving the active submitter, and requestSubmit() exposing null submitter." +notes = "Manual compat port for SubmitEvent surface: constructor/getter shape, legacy document.createEvent('SubmitEvent') rejection, requestSubmit(submitter) preserving the active submitter, and requestSubmit() exposing null submitter." [[test]] id = "form-submit-button-click-basic" diff --git a/moli-wpt-compat/fixtures/wpt/ported/forms/form-submit-event-basic.html b/moli-wpt-compat/fixtures/wpt/ported/forms/form-submit-event-basic.html index ea2fe33942..f129e86ad8 100644 --- a/moli-wpt-compat/fixtures/wpt/ported/forms/form-submit-event-basic.html +++ b/moli-wpt-compat/fixtures/wpt/ported/forms/form-submit-event-basic.html @@ -16,7 +16,6 @@ test(function () { submitter: button, }); const descriptor = Object.getOwnPropertyDescriptor(SubmitEvent.prototype, "submitter"); - const created = document.createEvent("SubmitEvent"); assert_equals(typeof SubmitEvent, "function", "SubmitEvent should be exposed"); assert_equals(SubmitEvent.length, 1, "SubmitEvent.length should match the constructor signature"); @@ -45,8 +44,9 @@ test(function () { assert_equals(typeof descriptor.get, "function", "submitter should be a getter"); assert_equals(descriptor.set, undefined, "submitter should not expose a setter"); - assert_true(created instanceof SubmitEvent, "document.createEvent('SubmitEvent') should use SubmitEvent"); - assert_equals(created.submitter, null, "document.createEvent('SubmitEvent') should default submitter to null"); + assert_throws_dom("NotSupportedError", function () { + document.createEvent("SubmitEvent"); + }, "SubmitEvent is absent from the legacy createEvent interface table"); }, "SubmitEvent constructor and prototype getter surface"); test(function () {