mirror of
https://github.com/TencentCloud/Octop.git
synced 2026-10-20 06:19:17 +08:00
* 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>
140 lines
4.3 KiB
Python
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
|