From d15cac61493b3894134bf648735e170223f8f8e5 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Mon, 25 May 2026 20:08:49 +0000 Subject: [PATCH] fix(js): Element.attributes, on* event defaults, per-nid getBoundingClientRect (#45) (#186) Three DOM-completeness gaps that broke jQuery 1.11.3 init and Playwright actionability: 1. Element.attributes was undefined, so legacy IE feature-detect code like div.attributes[eventName].expando threw TypeError. Added a NamedNodeMap-like backed by a new attribute_names DOM op (length, numeric index, string name lookup, getNamedItem/setNamedItem, iterable). 2. Standard on* event handler properties were missing from window, so ('on' + ev) in window returned false and forced jQuery into the legacy IE branch. Initialised the 79 HTML-spec on* handlers to null. 3. getBoundingClientRect returned the same {x:8,y:8,width:100,height:20} literal for every element, so Playwright's hit-testing couldn't tell elements apart. Replaced with a deterministic synthesis from _nid mapped into a 12-column grid sized to a 1280x720 viewport. Still not pixel-accurate (no layout engine), but stable across reads and distinct per node. --- crates/obscura-js/js/bootstrap.js | 65 ++++++++++++++++++++++++++++++- crates/obscura-js/src/ops.rs | 12 ++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 15c84aa..d4a2069 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -499,6 +499,31 @@ class Element extends Node { removeAttributeNS(ns, n) { this.removeAttribute(n); } hasAttribute(n) { return this.getAttribute(n) !== null; } hasAttributes() { return true; } // Simplified + get attributes() { + const el = this; + const names = _domParse("attribute_names", el._nid) || []; + const list = names.map((name) => ({ + name, + localName: name, + value: el.getAttribute(name) ?? "", + namespaceURI: null, + prefix: null, + specified: true, + ownerElement: el, + nodeName: name, + nodeValue: el.getAttribute(name) ?? "", + nodeType: 2, + })); + list.length = names.length; + list.getNamedItem = (n) => names.includes(n) ? list[names.indexOf(n)] : null; + list.setNamedItem = (a) => { if (a && a.name) el.setAttribute(a.name, a.value); return a; }; + list.removeNamedItem = (n) => { const a = list.getNamedItem(n); if (a) el.removeAttribute(n); return a; }; + list.item = (i) => list[i] || null; + for (let i = 0; i < names.length; i++) { + Object.defineProperty(list, names[i], { value: list[i], configurable: true, enumerable: false }); + } + return list; + } getAttributeNS(ns, n) { return this.getAttribute(n); } querySelector(s) { return _wrapEl(+_dom("query_selector_scoped", this._nid, s)); } querySelectorAll(s) { @@ -779,7 +804,23 @@ class Element extends Node { get scrollLeft() { return 0; } set scrollLeft(v) {} getBoundingClientRect() { globalThis.__obscura_click_target = this; - return {x:8,y:8,width:100,height:20,top:8,right:108,bottom:28,left:8,toJSON(){return this;}}; + // No layout engine, but Playwright's actionability polling needs each + // element to occupy a stable, distinct rect so hit-testing can pick the + // right one (issue #45). Synthesize a deterministic position from the + // node id: every nid maps to a unique cell in a 12-column grid, sized + // to fit a 1280x720 viewport. Stable across reads, different per node. + 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)); + const cell = this._nid | 0; + const col = ((cell * 7) | 0) % COLS; + const row = (((cell * 13) | 0) >> 0) % rowsPerScreen; + const x = 10 + col * GX; + const y = 10 + row * GY; + return { + x, y, width: CW, height: CH, + top: y, right: x + CW, bottom: y + CH, left: x, + toJSON() { return this; }, + }; } getClientRects() { return [this.getBoundingClientRect()]; } // No layout engine: a stub that always returns true unblocks Playwright's @@ -1196,6 +1237,28 @@ globalThis.frames = globalThis; globalThis.frameElement = null; globalThis.length = 0; +// HTML spec exposes on* event handler IDL attributes on Window. Libraries like +// jQuery feature-detect bubbling via `("on" + ev) in window` and fall back to +// a legacy IE path that crashes on missing DOM APIs when the check returns +// false. Initialising them to null makes the check match real browsers. +for (const _ev of [ + "abort","beforeprint","beforeunload","blur","cancel","canplay","canplaythrough", + "change","click","close","contextmenu","cuechange","dblclick","drag","dragend", + "dragenter","dragleave","dragover","dragstart","drop","durationchange","emptied", + "ended","error","focus","focusin","focusout","formdata","gotpointercapture", + "hashchange","input","invalid","keydown","keypress","keyup","languagechange", + "load","loadeddata","loadedmetadata","loadstart","lostpointercapture","message", + "mousedown","mouseenter","mouseleave","mousemove","mouseout","mouseover","mouseup", + "offline","online","pagehide","pageshow","paste","pause","play","playing", + "pointercancel","pointerdown","pointerenter","pointerleave","pointermove", + "pointerout","pointerover","pointerup","popstate","progress","ratechange", + "rejectionhandled","reset","resize","scroll","seeked","seeking","select", + "stalled","storage","submit","suspend","timeupdate","toggle","unhandledrejection", + "unload","volumechange","waiting","wheel", +]) { + if (!(("on" + _ev) in globalThis)) globalThis["on" + _ev] = null; +} + globalThis.Window = globalThis.Window || function Window() {}; Object.defineProperty(globalThis.Window, Symbol.hasInstance, { value(obj) { return obj === globalThis || (obj && obj.window === obj); }, diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index 24aa66e..2438a65 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -174,6 +174,18 @@ fn op_dom(state: &OpState, #[string] cmd: String, #[string] arg1: String, #[stri let val = dom.get_node(NodeId::new(nid)).and_then(|n| n.get_attribute(&arg2).map(|s| s.to_string())); serde_json::to_string(&val).unwrap_or("null".into()) } + "attribute_names" => { + let nid = arg1.parse::().unwrap_or(0); + let names: Vec = dom + .get_node(NodeId::new(nid)) + .map(|n| { + n.attrs() + .map(|a| a.iter().map(|x| x.name.local.as_ref().to_string()).collect()) + .unwrap_or_default() + }) + .unwrap_or_default(); + serde_json::to_string(&names).unwrap_or("[]".into()) + } "set_attribute" => { let nid = arg1.parse::().unwrap_or(0); let node_id = NodeId::new(nid);