diff --git a/moli-cdp-smoke/README.md b/moli-cdp-smoke/README.md index 6ea75681f3..b8ace74241 100644 --- a/moli-cdp-smoke/README.md +++ b/moli-cdp-smoke/README.md @@ -152,6 +152,12 @@ Offscreen canvases: viewport initialization, setter/conversion errors, copied Int32Array queries, context isolation, context reacquisition, resize retention, and clamping to the advertised maximum. It is not a GPU rendering test. +The default `svg-rect` group checks the detached `SVGRect` interface used by +SVG capability detection, sharing the renderer fixture for prototype, identity, +and restricted-float conversion contracts. It was calibrated on 2026-09-07 +against Debian `/usr/bin/chromium` 145.0.7632.116 and runs independently of +IndexedDB startup coverage. + Covered well: - The default raw `debugger-breakpoints`, `runtime-exception`, and diff --git a/moli-cdp-smoke/moli_cdp_smoke/groups/svg_rect.py b/moli-cdp-smoke/moli_cdp_smoke/groups/svg_rect.py new file mode 100644 index 0000000000..4e25275179 --- /dev/null +++ b/moli-cdp-smoke/moli_cdp_smoke/groups/svg_rect.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import Any + +from ..assertions import assert_equal, record_contract +from ..config import REPO_ROOT + + +async def run_svg_rect_group( + browser: Any, fixture: str, results: list[dict[str, Any]] +) -> None: + context = await browser.new_context() + try: + page = await context.new_page() + await page.goto(f"{fixture}/plain", wait_until="load", timeout=10_000) + script = (REPO_ROOT / "moli-renderer-v8/tests/fixtures/svg-create-rect.js").read_text() + observed = await page.evaluate(script) + assert_equal(observed, "svg-create-rect:ok", "SVGRect interface and value contract") + record_contract( + results, + "svg_create_rect", + contract="SVG capability detection has a real detached SVGRect with restricted float fields.", + source="Chromium 145.0.7632.116 executable probe (2026-09-07)", + commands=["Runtime.evaluate"], + observed=observed, + ) + finally: + await context.close() diff --git a/moli-cdp-smoke/moli_cdp_smoke/runner.py b/moli-cdp-smoke/moli_cdp_smoke/runner.py index 69cb722391..e079d36e8e 100644 --- a/moli-cdp-smoke/moli_cdp_smoke/runner.py +++ b/moli-cdp-smoke/moli_cdp_smoke/runner.py @@ -60,6 +60,7 @@ from .groups.proxy_auth import run_proxy_auth_group from .groups.puppeteer import run_puppeteer_group from .groups.stagehand import run_stagehand_group +from .groups.svg_rect import run_svg_rect_group from .groups.target_semantics import run_target_semantics_group from .groups.tracing import run_raw_tracing_group, run_tracing_group from .groups.url_policy import run_url_policy_group @@ -345,6 +346,12 @@ async def _await_group(group: SmokeGroup, awaitable: Awaitable[None]) -> None: "browser", run_webgl_viewport_group, ), + SmokeGroup( + "svg-rect", + "Detached SVGRect interface, float conversion, and SVG feature detection.", + "browser", + run_svg_rect_group, + ), SmokeGroup( "media-error", "HTMLMediaElement MediaError publication, identity, and reset lifecycle.", diff --git a/moli-cdp-smoke/tests/test_group_selection.py b/moli-cdp-smoke/tests/test_group_selection.py index 753a5d6a9b..6246e9fda8 100644 --- a/moli-cdp-smoke/tests/test_group_selection.py +++ b/moli-cdp-smoke/tests/test_group_selection.py @@ -33,6 +33,7 @@ def test_default_runs_every_repository_managed_group(self) -> None: self.assertIn("navigation-outcomes", DEFAULT_GROUP_NAMES) self.assertIn("media-error", DEFAULT_GROUP_NAMES) self.assertIn("webgl-viewport", DEFAULT_GROUP_NAMES) + self.assertIn("svg-rect", DEFAULT_GROUP_NAMES) self.assertIn("multi-page", DEFAULT_GROUP_NAMES) self.assertIn("puppeteer", DEFAULT_GROUP_NAMES) self.assertEqual( diff --git a/moli-renderer-v8/src/context_bootstrap/runtime_state.rs b/moli-renderer-v8/src/context_bootstrap/runtime_state.rs index 202c30a2f0..ba7900686c 100644 --- a/moli-renderer-v8/src/context_bootstrap/runtime_state.rs +++ b/moli-renderer-v8/src/context_bootstrap/runtime_state.rs @@ -1695,6 +1695,7 @@ pub(crate) fn finish_context_bootstrap( ("XPathResult", "XPathResult"), ("SVGLength", "SVGLength"), ("SVGNumber", "SVGNumber"), + ("SVGRect", "SVGRect"), ("SVGAnimatedLength", "SVGAnimatedLength"), ("SVGLengthList", "SVGLengthList"), ("SVGAnimatedLengthList", "SVGAnimatedLengthList"), diff --git a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs index f250f9b37d..5fe8d43de7 100644 --- a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs +++ b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs @@ -108,6 +108,11 @@ const CONSTRUCTOR_SPECS_BEFORE_STREAMS: &[ConstructorSpec] = &[ parent: None, kind: ConstructorKind::Illegal, }, + ConstructorSpec { + name: "SVGRect", + parent: None, + kind: ConstructorKind::Illegal, + }, ConstructorSpec { name: "SVGAnimatedLength", parent: None, diff --git a/moli-renderer-v8/src/context_bootstrap/svg_runtime/bindings.rs b/moli-renderer-v8/src/context_bootstrap/svg_runtime/bindings.rs index cdca687bde..33f59d28bb 100644 --- a/moli-renderer-v8/src/context_bootstrap/svg_runtime/bindings.rs +++ b/moli-renderer-v8/src/context_bootstrap/svg_runtime/bindings.rs @@ -435,6 +435,13 @@ struct SvgTextContentElementTemplateMethodsDeclaration { #[derive(WebApiFunctionTemplate)] #[webapi(name = "SVGSVGElement", enumerable)] struct SvgSvgElementTemplateMethodsDeclaration { + #[webapi( + method = "createSVGRect", + length = 0, + callback = super::rect::create_svg_rect + )] + create_svg_rect: (), + #[webapi( method = "createSVGMatrix", length = 0, diff --git a/moli-renderer-v8/src/context_bootstrap/svg_runtime/mod.rs b/moli-renderer-v8/src/context_bootstrap/svg_runtime/mod.rs index 6825f4def8..175d953a79 100644 --- a/moli-renderer-v8/src/context_bootstrap/svg_runtime/mod.rs +++ b/moli-renderer-v8/src/context_bootstrap/svg_runtime/mod.rs @@ -12,6 +12,7 @@ use moli_svg::{ mod bindings; mod builders; mod callbacks; +mod rect; const SVG_GRAPHICS_TRANSFORM_SLOT: &str = "__moliSvgGraphicsTransform"; const SVG_PATTERN_TRANSFORM_SLOT: &str = "__moliSvgPatternTransform"; @@ -244,6 +245,7 @@ pub(in crate::context_bootstrap) fn install_svg_template_bindings<'s>( match name { "SVGLength" => bindings::install_svg_length_bindings(scope, template), "SVGNumber" => bindings::install_svg_number_bindings(scope, template), + "SVGRect" => rect::install_bindings(scope, template), "SVGAnimatedLength" => bindings::install_svg_animated_length_bindings(scope, template), "SVGLengthList" => bindings::install_svg_length_list_bindings(scope, template), "SVGAnimatedLengthList" => { diff --git a/moli-renderer-v8/src/context_bootstrap/svg_runtime/rect.rs b/moli-renderer-v8/src/context_bootstrap/svg_runtime/rect.rs new file mode 100644 index 0000000000..c0c8ae7837 --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/svg_runtime/rect.rs @@ -0,0 +1,139 @@ +//! Detached SVGRect values created by SVGSVGElement.createSVGRect(). +//! +//! JSXGraph uses this method to detect SVG support. Returning a DOMRect would +//! pass that check but expose the wrong interface and double (not float) fields. + +use crate::{ + native_bridge::node_runtime_and_handle_from_object_or_detached, + util::{callback_data_index_value, callback_data_item, get_private_value, set_private_value}, + webidl, +}; +use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject}; + +const X: &str = "__moliSvgRectX"; +const Y: &str = "__moliSvgRectY"; +const WIDTH: &str = "__moliSvgRectWidth"; +const HEIGHT: &str = "__moliSvgRectHeight"; +const FIELDS: &[(&str, &str)] = &[("x", X), ("y", Y), ("width", WIDTH), ("height", HEIGHT)]; + +#[derive(WebApiObject)] +#[webapi(interface = "SVGRect")] +struct SvgRectObjectDeclaration { + #[webapi(slot = X)] + x: f64, + #[webapi(slot = Y)] + y: f64, + #[webapi(slot = WIDTH)] + width: f64, + #[webapi(slot = HEIGHT)] + height: f64, +} + +#[derive(WebApiFunctionTemplate)] +#[webapi(name = "SVGRect", enumerable)] +struct SvgRectAccessorsDeclaration { + #[webapi(accessor_property, getter = get_field, setter = set_field, + data = callback_data_index_value(scope, 0))] + x: (), + #[webapi(accessor_property, getter = get_field, setter = set_field, + data = callback_data_index_value(scope, 1))] + y: (), + #[webapi(accessor_property, getter = get_field, setter = set_field, + data = callback_data_index_value(scope, 2))] + width: (), + #[webapi(accessor_property, getter = get_field, setter = set_field, + data = callback_data_index_value(scope, 3))] + height: (), +} + +pub(super) fn install_bindings<'s>( + scope: &mut v8::PinScope<'s, '_, ()>, + template: v8::Local<'s, v8::FunctionTemplate>, +) { + let prototype = template.prototype_template(scope); + SvgRectAccessorsDeclaration::initialize_prototype_template(scope, prototype); +} + +pub(super) fn create_svg_rect<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'_, v8::Value>, +) { + let is_svg_root = node_runtime_and_handle_from_object_or_detached(scope, args.this()) + .ok() + .is_some_and(|(host, handle)| { + unsafe { &*host } + .dom_host() + .node(handle) + .and_then(crate::dom::native::Node::as_element) + .is_some_and(|element| element.is_svg_element("svg")) + }); + if !is_svg_root { + webidl::throw_type_error( + scope, + "SVGSVGElement.createSVGRect called on incompatible receiver.", + ); + return; + } + let rect = SvgRectObjectDeclaration::new(0.0, 0.0, 0.0, 0.0) + .bind(scope) + .expect("SVGRect declaration should bind"); + rv.set(rect.into()); +} + +fn field_for_receiver<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: &v8::FunctionCallbackArguments<'s>, +) -> Option<(&'static str, &'static str)> { + if get_private_value(scope, args.this(), X).is_none() { + webidl::throw_type_error(scope, "SVGRect accessor called on incompatible receiver."); + return None; + } + callback_data_item(scope, args, FIELDS, "SVGRect fields") +} + +fn get_field<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'_, v8::Value>, +) { + let Some((_, slot)) = field_for_receiver(scope, &args) else { + return; + }; + if let Some(value) = get_private_value(scope, args.this(), slot) { + rv.set(value); + } +} + +fn set_field<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _rv: v8::ReturnValue<'_, v8::Value>, +) { + let Some((name, slot)) = field_for_receiver(scope, &args) else { + return; + }; + let value = match webidl::convert::( + scope, + args.get(0), + webidl::Context::member("SVGRect", name), + ) { + Ok(value) => value.0 as f32, + Err(error) => { + webidl::throw_error(scope, &error); + return; + } + }; + // SVGRect uses restricted WebIDL float: round to binary32, reject both + // non-finite input and finite doubles which overflow that representation. + if !value.is_finite() { + webidl::throw_type_error(scope, "SVGRect value is outside the finite float range."); + return; + } + set_private_value( + scope, + args.this(), + slot, + v8::Number::new(scope, f64::from(value)).into(), + ); +} diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface.rs index b72d484819..25e4f061e1 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface.rs @@ -1,5 +1,17 @@ use super::*; +#[test] +fn svg_create_rect_supports_capability_detection_and_detached_float_values() { + let mut vm = new_storage_test_vm("https://svg-rect.test/"); + assert_eq!( + vm.eval(include_str!( + "../../../../tests/fixtures/svg-create-rect.js" + )) + .expect("SVGRect contract should pass"), + "svg-create-rect:ok" + ); +} + async fn expect_one_child_frame_task_source( vm: &mut ScriptVm, expected: impl Into, diff --git a/moli-renderer-v8/tests/fixtures/svg-create-rect.js b/moli-renderer-v8/tests/fixtures/svg-create-rect.js new file mode 100644 index 0000000000..2d71b93f50 --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/svg-create-rect.js @@ -0,0 +1,47 @@ +(() => { + const check = (condition, message) => { + if (!condition) throw new Error(`createSVGRect: ${message}`); + }; + const throwsTypeError = operation => { + try { operation(); } catch (error) { return error instanceof TypeError; } + return false; + }; + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + check(typeof svg.createSVGRect === 'function' && svg.createSVGRect.length === 0, 'interface'); + const rect = svg.createSVGRect(); + const values = value => [value.x, value.y, value.width, value.height]; + check(JSON.stringify(values(rect)) === '[0,0,0,0]', 'zero initialization'); + check(rect instanceof SVGRect && !(rect instanceof DOMRect), 'SVGRect, not DOMRect'); + check(Object.prototype.toString.call(rect) === '[object SVGRect]', 'prototype tag'); + check(Object.getOwnPropertyNames(rect).length === 0, 'private value storage'); + check(Object.getPrototypeOf(rect) === SVGRect.prototype, 'prototype identity'); + check(Object.getPrototypeOf(SVGRect.prototype) === Object.prototype, 'prototype hierarchy'); + check(throwsTypeError(() => new SVGRect()), 'illegal constructor'); + check(throwsTypeError(() => svg.createSVGRect.call({})), 'method receiver'); + check(throwsTypeError(() => svg.createSVGRect.call( + document.createElementNS('http://www.w3.org/2000/svg', 'g'))), 'non-root SVG receiver'); + check(throwsTypeError(() => svg.createSVGRect.call(Object.create(SVGSVGElement.prototype))), + 'forged SVG receiver'); + for (const name of ['x', 'y', 'width', 'height']) { + const descriptor = Object.getOwnPropertyDescriptor(SVGRect.prototype, name); + check(descriptor.enumerable && descriptor.configurable, `${name} descriptor`); + check(typeof descriptor.get === 'function' && typeof descriptor.set === 'function', `${name} accessor`); + check(throwsTypeError(() => descriptor.get.call({})), `${name} getter receiver`); + check(throwsTypeError(() => descriptor.set.call({}, 1)), `${name} setter receiver`); + rect[name] = '1.2'; + check(rect[name] === Math.fround(1.2), `${name} rounds to float`); + for (const invalid of [NaN, Infinity, -Infinity, 1e40, undefined, Symbol()]) { + check(throwsTypeError(() => { rect[name] = invalid; }), `${name} rejects non-finite float`); + check(rect[name] === Math.fround(1.2), `${name} rejected write preserves value`); + } + rect[name] = -5; + check(rect[name] === -5, `${name} accepts negative values`); + rect[name] = null; + check(rect[name] === 0, `${name} numeric conversion`); + } + const second = svg.createSVGRect(); + rect.width = 50; + check(second !== rect && second.width === 0, 'independent detached values'); + check(!svg.hasAttribute('width'), 'no live attribute binding'); + return 'svg-create-rect:ok'; +})()