Files
Octop/tests/unit/api/test_browser_stream_mouse.py
006f141d84 Fix/remote browser drag (#50)
* feat(browser): support real remote drag with shared canvas pointer

Forward CDP mousedown/mousemove/mouseup (with clickCount) so remote
browser and desktop can drag instead of pan-to-scroll, and share a
connecting indicator while the stream starts.



* fix(chat): restore loading earlier messages on scroll up

Keep the load-older gate armed across virtual list updates, release
the in-flight latch when history declines to start, and expose a
clickable fallback when the list does not overflow.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(workspace): clarify path semantics with from_workspace

Add from_workspace so workspace UI keeps leading-slash relative paths
while chat/tool downloads can pass host-absolute send_file paths
through BackendWorkspace, with a host download allowlist.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(workspace): preserve chat document path semantics

* fix(workspace): harden file URL and host path checks on Windows

Preserve Unix-style file:// paths instead of url2pathname backslashes,
decode percent-encoded segments, and deny POSIX system roots like /etc
before Path.resolve() on Windows.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(workspace): decode Windows file URLs to native paths

Unquote percent-encoded segments in file:///C:/… URLs and normalize
via Path so send_file enrichment matches str(Path.resolve()) on Windows.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: jubaoliang <jubaoliang@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:26:52 +08:00

140 lines
4.3 KiB
Python

"""Browser WebSocket stream must forward press / move / release for drag."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from octop.api.routers.browser import stream as stream_mod
def _fake_sess() -> SimpleNamespace:
client = SimpleNamespace(send=AsyncMock(return_value={}))
return SimpleNamespace(
click=AsyncMock(),
scroll=AsyncMock(),
type=AsyncMock(),
_internal=SimpleNamespace(client=client),
)
@pytest.mark.asyncio
async def test_mousedown_dispatches_cdp_mouse_pressed() -> None:
sess = _fake_sess()
await stream_mod._handle_client_event(
sess, {"type": "mousedown", "x": 40, "y": 80, "button": "left"}
)
sess._internal.client.send.assert_awaited()
method, params = sess._internal.client.send.await_args.args
assert method == "Input.dispatchMouseEvent"
assert params["type"] == "mousePressed"
assert params["x"] == 40
assert params["y"] == 80
assert params["button"] == "left"
assert params.get("clickCount") == 1
@pytest.mark.asyncio
async def test_mousemove_dispatches_cdp_mouse_moved() -> None:
sess = _fake_sess()
await stream_mod._handle_client_event(
sess, {"type": "mousemove", "x": 55, "y": 90, "button": "left"}
)
method, params = sess._internal.client.send.await_args.args
assert method == "Input.dispatchMouseEvent"
assert params["type"] == "mouseMoved"
assert params["x"] == 55
assert params["y"] == 90
@pytest.mark.asyncio
async def test_mouseup_dispatches_cdp_mouse_released() -> None:
sess = _fake_sess()
await stream_mod._handle_client_event(
sess, {"type": "mouseup", "x": 70, "y": 100, "button": "left"}
)
method, params = sess._internal.client.send.await_args.args
assert method == "Input.dispatchMouseEvent"
assert params["type"] == "mouseReleased"
assert params["x"] == 70
assert params["y"] == 100
assert params["button"] == "left"
@pytest.mark.asyncio
async def test_drag_sequence_preserves_pressed_buttons_on_move() -> None:
sess = _fake_sess()
await stream_mod._handle_client_event(
sess, {"type": "mousedown", "x": 10, "y": 20, "button": "left", "buttons": 1}
)
await stream_mod._handle_client_event(
sess, {"type": "mousemove", "x": 30, "y": 40, "button": "left", "buttons": 1}
)
await stream_mod._handle_client_event(
sess, {"type": "mouseup", "x": 30, "y": 40, "button": "left", "buttons": 0}
)
calls = sess._internal.client.send.await_args_list
assert len(calls) >= 3
pressed = calls[0].args[1]
moved = calls[1].args[1]
released = calls[2].args[1]
assert pressed["type"] == "mousePressed"
assert pressed["buttons"] == 1
assert moved["type"] == "mouseMoved"
assert moved["buttons"] == 1
assert released["type"] == "mouseReleased"
assert released["buttons"] == 0
@pytest.mark.asyncio
async def test_mousemove_hover_uses_buttons_zero() -> None:
sess = _fake_sess()
await stream_mod._handle_client_event(
sess,
{
"type": "mousemove",
"x": 12,
"y": 34,
"button": "none",
"buttons": 0,
},
)
method, params = sess._internal.client.send.await_args.args
assert method == "Input.dispatchMouseEvent"
assert params["type"] == "mouseMoved"
assert params["button"] == "none"
assert params["buttons"] == 0
@pytest.mark.asyncio
async def test_mousedown_forwards_click_count() -> None:
sess = _fake_sess()
await stream_mod._handle_client_event(
sess,
{
"type": "mousedown",
"x": 1,
"y": 2,
"button": "left",
"buttons": 1,
"clickCount": 2,
},
)
_method, params = sess._internal.client.send.await_args.args
assert params["type"] == "mousePressed"
assert params["clickCount"] == 2
@pytest.mark.asyncio
async def test_right_mousedown_uses_right_button_mask() -> None:
sess = _fake_sess()
await stream_mod._handle_client_event(
sess, {"type": "mousedown", "x": 5, "y": 6, "button": "right", "buttons": 2}
)
_method, params = sess._internal.client.send.await_args.args
assert params["type"] == "mousePressed"
assert params["button"] == "right"
assert params["buttons"] == 2