fix: handle portal combobox attributes (#1195)

## ℹ️ Description

- Link to the related issue(s): Issue #
- Fixes publishing for special-attribute button comboboxes whose option
list opens via a fuller click sequence or renders outside the control
parent element.

## 📋 Changes Summary

- Dispatch mouseup and click in addition to the existing pointer/mouse
down events when opening button comboboxes.
- Add document-level listbox/menu fallback lookup for portal-rendered
combobox popups.
- Normalize option text whitespace before text fallback matching.
- Add regression coverage for the combobox JS contract and no-options
failure diagnostics.

### ⚙️ Type of Change

- [x] 🐞 Bug fix (non-breaking change which fixes an issue)
- [ ]  New feature (adds new functionality without breaking existing
usage)
- [ ] 💥 Breaking change (changes that might break existing user setups,
scripts, or configurations)

##  Checklist

- [x] I have reviewed my changes to ensure they meet the project
standards.
- [x] I have tested my changes and ensured that all tests pass (pdm run
test).
- [x] I have formatted the code (pdm run format).
- [x] I have verified that linting passes (pdm run lint).
- [x] I have updated documentation where necessary.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

* **Bug Fixes**
* Improved dropdown/combobox selection reliability, including better
support for portal-rendered menus and document-level listbox/menu
fallbacks.
* Made option matching more tolerant by normalizing whitespace and
applying consistent case handling.
* Reduced selection failures by using a more complete native interaction
sequence when opening and choosing options.
* Enhanced unit coverage to validate the injected selection script and
clearer timeout error messaging.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Jens
2026-07-03 14:48:00 +02:00
committed by GitHub
parent 1bb8adc2cf
commit a051712dc8
2 changed files with 100 additions and 9 deletions

View File

@@ -500,9 +500,13 @@ async def set_pricing_fields(web:WebScrapingMixin, ad_cfg:Ad, ad_defaults:AdDefa
async def _select_button_combobox(web:WebScrapingMixin, elem_id:str, value:str) -> None:
"""Select an option from a <button role="combobox"> dropdown by its API value.
Opens the control with pointerdown/mousedown and selects the matching option
in a single async browser script execution. Uses React fiber matching first,
then DOM attribute matching, then normalized text fallback.
Opens the control with a full native click sequence (pointerdownmousedown
mouseup → click) and selects the matching option in a single async browser
script execution. Uses React fiber matching first, then DOM attribute
matching, then normalized text fallback. The listbox search first checks the button's ID-suffixed element,
then the button's parentElement, then uses aria-controls/owns
association, and finally falls back to visible document-level
portal candidates with aria-labelledby matching.
"""
js_elem_id = json.dumps(elem_id)
js_value = json.dumps(value)
@@ -513,6 +517,8 @@ async def _select_button_combobox(web:WebScrapingMixin, elem_id:str, value:str)
if (!btn) return {{ok:false, reason:'button_not_found'}};
btn.dispatchEvent(new PointerEvent('pointerdown',{{bubbles:true,cancelable:true}}));
btn.dispatchEvent(new MouseEvent('mousedown',{{bubbles:true,cancelable:true}}));
btn.dispatchEvent(new MouseEvent('mouseup',{{bubbles:true,cancelable:true}}));
btn.dispatchEvent(new MouseEvent('click',{{bubbles:true,cancelable:true}}));
var listbox = null;
var options = null;
var pollDeadline = Date.now() + {poll_deadline_ms};
@@ -526,6 +532,34 @@ async def _select_button_combobox(web:WebScrapingMixin, elem_id:str, value:str)
var m = btn.parentElement.querySelector('[role="menu"]');
if (m) candidate = m;
}}
/* Headless UI / React portals: try aria-controls/owns association first,
then visible document-level portal candidates with aria-labelledby. */
if (!candidate && btn.getAttribute) {{
var controlledId = btn.getAttribute('aria-controls');
if (controlledId) {{
candidate = document.getElementById(controlledId);
}}
}}
if (!candidate && btn.getAttribute) {{
var ownedId = btn.getAttribute('aria-owns');
if (ownedId) {{
candidate = document.getElementById(ownedId);
}}
}}
if (!candidate) {{
var portalCandidates = document.querySelectorAll('[role="listbox"],[role="menu"]');
for (var p = 0; p < portalCandidates.length; p++) {{
var pc = portalCandidates[p];
var rect = pc.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {{
var labelledby = pc.getAttribute('aria-labelledby');
if (!labelledby || labelledby === btn.id) {{
candidate = pc;
break;
}}
}}
}}
}}
if (candidate) {{
options = Array.from(candidate.querySelectorAll('[role="option"]'));
if (options.length > 0) break;
@@ -534,7 +568,7 @@ async def _select_button_combobox(web:WebScrapingMixin, elem_id:str, value:str)
if (!options || options.length === 0) {{
return {{ok:false, reason:'no_options_after_opening', options:[]}};
}}
var optionInfo = options.map(function(o){{return o.textContent ? o.textContent.trim() : '';}});
var optionInfo = options.map(function(o){{return o.textContent ? o.textContent.replace(/\\s+/g,' ').trim() : '';}});
var fiberKey = Object.keys(btn).find(function(k){{return k.startsWith('__reactFiber');}});
if (fiberKey) {{
var fiber = btn[fiberKey];
@@ -560,8 +594,8 @@ async def _select_button_combobox(web:WebScrapingMixin, elem_id:str, value:str)
o.click();
return {{ok:true}};
}}
var text = (o.textContent || '').trim().toLowerCase();
if (text === {js_value}.toLowerCase()) {{
var text = (o.textContent || '').replace(/\\s+/g,' ').trim().toLowerCase();
if (text === ({js_value}+'').replace(/\\s+/g,' ').trim().toLowerCase()) {{
o.click();
return {{ok:true}};
}}

View File

@@ -2016,9 +2016,9 @@ class TestSelectButtonCombobox:
"""Tests for _select_button_combobox (single async script combobox selection).
``_select_button_combobox`` is the internal helper used for special-attribute
comboboxes where the option list is embedded in the React fiber tree. It executes
a single async JS script that opens the control via pointerdown/mousedown,
polls for visible options, and clicks the matching option by value.
button comboboxes. It executes a single async JS script that opens the control
via a full native click sequence (pointerdownmousedown → mouseup → click),
polls for visible options, and clicks the matching option by value or text.
"""
@pytest.mark.asyncio
@@ -2046,6 +2046,63 @@ class TestSelectButtonCombobox:
assert json.dumps(elem_id) in js
assert json.dumps(option_value) in js
@pytest.mark.asyncio
async def test_select_button_combobox_js_contains_full_click_sequence(
self,
test_bot:KleinanzeigenBot,
) -> None:
"""The injected JS must dispatch a full click sequence (including mouseup/click)
and include document-level listbox search for portal-rendered menus."""
elem_id = "my-combobox-id"
option_value = "option_value"
with (
patch.object(test_bot, "web_execute", new_callable = AsyncMock, return_value = {"ok": True}) as mock_execute,
):
await _select_button_combobox(test_bot, elem_id, option_value)
assert mock_execute.await_args is not None
js = str(mock_execute.await_args.args[0])
# Full click sequence: pointerdown + mousedown + mouseup + click
assert "PointerEvent('pointerdown'" in js
assert "MouseEvent('mousedown'" in js
assert "MouseEvent('mouseup'" in js
assert "MouseEvent('click'" in js
# Scoped portal fallback: aria-controls/owns association first, then visible portal candidates with aria-labelledby
assert "btn.getAttribute('aria-controls')" in js
assert "document.getElementById(controlledId)" in js
assert "btn.getAttribute('aria-owns')" in js
assert "document.getElementById(ownedId)" in js
assert "document.querySelectorAll('[role=\"listbox\"],[role=\"menu\"]')" in js
assert "getBoundingClientRect" in js
assert "aria-labelledby" in js
# Whitespace normalization in text matching
assert ".replace(/\\s+/g,' ').trim()" in js
@pytest.mark.asyncio
async def test_select_button_combobox_no_options_failure_raises_timeout(
self,
test_bot:KleinanzeigenBot,
) -> None:
"""When JS returns no_options_after_opening, TimeoutError contains the mapped label."""
elem_id = "my-combobox-id"
option_value = "missing_option"
js_status = {
"ok": False,
"reason": "no_options_after_opening",
"options": [],
}
with (
patch.object(test_bot, "web_execute", new_callable = AsyncMock, return_value = js_status),
pytest.raises(TimeoutError) as exc_info,
):
await _select_button_combobox(test_bot, elem_id, option_value)
msg = str(exc_info.value)
assert "my-combobox-id" in msg
assert "No options appeared after opening" in msg
@pytest.mark.asyncio
async def test_select_button_combobox_structured_failure_raises_timeout(
self,