diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 98ea994..65ffaa1 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -2135,6 +2135,10 @@ class Element extends Node { } set value(v) { const tag = this.localName; + if (tag === 'option') { + this.setAttribute('value', String(v)); + return; + } if (tag === 'select') { // Set selected on matching option, clear on others. Puppeteer's // page.select(selector, value) round-trips through this setter. @@ -2236,7 +2240,36 @@ class Element extends Node { if (this._selected !== undefined) return this._selected; return this.hasAttribute("selected"); } - set selected(v) { this._selected = !!v; } + set selected(v) { + this._selected = !!v; + // Keep the native DOM tree in sync so layout/paint observes live form + // state after scripts construct or change an option. + if (this.localName === 'option') { + if (this._selected) this.setAttribute('selected', ''); + else this.removeAttribute('selected'); + } + } + get text() { + if (['option', 'script', 'title', 'a'].includes(this.localName)) { + return this.textContent; + } + return undefined; + } + set text(v) { + if (['option', 'script', 'title', 'a'].includes(this.localName)) { + this.textContent = String(v); + return; + } + // Most elements have no platform `text` reflector. Preserve ordinary + // expando semantics for them even though all HTML element interfaces + // currently share this prototype. + Object.defineProperty(this, 'text', { + value: v, + writable: true, + enumerable: true, + configurable: true + }); + } get disabled() { return this.hasAttribute("disabled"); } set disabled(v) { if (v) this.setAttribute("disabled", ""); else this.removeAttribute("disabled"); } get type() { return this.getAttribute("type") || (this.localName === "input" ? "text" : ""); } @@ -2375,12 +2408,29 @@ class Element extends Node { if (this.localName !== 'select') return []; return HTMLCollection._from(this.querySelectorAll('option')); } + add(item, before = null) { + if (this.localName !== 'select') { + throw new TypeError("Illegal invocation"); + } + if (!item || item.nodeType !== 1 + || (item.localName !== 'option' && item.localName !== 'optgroup')) { + throw new TypeError("Failed to execute 'add' on 'HTMLSelectElement': parameter 1 is not of type 'HTMLOptionElement' or 'HTMLOptGroupElement'."); + } + if (typeof before === 'number') { + const reference = this.options[before] || null; + this.insertBefore(item, reference); + } else if (before == null) { + this.appendChild(item); + } else { + this.insertBefore(item, before); + } + } get selectedIndex() { const opts = this.options; for (let i = 0; i < opts.length; i++) { if (opts[i].selected || opts[i].hasAttribute('selected')) return i; } - return -1; + return opts.length ? 0 : -1; } set selectedIndex(v) { const opts = this.options; diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index ea2f291..f34590f 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -3094,6 +3094,44 @@ mod tests { assert_eq!(value["attribute"], "color: red;"); } + #[test] + fn select_add_and_option_text_update_the_live_dom() { + let mut rt = setup_runtime(""); + let result = rt + .evaluate( + r#"(() => { + const select = document.getElementById('language'); + const english = document.createElement('option'); + english.value = 'en'; + english.text = 'English'; + english.selected = true; + select.add(english); + const greek = document.createElement('option'); + greek.value = 'el'; + greek.text = 'Greek'; + select.add(greek, 0); + return JSON.stringify({ + labels: [...select.options].map(option => option.textContent), + selectedIndex: select.selectedIndex, + value: select.value, + html: select.outerHTML + }); + })()"#, + ) + .unwrap(); + let value: serde_json::Value = + serde_json::from_str(result.as_str().unwrap()).unwrap(); + assert_eq!(value["labels"], serde_json::json!(["Greek", "English"])); + assert_eq!(value["selectedIndex"], 1); + assert_eq!(value["value"], "en"); + assert!( + value["html"] + .as_str() + .unwrap() + .contains(r#""#) + ); + } + /// Regression for #105: `element.querySelector` and `querySelectorAll` /// must scope to the receiver's subtree, not the whole document. #[test]