mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-07-09 12:41:05 +02:00
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:
@@ -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 (pointerdown → mousedown →
|
||||
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}};
|
||||
}}
|
||||
|
||||
@@ -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 (pointerdown → mousedown → 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,
|
||||
|
||||
Reference in New Issue
Block a user