From 234368149759c559a05f318dd5c9fefef0406131 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Fri, 31 Jul 2026 07:51:57 +0000 Subject: [PATCH] Expose live browser geometry and computed styles --- crates/obscura-js/js/bootstrap.js | 372 +++++++++++++++++++++++++----- crates/obscura-js/src/ops.rs | 37 ++- crates/obscura-js/src/runtime.rs | 359 +++++++++++++++++++++++++++- 3 files changed, 706 insertions(+), 62 deletions(-) diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index e8f4d10..0581fda 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -2800,11 +2800,77 @@ class Element extends Node { } get offsetTop() { return this.getBoundingClientRect().top; } get offsetLeft() { return this.getBoundingClientRect().left; } - // documentElement / body / window expose VIEWPORT geometry, not their own content box. - // Puppeteer's #clickableBox clips boxes to document.documentElement.clientWidth/Height; - // returning 100x20 there made every element appear off-screen and broke .click(). - get clientWidth() { return this._isViewportRoot() ? (globalThis.innerWidth || 1280) : 100; } - get clientHeight() { return this._isViewportRoot() ? (globalThis.innerHeight || 720) : 20; } + // In standards mode documentElement exposes viewport client geometry. + // Puppeteer's #clickableBox clips boxes to those dimensions; returning the + // non-render fallback 100x20 there makes every element appear off-screen. + get clientWidth() { + // In standards mode only the root element exposes the viewport. Body is + // an ordinary box; treating it as another viewport breaks libraries that + // measure the page's body or a full-viewport sizing sentinel. + if (this.tagName === 'HTML') return globalThis.innerWidth || 1280; + const metrics = this._renderClientMetrics(); + return metrics ? metrics.width : 100; + } + get clientHeight() { + if (this.tagName === 'HTML') return globalThis.innerHeight || 720; + const metrics = this._renderClientMetrics(); + return metrics ? metrics.height : 20; + } + _renderClientMetrics() { + if (typeof Deno.core.ops.op_layout_geometry !== 'function') return null; + try { + const raw = Deno.core.ops.op_layout_geometry(String(this._nid | 0)); + if (!raw) return { width: 0, height: 0 }; + const geometry = JSON.parse(raw); + if (geometry + && Number.isFinite(geometry.clientWidth) + && Number.isFinite(geometry.clientHeight)) { + // CSSOM View exposes Web IDL longs. The native layout retains + // subpixel precision for getBoundingClientRect(); client metrics round + // to whole CSS pixels like Chromium. + return { + width: Math.round(Math.max(0, geometry.clientWidth)), + height: Math.round(Math.max(0, geometry.clientHeight)), + }; + } + } catch (_error) {} + return { width: 0, height: 0 }; + } + // `undefined` means this is a non-render build. `null` means the render + // engine is present but this element has no associated CSS box (for + // example, display:none or a detached element). Keep those states distinct: + // CSSOM View returns an empty rect list for the latter, while the former + // deliberately retains Obscura's compatibility geometry. + _renderBoxGeometry() { + if (typeof Deno.core.ops.op_layout_geometry !== 'function') return undefined; + try { + const raw = Deno.core.ops.op_layout_geometry(String(this._nid | 0)); + if (!raw) return null; + const geometry = JSON.parse(raw); + if (geometry + && Number.isFinite(geometry.x) + && Number.isFinite(geometry.y) + && Number.isFinite(geometry.width) + && Number.isFinite(geometry.height)) { + return geometry; + } + } catch (_error) {} + return null; + } + _rectFromRenderGeometry(geometry) { + const x = geometry.x, y = geometry.y; + const width = geometry.width, height = geometry.height; + const rect = { + x, y, width, height, + top: y, right: x + width, bottom: y + height, left: x, + toJSON() { return this; }, + }; + Object.defineProperty(rect, "__obscuraViewportFixed", { + value: !!geometry.viewportFixed, + enumerable: false, + }); + return rect; + } get scrollWidth() { if (!this._isViewportRoot()) return 100; const metrics = this._renderScrollMetrics(); @@ -2905,26 +2971,16 @@ class Element extends Node { // Real layout when the render feature is compiled in: ask the Rust layout // cache for this element's border box. The op is absent in the default // build, so probe with typeof and fall through to the synthetic rect below. - if (typeof Deno.core.ops.op_layout_geometry === 'function') { - try { - var _geom = Deno.core.ops.op_layout_geometry(String(this._nid | 0)); - if (_geom) { - var _r = JSON.parse(_geom); - if (_r && typeof _r.width === 'number') { - var _x = _r.x, _y = _r.y, _w = _r.width, _h = _r.height; - var _rect = { - x: _x, y: _y, width: _w, height: _h, - top: _y, right: _x + _w, bottom: _y + _h, left: _x, - toJSON() { return this; }, - }; - Object.defineProperty(_rect, "__obscuraViewportFixed", { - value: !!_r.viewportFixed, - enumerable: false, - }); - return _rect; - } - } - } catch (_e) {} + const geometry = this._renderBoxGeometry(); + if (geometry !== undefined) { + if (geometry) return this._rectFromRenderGeometry(geometry); + // CSSOM View: an element without an associated box has an all-zero + // bounding rect. Do not leak the non-render 100x20 compatibility cell. + return { + x: 0, y: 0, width: 0, height: 0, + top: 0, right: 0, bottom: 0, left: 0, + toJSON() { return this; }, + }; } // Default (non-render) builds keep viewport-sized roots. Without this // synthetic fallback every hit test against them clips down to a 100x20 @@ -2938,9 +2994,9 @@ class Element extends Node { toJSON() { return this; }, }; } - // No layout engine (default build) or this node has no box: synthesize a - // deterministic position from the node id so Playwright's actionability - // polling still gets a stable, distinct rect for hit-testing (issue #45). + // No layout engine (default build): synthesize a deterministic position + // from the node id so Playwright's actionability polling still gets a + // stable, distinct rect for hit-testing (issue #45). // Every nid maps to a unique cell in a 12-column grid for a 1280x720 viewport. const VW = 1280, VH = 720, COLS = 12, CW = 100, CH = 20, GX = 110, GY = 30; const rowsPerScreen = Math.max(1, Math.floor((VH - 10) / GY)); @@ -2955,7 +3011,14 @@ class Element extends Node { toJSON() { return this; }, }; } - getClientRects() { return new DOMRectList([this.getBoundingClientRect()]); } + getClientRects() { + const geometry = this._renderBoxGeometry(); + if (geometry === null) return new DOMRectList([]); + if (geometry !== undefined) { + return new DOMRectList([this._rectFromRenderGeometry(geometry)]); + } + return new DOMRectList([this.getBoundingClientRect()]); + } // No layout engine: a stub that always returns true unblocks Playwright's // actionability polling. With a real layout we'd check display, visibility, // opacity and rect dimensions per spec. @@ -5594,36 +5657,218 @@ if (typeof TextDecoder === 'undefined') { }; } +function _splitMediaQueryList(input) { + const result = []; + let start = 0, depth = 0, quote = ''; + for (let i = 0; i < input.length; i++) { + const ch = input[i]; + if (quote) { + if (ch === '\\') i++; + else if (ch === quote) quote = ''; + } else if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '(') { + depth++; + } else if (ch === ')') { + depth--; + if (depth < 0) return null; + } else if (ch === ',' && depth === 0) { + result.push(input.slice(start, i)); + start = i + 1; + } + } + if (depth !== 0 || quote) return null; + result.push(input.slice(start)); + return result; +} + +function _splitMediaAnd(input) { + const result = []; + let start = 0, depth = 0, quote = ''; + for (let i = 0; i < input.length; i++) { + const ch = input[i]; + if (quote) { + if (ch === '\\') i++; + else if (ch === quote) quote = ''; + continue; + } + if (ch === '"' || ch === "'") { quote = ch; continue; } + if (ch === '(') { depth++; continue; } + if (ch === ')') { depth--; continue; } + if (depth === 0 && input.slice(i, i + 3).toLowerCase() === 'and' + && (i === 0 || /\s/.test(input[i - 1])) + && (i + 3 === input.length || /\s/.test(input[i + 3]))) { + result.push(input.slice(start, i)); + start = i + 3; + i += 2; + } + } + result.push(input.slice(start)); + return result; +} + +function _mediaViewportDimension(name) { + const value = name === 'width' ? Number(globalThis.innerWidth) : Number(globalThis.innerHeight); + if (Number.isFinite(value)) return value; + return name === 'width' ? 1440 : 900; +} + +function _parseMediaPx(value) { + const match = String(value).trim().match(/^([+-]?(?:\d+(?:\.\d*)?|\.\d+))(px)?$/i); + if (!match || (!match[2] && Number(match[1]) !== 0)) return null; + const result = Number(match[1]); + return Number.isFinite(result) ? result : null; +} + +function _compareMediaValues(left, operator, right) { + if (operator === '<') return left < right; + if (operator === '<=') return left <= right; + if (operator === '>') return left > right; + if (operator === '>=') return left >= right; + return left === right; +} + +function _evaluateMediaDimension(feature) { + let match = feature.match(/^(min|max)-(width|height)\s*:\s*(.+)$/); + if (match) { + const expected = _parseMediaPx(match[3]); + if (expected === null) return false; + const actual = _mediaViewportDimension(match[2]); + return match[1] === 'min' ? actual >= expected : actual <= expected; + } + + match = feature.match(/^(width|height)\s*:\s*(.+)$/); + if (match) { + const expected = _parseMediaPx(match[2]); + return expected !== null && _mediaViewportDimension(match[1]) === expected; + } + + match = feature.match(/^(width|height)\s*(<=|>=|=|<|>)\s*(.+)$/); + if (match) { + const expected = _parseMediaPx(match[3]); + return expected !== null + && _compareMediaValues(_mediaViewportDimension(match[1]), match[2], expected); + } + + match = feature.match(/^(.+?)\s*(<=|>=|=|<|>)\s*(width|height)$/); + if (match) { + const expected = _parseMediaPx(match[1]); + return expected !== null + && _compareMediaValues(expected, match[2], _mediaViewportDimension(match[3])); + } + + match = feature.match(/^(.+?)\s*(<=|>=|<|>)\s*(width|height)\s*(<=|>=|<|>)\s*(.+)$/); + if (match) { + const lower = _parseMediaPx(match[1]); + const upper = _parseMediaPx(match[5]); + if (lower === null || upper === null) return false; + const actual = _mediaViewportDimension(match[3]); + return _compareMediaValues(lower, match[2], actual) + && _compareMediaValues(actual, match[4], upper); + } + + if (feature === 'width' || feature === 'height') + return _mediaViewportDimension(feature) !== 0; + return null; +} + +function _evaluateMediaFeature(raw) { + let feature = raw.trim().toLowerCase(); + if (feature[0] !== '(' || feature[feature.length - 1] !== ')') return false; + feature = feature.slice(1, -1).trim(); + + const dimension = _evaluateMediaDimension(feature); + if (dimension !== null) return dimension; + + let match = feature.match(/^orientation\s*:\s*(portrait|landscape)$/); + if (match) { + const width = _mediaViewportDimension('width'); + const height = _mediaViewportDimension('height'); + return match[1] === 'portrait' ? height >= width : width > height; + } + + match = feature.match(/^prefers-color-scheme\s*:\s*(dark|light|no-preference)$/); + if (match) return match[1] === 'light'; + match = feature.match(/^prefers-reduced-motion\s*:\s*(reduce|no-preference)$/); + if (match) return match[1] === 'no-preference'; + + match = feature.match(/^(pointer|any-pointer)\s*:\s*(none|coarse|fine)$/); + if (match) return match[2] === 'fine'; + match = feature.match(/^(hover|any-hover)\s*:\s*(none|hover)$/); + if (match) return match[2] === 'hover'; + + if (feature === 'color') return true; + match = feature.match(/^color\s*:\s*(\d+)$/); + if (match) return Number(match[1]) === 8; + return false; +} + +function _evaluateOneMediaQuery(raw) { + let query = raw.trim().toLowerCase(); + if (!query) return false; + + let negate = false; + let modifier = query.match(/^(not|only)\b\s*/); + if (modifier) { + negate = modifier[1] === 'not'; + query = query.slice(modifier[0].length).trim(); + } + + let typeMatches = true; + if (query[0] !== '(') { + const type = query.match(/^([a-z][a-z0-9-]*)\b/i); + if (!type) return false; + typeMatches = type[1] === 'all' || type[1] === 'screen'; + if (type[1] !== 'all' && type[1] !== 'screen' && type[1] !== 'print') + typeMatches = false; + query = query.slice(type[0].length).trim(); + if (query) { + const conjunction = query.match(/^and\b\s*/); + if (!conjunction) return false; + query = query.slice(conjunction[0].length).trim(); + } + } + + let matches = typeMatches; + if (query) { + const conditions = _splitMediaAnd(query); + if (!conditions.length || conditions.some(condition => !condition.trim())) return false; + matches = matches && conditions.every(_evaluateMediaFeature); + } + return negate ? !matches : matches; +} + +function _evaluateMediaQueryList(query) { + const list = _splitMediaQueryList(String(query)); + return !!list && list.some(_evaluateOneMediaQuery); +} + globalThis.matchMedia = _markNative(function matchMedia(q) { - var s = (q || '').toLowerCase().replace(/\s+/g, ''); - var matches = false; - // Match headless Chromium's default: light color scheme. Reporting dark - // here made every JS-driven site (mdBook, docs, apps) switch itself into a - // dark theme, rendering nothing like a normal headless-Chrome screenshot. - if (s.includes('prefers-color-scheme:dark')) matches = false; - else if (s.includes('prefers-color-scheme:light')) matches = true; - else if (s.includes('prefers-color-scheme:no-preference')) matches = false; - else if (s.includes('prefers-reduced-motion:no-preference')) matches = true; - else if (s.includes('prefers-reduced-motion:reduce')) matches = false; - else if (s.includes('any-pointer:fine')) matches = true; - else if (s.includes('any-pointer:coarse')) matches = false; - else if (s.includes('pointer:fine')) matches = true; - else if (s.includes('hover:hover')) matches = true; - else if (s.includes('any-hover:hover')) matches = true; - else if (s.includes('color)') || s === '(color)') matches = true; - else if (s.includes('min-width')) { - var m = s.match(/min-width:\s*(\d+)px/); - matches = m ? (globalThis.innerWidth || 1440) >= parseInt(m[1]) : false; - } - else if (s.includes('max-width')) { - var m2 = s.match(/max-width:\s*(\d+)px/); - matches = m2 ? (globalThis.innerWidth || 1440) <= parseInt(m2[1]) : false; - } - return { matches: matches, media: q, onchange: null, addListener(){}, removeListener(){}, addEventListener(){}, removeEventListener(){}, dispatchEvent(){return true;} }; + const media = q == null ? '' : String(q); + return { + get matches() { return _evaluateMediaQueryList(media); }, + media, + onchange: null, + addListener(){}, + removeListener(){}, + addEventListener(){}, + removeEventListener(){}, + dispatchEvent(){return true;} + }; }); globalThis.getComputedStyle = (el) => { if (!el) el = document.body || {}; const style = el?.style || el?._style || new CSSStyleDeclaration(); + // Render builds expose one immutable snapshot from the retained final + // cascade/layout. Fetch it once per CSSStyleDeclaration proxy: reading a + // dozen properties must not trigger a dozen layout/native crossings. + let rendered = null; + if (typeof Deno.core.ops.op_computed_style === 'function' && el?._nid != null) { + try { + const raw = Deno.core.ops.op_computed_style(String(el._nid | 0)); + rendered = raw ? JSON.parse(raw) : null; + } catch (e) {} + } // React virtualization libraries (react-window, tanstack-virtual, // react-virtuoso) all compute container dimensions via getComputedStyle. // The defaults table previously returned `auto` for width/height and @@ -5679,10 +5924,13 @@ globalThis.getComputedStyle = (el) => { const lookup = (rawProp) => { if (typeof rawProp !== 'string') return ''; - // Inline value first. + const kebab = rawProp.replace(/([A-Z])/g, '-$1').toLowerCase(); + if (rendered && Object.prototype.hasOwnProperty.call(rendered, kebab)) + return rendered[kebab]; + // Non-render builds and properties outside the renderer snapshot retain + // the lightweight inline CSSOM behavior. const inlineVal = target.getPropertyValue ? target.getPropertyValue(rawProp) : ''; if (inlineVal) return inlineVal; - const kebab = rawProp.replace(/([A-Z])/g, '-$1').toLowerCase(); const dim = dimensionFor(kebab); if (dim != null) return dim; if (defaultsKebab[rawProp]) return defaultsKebab[rawProp]; @@ -5694,13 +5942,23 @@ globalThis.getComputedStyle = (el) => { return new Proxy(style, { get(_, prop) { if (prop === Symbol.toPrimitive || prop === Symbol.toStringTag) return undefined; - if (prop in target) return target[prop]; if (prop === 'getPropertyValue') return (name) => lookup(name); if (prop === 'getPropertyPriority') return () => ''; if (prop === 'item') return (i) => ''; if (prop === 'length') return 0; if (prop === 'cssText') return ''; if (prop === 'parentRule') return null; + // CSSStyleDeclaration's `has` trap intentionally reports every known + // CSS IDL property. Checking `prop in target` before this lookup therefore + // returned the empty inline declaration for e.g. computed.display and + // prevented every computed/default fallback below from running. + if (typeof prop === 'string' + && (_CSS_PROP_SET.has(prop) + || _CSS_PROP_SET.has(_cssKebabToCamel(prop)) + || prop.includes('-'))) { + return lookup(prop); + } + if (prop in target) return target[prop]; if (typeof prop === 'string') return lookup(prop); return undefined; }, diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index 524cf98..97197b7 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -2261,6 +2261,7 @@ pub fn build_extension() -> Extension { ops.push(op_set_dynamic_fonts()); ops.push(op_image_metadata()); ops.push(op_layout_geometry()); + ops.push(op_computed_style()); ops.push(op_layout_metrics()); ops.push(op_scroll_offset()); ops.push(op_scroll_to()); @@ -2426,8 +2427,10 @@ pub(crate) fn clamp_scroll_offset(state: &mut ObscuraState, requested: (f32, f32 /// cache. The cache is computed lazily on first read and cleared on navigation /// (see `set_dom`). Coordinates are viewport-relative after the shared root /// scroll offset, except for viewport-fixed subtrees. Returns JSON -/// `{"x","y","width","height"}` in CSS pixels, or an empty string when the -/// node has no box. Feature-gated. +/// `{"x","y","width","height","clientWidth","clientHeight"}` in CSS pixels, +/// or an empty string when the node has no box. The client dimensions are the +/// unscaled padding box used by CSSOM View, whereas the rect is the visual +/// viewport-relative border box. Feature-gated. #[cfg(feature = "render")] #[op2] #[string] @@ -2441,15 +2444,41 @@ fn op_layout_geometry(state: &OpState, #[string] nid_str: String) -> String { let Some(rect) = prepared.viewport_rect(nid, scroll) else { return String::new(); }; + let Some((client_width, client_height)) = prepared.client_size(nid) else { + return String::new(); + }; let viewport_fixed = prepared.viewport_fixed_nodes().contains(&nid); return format!( - "{{\"x\":{},\"y\":{},\"width\":{},\"height\":{},\"viewportFixed\":{}}}", - rect.x, rect.y, rect.width, rect.height, viewport_fixed + "{{\"x\":{},\"y\":{},\"width\":{},\"height\":{},\"clientWidth\":{},\"clientHeight\":{},\"viewportFixed\":{}}}", + rect.x, + rect.y, + rect.width, + rect.height, + client_width, + client_height, + viewport_fixed ); } String::new() } +/// One renderer-computed CSS snapshot for `getComputedStyle()`. Returning all +/// supported properties together keeps a single JS style object to one native +/// call and one use of the retained prepared layout. +#[cfg(feature = "render")] +#[op2] +#[string] +fn op_computed_style(state: &OpState, #[string] nid_str: String) -> String { + let shared = state.borrow::().clone(); + let nid: u32 = nid_str.parse().unwrap_or(0); + let nid = obscura_dom::tree::NodeId::new(nid); + let mut gs = shared.borrow_mut(); + ensure_prepared_render(&mut gs) + .and_then(|prepared| prepared.computed_style(nid)) + .and_then(|snapshot| serde_json::to_string(&snapshot).ok()) + .unwrap_or_default() +} + /// Root scrolling overflow in CSS pixels. The JS CSSOM probes this op only in /// render builds; default scraping builds retain their deliberately unbounded /// synthetic scrolling behavior. diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 49aa206..1607826 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -2067,6 +2067,178 @@ mod tests { ); } + #[test] + fn match_media_evaluates_query_lists_conjunctions_ranges_and_orientation() { + let dom = parse_html(""); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(1280.0, 720.0); + rt.run_page_init(); + + let result = rt + .evaluate( + r#" + return [ + matchMedia("(min-width: 1024px) and (min-height: 700px)").matches, + matchMedia("(min-width: 1024px) and (min-height: 900px)").matches, + matchMedia("(max-width: 600px), screen and (orientation: landscape)").matches, + matchMedia("not print").matches, + matchMedia("not screen").matches, + matchMedia("only screen and (width: 1280px) and (height = 720px)").matches, + matchMedia("(1000px <= width < 1400px) and (height > 700px)").matches, + matchMedia("(orientation: portrait)").matches, + matchMedia("(prefers-color-scheme: light) and (pointer: fine) and (hover: hover)").matches, + matchMedia("(obscura-unknown-feature: yes)").matches + ]; + "#, + ) + .unwrap(); + assert_eq!( + result, + serde_json::json!([true, false, true, true, false, true, true, false, true, false]) + ); + } + + #[test] + fn match_media_matches_are_live_across_viewport_resizes() { + let dom = parse_html(""); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(900.0, 600.0); + rt.run_page_init(); + assert_eq!( + rt.evaluate( + r#" + return [ + (globalThis.__wideAndShort = matchMedia( + "(min-width: 800px) and (max-height: 700px)" + )).matches, + (globalThis.__portrait = matchMedia( + "(orientation: portrait)" + )).matches + ]; + "#, + ) + .unwrap(), + serde_json::json!([true, false]) + ); + + rt.set_viewport(600.0, 900.0); + assert_eq!( + rt.evaluate( + "return [__wideAndShort.matches, __portrait.matches,\ + matchMedia('(max-width: 600px), print').matches];", + ) + .unwrap(), + serde_json::json!([false, true, true]) + ); + } + + #[test] + fn computed_style_access_does_not_get_shadowed_by_inline_style_proxy() { + let mut rt = setup_runtime( + r#"
"#, + ); + let result = rt + .evaluate( + r#" + const box = document.getElementById("box"); + const computed = getComputedStyle(box); + return [ + computed.display, + computed.visibility, + computed.opacity, + computed.width, + computed.getPropertyValue("display"), + computed.getPropertyValue("background-color") + ]; + "#, + ) + .unwrap(); + assert_eq!( + result, + serde_json::json!([ + "block", + "visible", + "0.5", + "40px", + "block", + "rgba(0, 0, 0, 0)" + ]) + ); + } + + #[cfg(feature = "render")] + #[test] + fn computed_style_uses_renderer_stylesheet_cascade_and_invalidates() { + let dom = parse_html( + r#"
"#, + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(400.0, 200.0); + rt.run_page_init(); + + let initial = rt + .evaluate( + r#" + const box = document.getElementById("box"); + const c = getComputedStyle(box); + return [ + c.display, c.position, c.zIndex, c.visibility, c.opacity, + c.backgroundColor, c.getPropertyValue("color"), + c.width, c.height, c.minWidth, c.maxWidth, c.boxSizing, + c.overflowX, c.overflowY, + c.marginTop, c.marginRight, c.marginBottom, c.marginLeft, + c.paddingTop, c.borderLeftWidth, c.borderLeftColor, + c.flexDirection, c.flexWrap, c.alignItems, + c.justifyContent, c.rowGap, c.columnGap, c.transform + ]; + "#, + ) + .unwrap(); + assert_eq!( + initial, + serde_json::json!([ + "flex", "relative", "7", "hidden", "0.35", + "rgb(10, 20, 30)", "rgb(40, 50, 60)", + "120px", "40px", "20px", "160px", "border-box", + "clip", "visible", + "1px", "2px", "3px", "4px", + "5px", "2px", "rgb(70, 80, 90)", + "column", "wrap", "center", + "space-between", "6px", "9px", "translate(3px, 4px)" + ]) + ); + + assert_eq!( + rt.evaluate( + r#" + const box = document.getElementById("box"); + box.className = "alt"; + const c = getComputedStyle(box); + return [c.display, c.width, c.opacity]; + "#, + ) + .unwrap(), + serde_json::json!(["grid", "150px", "0.8"]) + ); + } + #[tokio::test(flavor = "current_thread")] async fn idle_event_loop_flushes_resolved_promise_continuations() { let mut rt = setup_runtime("
pending
"); @@ -3152,6 +3324,191 @@ mod tests { assert!(values[12..].iter().all(|value| value == &serde_json::json!(true))); } + /// CSSOM View exposes the viewport through the standards-mode root, but + /// ordinary elements (including body) report their padding box. Modern + /// animation libraries commonly measure a fixed 100vh sentinel through + /// clientHeight; the old synthetic 100x20 fallback collapsed all of their + /// viewport-relative trigger ranges. + #[cfg(feature = "render")] + #[test] + fn rendered_client_metrics_use_the_live_padding_box() { + let dom = parse_html( + r#" +
+
+
+ "#, + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(320.0, 200.0); + rt.run_page_init(); + + let initial = rt + .evaluate( + r#" + const tracker = document.getElementById("tracker"); + const box = document.getElementById("box"); + return { + root: [ + document.documentElement.clientWidth, + document.documentElement.clientHeight + ], + body: [document.body.clientWidth, document.body.clientHeight], + tracker: [ + tracker.clientWidth, tracker.clientHeight, + tracker.offsetWidth, tracker.offsetHeight + ], + box: [ + box.clientWidth, box.clientHeight, + box.getBoundingClientRect().width, + box.getBoundingClientRect().height + ] + }; + "#, + ) + .unwrap(); + assert_eq!(initial["root"], serde_json::json!([320, 200])); + assert_eq!(initial["body"], serde_json::json!([320, 967])); + assert_eq!(initial["tracker"], serde_json::json!([320, 200, 320, 200])); + assert_eq!(initial["box"][0], serde_json::json!(116)); + assert_eq!(initial["box"][1], serde_json::json!(62)); + assert!((initial["box"][2].as_f64().unwrap() - 123.0).abs() < 0.05); + assert_eq!(initial["box"][3], serde_json::json!(67)); + + // Attribute-backed inline-style changes invalidate the retained + // render. Borders do not change the padding box; padding does. + let mutated = rt + .evaluate( + r#" + const tracker = document.getElementById("tracker"); + const box = document.getElementById("box"); + tracker.style.height = "50vh"; + box.style.borderLeftWidth = "13px"; + box.style.paddingLeft = "17px"; + return [ + tracker.clientHeight, + box.clientWidth, + box.getBoundingClientRect().width + ]; + "#, + ) + .unwrap(); + assert_eq!(mutated[0], serde_json::json!(100)); + assert_eq!(mutated[1], serde_json::json!(126)); + assert_eq!(mutated[2], serde_json::json!(143)); + + // A later CDP/emulation viewport update invalidates the layout too; + // both the root special case and an ordinary 100vh box are live. + rt.set_viewport(640.0, 360.0); + assert_eq!( + rt.evaluate( + r#"const tracker = document.getElementById("tracker"); + return [ + document.documentElement.clientWidth, + document.documentElement.clientHeight, + tracker.clientWidth, + tracker.clientHeight + ]"#, + ) + .unwrap(), + serde_json::json!([640, 360, 640, 180]) + ); + } + + /// CSSOM View distinguishes "no associated CSS box" from a real box whose + /// dimensions happen to be zero. Blink and Gecko return an all-zero + /// bounding rect and no client rects for display:none/detached elements; + /// a laid-out zero-size box still contributes one client rect. + #[cfg(feature = "render")] + #[test] + fn rendered_cssom_rects_distinguish_no_box_from_zero_size_box() { + let dom = parse_html( + r#" + +
+ "#, + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(320.0, 200.0); + rt.run_page_init(); + + let result = rt + .evaluate( + r#" + const hidden = document.getElementById("hidden"); + const detached = document.createElement("div"); + detached.style.cssText = "display:block;width:90px;height:50px"; + const zero = document.getElementById("zero"); + const sample = element => { + const rect = element.getBoundingClientRect(); + const rects = element.getClientRects(); + return { + rect: [ + rect.x, rect.y, rect.width, rect.height, + rect.top, rect.right, rect.bottom, rect.left + ], + rectCount: rects.length, + firstWidth: rects.length ? rects[0].width : null, + }; + }; + return { + hidden: sample(hidden), + detached: sample(detached), + zero: sample(zero), + }; + "#, + ) + .unwrap(); + + for name in ["hidden", "detached"] { + assert_eq!( + result[name]["rect"], + serde_json::json!([0, 0, 0, 0, 0, 0, 0, 0]), + "{name} must expose the CSSOM View no-box bounding rect" + ); + assert_eq!( + result[name]["rectCount"], + serde_json::json!(0), + "{name} must expose an empty client rect list" + ); + assert_eq!(result[name]["firstWidth"], serde_json::Value::Null); + } + assert_eq!(result["zero"]["rect"][2], serde_json::json!(0)); + assert_eq!(result["zero"]["rect"][3], serde_json::json!(0)); + assert_eq!( + result["zero"]["rectCount"], + serde_json::json!(1), + "a real zero-size layout box must not be mistaken for no box" + ); + assert_eq!(result["zero"]["firstWidth"], serde_json::json!(0)); + } + + #[cfg(not(feature = "render"))] + #[test] + fn non_render_cssom_rects_keep_compatibility_geometry() { + let mut rt = setup_runtime(r#"
"#); + let result = rt + .evaluate( + r#" + const box = document.getElementById("box"); + const detached = document.createElement("div"); + return [box, detached].map(element => { + const rect = element.getBoundingClientRect(); + return [rect.width, rect.height, element.getClientRects().length]; + }); + "#, + ) + .unwrap(); + assert_eq!(result, serde_json::json!([[100, 20, 1], [100, 20, 1]])); + } + /// Chromium 150 reference (800x513 CSS-pixel viewport): /// top=[60,20,20,-267], bottom=[448,448,231,31,-496] at the sampled /// root scroll offsets. This keeps sticky distinct from fixed positioning, @@ -3511,7 +3868,7 @@ mod tests { fn root_overflow_clip_preserves_cssom_scroll_range() { let dom = parse_html( r#" - +