Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion ruyipage/_async/_generated.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
48 changes: 47 additions & 1 deletion scripts/generate_async_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
# 导入同步类
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
80 changes: 80 additions & 0 deletions tests/async_smoke/test_async_baseline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
23 changes: 23 additions & 0 deletions tests/async_smoke/test_async_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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"


# ── 截图测试 ──────────────────────────────────────────────────────────────


Expand Down
62 changes: 62 additions & 0 deletions tests/features/test_human_move_retarget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading