diff --git a/ruyipage/_async/_generated.py b/ruyipage/_async/_generated.py index 0a8b1cd..31ec815 100644 --- a/ruyipage/_async/_generated.py +++ b/ruyipage/_async/_generated.py @@ -6,7 +6,7 @@ # │ 生成时间: 2026-08-02 23:52:54 │ # └──────────────────────────────────────────────────────────────────┘ -from .greenlet_bridge import greenlet_spawn +from .greenlet_bridge import greenlet_spawn as _bridge_greenlet_spawn from ._overrides import AsyncFirefoxBaseMixin, AsyncFirefoxElementMixin @@ -802,3 +802,44 @@ async def screenshot(self, path=None, as_bytes=None, as_base64=None): async def style(self, name, pseudo=''): _r = await greenlet_spawn(self._sync.style, name, pseudo=pseudo) return _wrap_async_result(_r, self) + + +def _unwrap_async_argument(value): + """Unwrap ruyipage async proxies before calling the synchronous API.""" + wrapper_types = ( + AsyncUnitProxy, + AsyncNoneElement, + AsyncFirefoxBase, + AsyncFirefoxElement, + ) + if isinstance(value, wrapper_types): + return value._sync + + if type(value) is list: + items = [_unwrap_async_argument(item) for item in value] + return value if all(a is b for a, b in zip(items, value)) else items + if type(value) is tuple: + items = tuple(_unwrap_async_argument(item) for item in value) + return value if all(a is b for a, b in zip(items, value)) else items + if type(value) is dict: + items = [ + (key, _unwrap_async_argument(item)) + for key, item in value.items() + ] + if all( + new_item is item + for (_, new_item), (_, item) in zip(items, value.items()) + ): + return value + return dict(items) + return value + + +async def greenlet_spawn(fn, *args, **kwargs): + """Run a sync API call after unwrapping ruyipage async arguments.""" + args = tuple(_unwrap_async_argument(value) for value in args) + kwargs = { + key: _unwrap_async_argument(value) + for key, value in kwargs.items() + } + return await _bridge_greenlet_spawn(fn, *args, **kwargs) diff --git a/scripts/generate_async_api.py b/scripts/generate_async_api.py index a2ae7c5..76b22bf 100644 --- a/scripts/generate_async_api.py +++ b/scripts/generate_async_api.py @@ -465,6 +465,51 @@ def _wrap_async_result(value, owner=None, unit_proxy=None): ''' +def generate_async_call_helpers(): + """Generate argument unwrapping and sync-call bridge helpers.""" + return ''' +def _unwrap_async_argument(value): + """Unwrap ruyipage async proxies before calling the synchronous API.""" + wrapper_types = ( + AsyncUnitProxy, + AsyncNoneElement, + AsyncFirefoxBase, + AsyncFirefoxElement, + ) + if isinstance(value, wrapper_types): + return value._sync + + if type(value) is list: + items = [_unwrap_async_argument(item) for item in value] + return value if all(a is b for a, b in zip(items, value)) else items + if type(value) is tuple: + items = tuple(_unwrap_async_argument(item) for item in value) + return value if all(a is b for a, b in zip(items, value)) else items + if type(value) is dict: + items = [ + (key, _unwrap_async_argument(item)) + for key, item in value.items() + ] + if all( + new_item is item + for (_, new_item), (_, item) in zip(items, value.items()) + ): + return value + return dict(items) + return value + + +async def greenlet_spawn(fn, *args, **kwargs): + """Run a sync API call after unwrapping ruyipage async arguments.""" + args = tuple(_unwrap_async_argument(value) for value in args) + kwargs = { + key: _unwrap_async_argument(value) + for key, value in kwargs.items() + } + return await _bridge_greenlet_spawn(fn, *args, **kwargs) +''' + + def generate_source(generated_at=None): """Return the generated _async/_generated.py source without writing it.""" # 导入同步类 @@ -485,7 +530,7 @@ def generate_source(generated_at=None): # │ 生成时间: {now} │ # └──────────────────────────────────────────────────────────────────┘ -from .greenlet_bridge import greenlet_spawn +from .greenlet_bridge import greenlet_spawn as _bridge_greenlet_spawn from ._overrides import AsyncFirefoxBaseMixin, AsyncFirefoxElementMixin '''.format(now=generated_at) @@ -624,6 +669,7 @@ def generate_source(generated_at=None): extra_unit_props=element_extra_units, ) ) + parts.append(generate_async_call_helpers()) # 写入文件 return "\n".join(parts) diff --git a/tests/async_smoke/test_async_baseline.py b/tests/async_smoke/test_async_baseline.py index 20f0af1..2dcb922 100644 --- a/tests/async_smoke/test_async_baseline.py +++ b/tests/async_smoke/test_async_baseline.py @@ -423,6 +423,14 @@ def displayed(self): def self_unit(self): return self + class _ArgumentUnit: + def __init__(self): + self.received = None + + def receive(self, *args, **kwargs): + self.received = (args, kwargs) + return self + @pytest.mark.asyncio async def test_page_unit_owner_return_stays_async(self): from ruyipage._async._generated import AsyncFirefoxPage @@ -455,6 +463,78 @@ def __init__(self): assert element.wait._owner is element assert await element.wait() is element + @pytest.mark.asyncio + async def test_unit_proxy_unwraps_async_arguments(self): + from ruyipage._async._generated import AsyncFirefoxElement, AsyncUnitProxy + + sync_element = object() + element = AsyncFirefoxElement(sync_element) + domain_value = type("DomainValue", (), {"_sync": object()})() + sync_unit = self._ArgumentUnit() + unit = AsyncUnitProxy(sync_unit) + + result = await unit.receive( + element, + keyword=element, + passthrough=domain_value, + ) + + assert sync_unit.received == ( + (sync_element,), + {"keyword": sync_element, "passthrough": domain_value}, + ) + assert result is unit + + @pytest.mark.asyncio + async def test_generated_element_method_unwraps_async_target(self): + from ruyipage._async._generated import AsyncFirefoxElement + + class SyncElement: + def __init__(self): + self.received = None + + def drag_to(self, target, duration=0.5): + self.received = (target, duration) + return self + + sync_source = SyncElement() + sync_target = object() + source = AsyncFirefoxElement(sync_source) + target = AsyncFirefoxElement(sync_target) + + result = await source.drag_to(target, duration=0.25) + + assert sync_source.received == (sync_target, 0.25) + assert result is source + + @pytest.mark.asyncio + async def test_generated_page_method_unwraps_wrappers_inside_containers(self): + from ruyipage._async._generated import AsyncFirefoxPage, AsyncFirefoxTab + + class SyncPage: + def __init__(self): + self.received = None + + def close_other_tabs(self, tab_or_ids=None): + self.received = tab_or_ids + + sync_page = SyncPage() + sync_tab = object() + tab = AsyncFirefoxTab(sync_tab) + domain_value = type("DomainValue", (), {"_sync": object()})() + metadata = {"domain": domain_value} + page = AsyncFirefoxPage(sync_page) + + await page.close_other_tabs([tab, metadata]) + + assert sync_page.received == [sync_tab, metadata] + assert sync_page.received[1] is metadata + + await page.close_other_tabs((tab, metadata)) + + assert sync_page.received == (sync_tab, metadata) + assert sync_page.received[1] is metadata + class TestAsyncGeneratedReturnWrapping: """Verify generated wrappers preserve async types for object properties.""" diff --git a/tests/async_smoke/test_async_smoke.py b/tests/async_smoke/test_async_smoke.py index 86e531c..0704761 100644 --- a/tests/async_smoke/test_async_smoke.py +++ b/tests/async_smoke/test_async_smoke.py @@ -113,6 +113,20 @@ async def test_async_click_and_input(async_page, fixture_page_url): # ── 表单控件(对标 test_form_controls.py)────────────────────────────────── +async def test_async_actions_accept_async_element(async_page, fixture_page_url): + await async_page.get(fixture_page_url("basic_form.html")) + + input_el = await async_page.ele("#text-input") + actions = async_page.actions + + assert await actions.move_to(input_el) is actions + assert await actions.click() is actions + assert await actions.type("actions async") is actions + assert await actions.perform() is actions + + assert await input_el.get_value() == "actions async" + + async def test_async_form_controls(async_page, fixture_page_url): """对标 test_form_controls_behave_correctly""" await async_page.get(fixture_page_url("form_controls.html")) @@ -165,6 +179,15 @@ async def test_async_run_js_with_args(async_page): assert result == 7 +async def test_async_run_js_with_async_element_arg(async_page, fixture_page_url): + await async_page.get(fixture_page_url("basic_form.html")) + element = await async_page.ele("#text-input") + + result = await async_page.run_js("(element) => element.id", element) + + assert result == "text-input" + + # ── 截图测试 ────────────────────────────────────────────────────────────── diff --git a/tests/features/test_human_move_retarget.py b/tests/features/test_human_move_retarget.py index 81bb6e3..5b580e7 100644 --- a/tests/features/test_human_move_retarget.py +++ b/tests/features/test_human_move_retarget.py @@ -61,6 +61,68 @@ def test_human_move_re_resolves_element_center_after_scroll(monkeypatch): assert actions.curr_y == 514 +@pytest.mark.asyncio +@pytest.mark.feature +async def test_async_actions_human_move_uses_wrapped_element_center(): + from ruyipage._async._generated import AsyncFirefoxElement, AsyncUnitProxy + + owner = _DummyOwner() + actions = Actions(owner) + sync_element = _DummyElement([(420, 240)], in_viewport=True) + element = AsyncFirefoxElement(sync_element) + async_actions = AsyncUnitProxy(actions) + + result = await async_actions.human_move(element, style="line") + + moves = [a for a in actions._pointer_actions if a.get("type") == "pointerMove"] + assert result is async_actions + assert moves[-1]["x"] == 420 + assert moves[-1]["y"] == 240 + assert actions.curr_x == 420 + assert actions.curr_y == 240 + + +@pytest.mark.asyncio +@pytest.mark.feature +async def test_async_actions_drag_to_unwraps_source_and_target_elements(): + from ruyipage._async._generated import AsyncFirefoxElement, AsyncUnitProxy + + actions = Actions(_DummyOwner()) + source = AsyncFirefoxElement( + _DummyElement([(120, 80)], in_viewport=True) + ) + target = AsyncFirefoxElement( + _DummyElement([(480, 320)], in_viewport=True) + ) + async_actions = AsyncUnitProxy(actions) + + result = await async_actions.drag_to(source, target, steps=4) + + moves = [a for a in actions._pointer_actions if a.get("type") == "pointerMove"] + assert result is async_actions + assert (moves[0]["x"], moves[0]["y"]) == (120, 80) + assert (moves[-1]["x"], moves[-1]["y"]) == (480, 320) + + +@pytest.mark.asyncio +@pytest.mark.feature +async def test_async_touch_move_uses_wrapped_element_center(): + from ruyipage._async._generated import AsyncFirefoxElement, AsyncUnitProxy + from ruyipage._units.touch_actions import TouchActions + + touch = TouchActions(_DummyOwner()) + sync_element = _DummyElement([(360, 180)], in_viewport=True) + element = AsyncFirefoxElement(sync_element) + async_touch = AsyncUnitProxy(touch) + + result = await async_touch.move_to(element) + + assert result is async_touch + assert (touch._x, touch._y) == (360, 180) + assert touch._fingers[0][-1]["x"] == 360 + assert touch._fingers[0][-1]["y"] == 180 + + @pytest.mark.feature def test_human_move_tuple_still_clamps_out_of_viewport_target(): owner = _DummyOwner()