diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs index b9a934d644..b3841369ee 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs @@ -6,6 +6,7 @@ mod media_capabilities; mod media_devices; mod navigator; mod navigator_subobjects; +mod scheduling; mod screen; mod visual_viewport; mod window_state; diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs index ec03f1c829..77ed40c2d5 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs @@ -30,6 +30,7 @@ use super::media_capabilities::{ }; use super::media_devices::{build_media_devices_object, install_media_devices_template_bindings}; use super::navigator_subobjects::{NavigatorSubobject, ensure_navigator_subobject}; +use super::scheduling::{build_scheduling_object, install_scheduling_template_bindings}; use crate::document_runtime::DomHandle; use crate::native_bridge::OwnerDispatchScope; use crate::util::{ @@ -74,6 +75,7 @@ const NAVIGATOR_RUNTIME_DATA_KEYS: &[&str] = &[ "storageBuckets", "geolocation", "mediaCapabilities", + "scheduling", ]; const WORKER_NAVIGATOR_INSTALLED_SLOT: &str = "__moliWorkerNavigatorInstalled"; const WORKER_NAVIGATOR_MATERIALIZING_SLOT: &str = "__moliWorkerNavigatorMaterializing"; @@ -193,6 +195,8 @@ struct NavigatorRuntimeDataPrototypeDeclaration { geolocation: (), #[webapi(accessor_property, getter = navigator_runtime_data_getter_callback, data = callback_data_index_value(scope, 32))] media_capabilities: (), + #[webapi(accessor_property, getter = navigator_runtime_data_getter_callback, data = callback_data_index_value(scope, 33))] + scheduling: (), #[webapi(accessor_property, getter = navigator_cookie_enabled_getter_callback)] cookie_enabled: (), } @@ -591,6 +595,9 @@ struct WindowNavigatorBackingDeclaration<'scope, 'profile> { #[webapi(data_property, enumerable)] media_capabilities: v8::Local<'scope, v8::Value>, + + #[webapi(data_property, enumerable)] + scheduling: v8::Local<'scope, v8::Value>, } #[derive(WebApiObject)] @@ -896,6 +903,7 @@ pub(in crate::context_bootstrap) fn install_navigator_template_bindings<'s>( install_geolocation_template_bindings(scope, template, interface_name); install_navigator_collection_template_bindings(scope, template, interface_name); install_media_capabilities_template_bindings(scope, template, interface_name); + install_scheduling_template_bindings(scope, template, interface_name); let prototype = template.prototype_template(scope); match interface_name { "MediaDevices" => install_media_devices_template_bindings(scope, template), @@ -1106,6 +1114,7 @@ pub(super) fn build_lazy_navigator_subobject_in_current_realm<'s>( || navigator_geolocation_secure_context_available(scope, owner_child, owner_popup); build_media_capabilities_object(scope, secure_context, worker)?.into() } + NavigatorSubobject::Scheduling => build_scheduling_object(scope)?.into(), }; Ok(value) } @@ -1279,6 +1288,7 @@ fn build_window_navigator_backing_for_owner<'s>( storage_buckets: v8::undefined(scope).into(), geolocation: v8::undefined(scope).into(), media_capabilities: v8::undefined(scope).into(), + scheduling: v8::undefined(scope).into(), } .bind(scope) .map_err(|error| anyhow!("failed to bind Navigator backing object: {error}")) diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator_subobjects.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator_subobjects.rs index 76e3def923..10a7b36d22 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator_subobjects.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator_subobjects.rs @@ -22,11 +22,12 @@ pub(super) enum NavigatorSubobject { StorageBuckets, Geolocation, MediaCapabilities, + Scheduling, } impl NavigatorSubobject { #[cfg(test)] - pub(super) const ALL: [Self; 16] = [ + pub(super) const ALL: [Self; 17] = [ Self::Languages, Self::MimeTypes, Self::Plugins, @@ -43,6 +44,7 @@ impl NavigatorSubobject { Self::StorageBuckets, Self::Geolocation, Self::MediaCapabilities, + Self::Scheduling, ]; pub(super) fn from_key(key: &str) -> Option { @@ -63,6 +65,7 @@ impl NavigatorSubobject { "storageBuckets" => Some(Self::StorageBuckets), "geolocation" => Some(Self::Geolocation), "mediaCapabilities" => Some(Self::MediaCapabilities), + "scheduling" => Some(Self::Scheduling), _ => None, } } @@ -85,6 +88,7 @@ impl NavigatorSubobject { Self::StorageBuckets => "storageBuckets", Self::Geolocation => "geolocation", Self::MediaCapabilities => "mediaCapabilities", + Self::Scheduling => "scheduling", } } } diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/scheduling.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/scheduling.rs new file mode 100644 index 0000000000..f6380a0c61 --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/scheduling.rs @@ -0,0 +1,67 @@ +use anyhow::{Result, anyhow}; +use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject}; + +use crate::{util::throw_type_error, web_api_interfaces, webidl}; + +#[derive(WebApiObject)] +#[webapi(interface = web_api_interfaces::Scheduling)] +struct SchedulingObjectDeclaration {} + +#[derive(Default, WebApiFunctionTemplate)] +#[webapi(interface = web_api_interfaces::Scheduling, enumerable)] +struct SchedulingPrototypeDeclaration { + #[webapi(method, length = 0, callback = is_input_pending_callback)] + is_input_pending: (), +} + +#[derive(webidl::WebIdlDictionary)] +#[webidl(prefix = "IsInputPendingOptions")] +struct IsInputPendingOptions { + #[webidl(name = "includeContinuous", default = false)] + _include_continuous: bool, +} + +pub(super) fn install_scheduling_template_bindings<'s>( + scope: &mut v8::PinScope<'s, '_, ()>, + template: v8::Local<'s, v8::FunctionTemplate>, + interface_name: &str, +) { + if interface_name == "Scheduling" { + SchedulingPrototypeDeclaration::initialize_prototype_template( + scope, + template.prototype_template(scope), + ); + } +} + +pub(super) fn build_scheduling_object<'s>( + scope: &mut v8::PinScope<'s, '_>, +) -> Result> { + SchedulingObjectDeclaration::new() + .bind(scope) + .map_err(|error| anyhow!("failed to bind Scheduling object: {error}")) +} + +fn is_input_pending_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + if !web_api_interfaces::Scheduling::is_instance(scope, args.this()) { + throw_type_error(scope, "Illegal invocation"); + return; + } + // Even the idle compatibility profile must perform dictionary conversion: + // includeContinuous getters and their exceptions are observable WebIDL. + if let Err(error) = webidl::parse_dictionary::( + scope, + args.get(0), + webidl::Context::argument("Scheduling.isInputPending", 1), + ) { + webidl::throw_error(scope, &error); + return; + } + // Capability shim: pending-input prediction is not connected to the owner + // queue yet. Report the idle state, not a measurement of queued CDP input. + rv.set_bool(false); +} diff --git a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs index 01e1a86d6c..c373e7229e 100644 --- a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs +++ b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs @@ -544,6 +544,10 @@ const CONSTRUCTOR_SPECS_BEFORE_STREAMS: &[ConstructorSpec] = &[ interface: web_api_interfaces::MediaCapabilities::DESCRIPTOR, kind: ConstructorKind::Illegal, }, + ConstructorSpec { + interface: web_api_interfaces::Scheduling::DESCRIPTOR, + kind: ConstructorKind::Illegal, + }, ConstructorSpec { interface: web_api_interfaces::Touch::DESCRIPTOR, kind: ConstructorKind::Touch, diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs index 1a1e2752f3..b14c7f6b91 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs @@ -42,6 +42,7 @@ mod platform_identity; mod pointer_lock; mod popup_hyperlinks; mod promise_rejection; +mod scheduling; mod security_policy; mod service_worker_drain; mod session_description; diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/scheduling.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/scheduling.rs new file mode 100644 index 0000000000..7add18bb70 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/scheduling.rs @@ -0,0 +1,48 @@ +use super::*; + +#[test] +fn navigator_scheduling_exposes_native_idle_capability_contract() { + for url in ["https://scheduling.test/", "http://scheduling.test/"] { + let mut vm = new_storage_test_vm(url); + assert_eq!( + vm.eval(include_str!( + "../../../../tests/fixtures/navigator-scheduling.js" + )) + .expect("Scheduling idle and WebIDL contract should hold"), + "window-ok" + ); + } +} + +#[test] +fn navigator_scheduling_materializes_in_its_navigator_realm() { + let mut vm = new_storage_test_vm("https://scheduling-realm.test/"); + vm.eval( + r#" + const frame = document.createElement('iframe'); + (document.body || document.documentElement || document).appendChild(frame); + globalThis.__schedulingFrame = frame; + "#, + ) + .expect("Scheduling child setup"); + materialize_single_child_default_realm_for_test(&mut vm, "Scheduling child realm"); + assert_eq!( + vm.eval( + r#" + (() => { + const child = __schedulingFrame.contentWindow; + const getter = Object.getOwnPropertyDescriptor(Navigator.prototype, 'scheduling').get; + const scheduling = getter.call(child.navigator); + return JSON.stringify({ + ownRealm: Object.getPrototypeOf(scheduling) === child.Scheduling.prototype, + stable: scheduling === child.navigator.scheduling, + separate: scheduling !== navigator.scheduling, + idle: Scheduling.prototype.isInputPending.call(scheduling) + }); + })() + "#, + ) + .expect("borrowed Scheduling getter should use the Navigator realm"), + r#"{"ownRealm":true,"stable":true,"separate":true,"idle":false}"# + ); +} diff --git a/moli-renderer-v8/src/script_vm/tests/lazy_window_surfaces.rs b/moli-renderer-v8/src/script_vm/tests/lazy_window_surfaces.rs index 58714e30d8..3029a4a01f 100644 --- a/moli-renderer-v8/src/script_vm/tests/lazy_window_surfaces.rs +++ b/moli-renderer-v8/src/script_vm/tests/lazy_window_surfaces.rs @@ -621,6 +621,7 @@ fn navigator_scalar_access_does_not_materialize_unused_same_object_children() { "Geolocation", "GeolocationPositionError", "MediaCapabilities", + "Scheduling", "Clipboard", "ClipboardItem", ] { @@ -663,7 +664,8 @@ fn navigator_scalar_access_does_not_materialize_unused_same_object_children() { "mediaCapabilities", navigator.mediaCapabilities, navigator.mediaCapabilities - ] + ], + ["scheduling", navigator.scheduling, navigator.scheduling] ]; return JSON.stringify({ same: pairs.every(([, first, second]) => first === second), @@ -685,13 +687,15 @@ fn navigator_scalar_access_does_not_materialize_unused_same_object_children() { Geolocation.prototype, mediaCapabilitiesRealm: Object.getPrototypeOf(navigator.mediaCapabilities) === - MediaCapabilities.prototype + MediaCapabilities.prototype, + schedulingRealm: + Object.getPrototypeOf(navigator.scheduling) === Scheduling.prototype }); })() "#, ) .expect("Navigator lazy subobjects should materialize"), - r#"{"same":true,"languagesFrozen":true,"permissionsRealm":true,"mediaDevicesRealm":true,"storageRealm":true,"clipboardRealm":true,"geolocationRealm":true,"mediaCapabilitiesRealm":true}"# + r#"{"same":true,"languagesFrozen":true,"permissionsRealm":true,"mediaDevicesRealm":true,"storageRealm":true,"clipboardRealm":true,"geolocationRealm":true,"mediaCapabilitiesRealm":true,"schedulingRealm":true}"# ); assert_eq!( default_navigator_subobjects(&mut vm), @@ -712,10 +716,12 @@ fn navigator_scalar_access_does_not_materialize_unused_same_object_children() { "storageBuckets", "geolocation", "mediaCapabilities", + "scheduling", ] ); assert_eq!(constructor_materialization_count(&mut vm, "Geolocation"), 1); assert_eq!(constructor_materialization_count(&mut vm, "Clipboard"), 1); + assert_eq!(constructor_materialization_count(&mut vm, "Scheduling"), 1); assert_eq!( constructor_materialization_count(&mut vm, "ClipboardItem"), 0, diff --git a/moli-renderer-v8/src/web_api_interfaces.rs b/moli-renderer-v8/src/web_api_interfaces.rs index 93553cdf52..865cd03f2f 100644 --- a/moli-renderer-v8/src/web_api_interfaces.rs +++ b/moli-renderer-v8/src/web_api_interfaces.rs @@ -416,6 +416,7 @@ interfaces! { SVGTransform; SVGTransformList; SVGUseElement: SVGGraphicsElement; + Scheduling; Screen; ScreenOrientation: EventTarget; SecurityPolicyViolationEvent: Event; diff --git a/moli-renderer-v8/src/worker/thread/tests/postmessage.rs b/moli-renderer-v8/src/worker/thread/tests/postmessage.rs index af88a2dfd1..259de7c9ed 100644 --- a/moli-renderer-v8/src/worker/thread/tests/postmessage.rs +++ b/moli-renderer-v8/src/worker/thread/tests/postmessage.rs @@ -1,5 +1,22 @@ use super::*; +#[tokio::test] +async fn worker_navigator_does_not_expose_window_scheduling() { + ensure_v8(); + let mut handle = spawn_worker( + format!( + "try {{ postMessage({}); }} catch (error) {{ postMessage(String(error)); }} close();", + include_str!("../../../../tests/fixtures/navigator-scheduling.js") + ), + "https://scheduling-worker.test/worker.js".into(), + ); + let message = timeout(TIMEOUT, handle.recv()) + .await + .expect("worker Scheduling exposure probe should settle") + .expect("worker should return the exposure result"); + assert_eq!(expect_post_json(message), r#""worker-ok""#); +} + #[tokio::test] async fn worker_compression_streams_roundtrip_all_formats() { ensure_v8(); diff --git a/moli-renderer-v8/tests/fixtures/navigator-scheduling.js b/moli-renderer-v8/tests/fixtures/navigator-scheduling.js new file mode 100644 index 0000000000..9faccd2c68 --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/navigator-scheduling.js @@ -0,0 +1,54 @@ +// Idle/capability contract only; this does not test queued-input reporting. +(() => { + const assert = (condition, message) => { if (!condition) throw new Error(message); }; + if (typeof window === 'undefined') { + assert(typeof Scheduling === 'undefined', 'Scheduling must not be exposed in workers'); + assert(!('scheduling' in navigator), 'WorkerNavigator must not expose scheduling'); + return 'worker-ok'; + } + const scheduling = navigator.scheduling; + assert(typeof Scheduling === 'function', 'Scheduling interface'); + assert(scheduling instanceof Scheduling, 'Scheduling brand'); + assert(Object.getPrototypeOf(scheduling) === Scheduling.prototype, 'Scheduling prototype'); + assert(Object.prototype.toString.call(scheduling) === '[object Scheduling]', 'Scheduling tag'); + assert(navigator.scheduling === scheduling, 'cached per-Navigator object'); + assert(Object.getOwnPropertyNames(scheduling).length === 0, 'no own implementation fields'); + assert(!Object.hasOwn(navigator, 'scheduling'), 'Navigator prototype accessor'); + const descriptor = Object.getOwnPropertyDescriptor(Navigator.prototype, 'scheduling'); + assert(descriptor.enumerable && descriptor.configurable && !descriptor.set, 'readonly accessor flags'); + assert(descriptor.get.call(navigator) === scheduling, 'borrowed getter'); + const method = Scheduling.prototype.isInputPending; + const methodDescriptor = Object.getOwnPropertyDescriptor(Scheduling.prototype, 'isInputPending'); + assert(methodDescriptor.enumerable && methodDescriptor.configurable && methodDescriptor.writable, 'method flags'); + assert(method.length === 0 && method.name === 'isInputPending', 'method signature'); + assert(Function.prototype.toString.call(method).includes('[native code]'), 'native method'); + const throwsTypeError = (callback, message) => { + let caught; + try { callback(); } catch (error) { caught = error; } + assert(caught instanceof TypeError, message); + }; + throwsTypeError(() => new Scheduling(), 'illegal constructor'); + throwsTypeError(() => Scheduling(), 'illegal function call'); + for (const receiver of [null, {}, Scheduling.prototype, Object.create(Scheduling.prototype), new Proxy(scheduling, {})]) { + throwsTypeError(() => method.call(receiver), 'illegal method receiver'); + } + throwsTypeError(() => descriptor.get.call({}), 'illegal Navigator receiver'); + for (const options of [undefined, null, {}, {includeContinuous:false}, {includeContinuous:true}, () => {}]) { + assert(method.call(scheduling, options) === false, 'idle result'); + } + for (const options of [1, true, 'x', Symbol('x')]) { + throwsTypeError(() => method.call(scheduling, options), 'dictionary conversion'); + } + const accesses = []; + const options = new Proxy({}, {get(_target, key) { accesses.push(key); return {valueOf() { throw new Error('no coercion'); }}; }}); + assert(method.call(scheduling, options) === false, 'truthy includeContinuous'); + assert(accesses.join(',') === 'includeContinuous', 'only read the declared dictionary member'); + const sentinel = new Error('options getter'); + let caught; + try { method.call(scheduling, {get includeContinuous() { throw sentinel; }}); } catch (error) { caught = error; } + assert(caught === sentinel, 'preserve dictionary getter exception'); + let reads = 0; + throwsTypeError(() => method.call({}, {get includeContinuous() { reads++; }}), 'brand precedes conversion'); + assert(reads === 0, 'invalid receiver must not read options'); + return 'window-ok'; +})()