210 lines
7.7 KiB
Python
210 lines
7.7 KiB
Python
"""File-bridge remote control tests (no Chromium, no sockets).
|
|
|
|
The bridge is four regular files in the worktree because runsc
|
|
containers cannot publish a Unix socket to the host (verified
|
|
2026-10-30). These tests drive RemoteBridge.poll() with a stub
|
|
browser and plain files.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from playwright_remote import (
|
|
RemoteBridge, append_input, effective_holder, read_state,
|
|
validate_input, write_control)
|
|
except ImportError:
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
|
from cloud.playwright_remote import (
|
|
RemoteBridge, append_input, effective_holder, read_state,
|
|
validate_input, write_control)
|
|
|
|
|
|
class _StubBrowser:
|
|
def __init__(self):
|
|
self.calls = []
|
|
self.frame = ""
|
|
|
|
def cached_info(self):
|
|
return {"url": "https://example.com", "title": "T",
|
|
"started": True}
|
|
|
|
def click_at(self, x, y):
|
|
self.calls.append(("click_at", x, y))
|
|
return {"ok": True}
|
|
|
|
def mouse_move(self, x, y):
|
|
self.calls.append(("mouse_move", x, y))
|
|
return {"ok": True}
|
|
|
|
def key_press(self, key):
|
|
self.calls.append(("key", key))
|
|
return {"ok": True}
|
|
|
|
def type_text(self, text):
|
|
self.calls.append(("type", text))
|
|
return {"ok": True}
|
|
|
|
def pump_screencast(self):
|
|
return self.frame
|
|
|
|
|
|
class BridgeTestCase(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.browser = _StubBrowser()
|
|
self.bridge = RemoteBridge(self.browser, self.tmp.name)
|
|
|
|
def tearDown(self):
|
|
self.tmp.cleanup()
|
|
|
|
def test_control_and_input_flow(self):
|
|
# Agent holder: inputs are dropped, not executed.
|
|
append_input(self.tmp.name,
|
|
{"type": "mouse_click", "x": 10, "y": 20})
|
|
self.bridge.poll()
|
|
self.assertEqual(self.browser.calls, [])
|
|
# Takeover via control file, then a fresh input executes.
|
|
write_control(self.tmp.name, "user")
|
|
self.bridge.poll()
|
|
self.assertEqual(self.bridge.get_holder(), "user")
|
|
append_input(self.tmp.name,
|
|
{"type": "mouse_click", "x": 30, "y": 40})
|
|
self.bridge.poll()
|
|
self.assertEqual(self.browser.calls, [("click_at", 30, 40)])
|
|
# Second poll re-executes nothing (offset tracking).
|
|
self.bridge.poll()
|
|
self.assertEqual(self.browser.calls, [("click_at", 30, 40)])
|
|
# Release drops later inputs until the next takeover.
|
|
write_control(self.tmp.name, "agent")
|
|
self.bridge.poll()
|
|
append_input(self.tmp.name, {"type": "key", "key": "Enter"})
|
|
self.bridge.poll()
|
|
self.assertEqual(self.browser.calls, [("click_at", 30, 40)])
|
|
|
|
def test_partial_line_waits(self):
|
|
write_control(self.tmp.name, "user")
|
|
self.bridge.poll()
|
|
with open(os.path.join(self.tmp.name,
|
|
".browser-remote-input.jsonl"),
|
|
"ab") as fh:
|
|
fh.write(b'{"type": "key", "key": "En')
|
|
self.bridge.poll()
|
|
self.assertEqual(self.browser.calls, [])
|
|
with open(os.path.join(self.tmp.name,
|
|
".browser-remote-input.jsonl"),
|
|
"ab") as fh:
|
|
fh.write(b'ter"}\n')
|
|
self.bridge.poll()
|
|
self.assertEqual(self.browser.calls, [("key", "Enter")])
|
|
|
|
def test_state_and_frame_files(self):
|
|
self.browser.frame = "FRAMEBYTES"
|
|
self.bridge.poll()
|
|
state, err = read_state(self.tmp.name)
|
|
self.assertIsNone(err)
|
|
self.assertEqual(state["holder"], "agent")
|
|
self.assertTrue(state["streaming"])
|
|
self.assertGreater(state["updated_at"], time.time() - 5)
|
|
with open(os.path.join(self.tmp.name,
|
|
".browser-remote-frame.jpg"),
|
|
"rb") as fh:
|
|
self.assertEqual(fh.read(), b"FRAMEBYTES")
|
|
|
|
def test_writes_are_change_gated(self):
|
|
"""Unchanged polls must not rewrite the bridge files.
|
|
|
|
The old code rewrote both files every ~50ms iteration
|
|
(~40 atomic temp+replace round-trips per second through
|
|
the runsc gofer mount for identical bytes). Now only
|
|
changed bytes and the heartbeat get written.
|
|
"""
|
|
state_path = os.path.join(
|
|
self.tmp.name, ".browser-remote-state.json")
|
|
frame_path = os.path.join(
|
|
self.tmp.name, ".browser-remote-frame.jpg")
|
|
self.browser.frame = "FRAMEBYTES"
|
|
self.bridge.poll()
|
|
old = time.time() - 100
|
|
os.utime(state_path, (old, old))
|
|
os.utime(frame_path, (old, old))
|
|
# Unchanged second poll: neither file rewritten.
|
|
self.bridge.poll()
|
|
self.assertEqual(os.stat(state_path).st_mtime, old)
|
|
self.assertEqual(os.stat(frame_path).st_mtime, old)
|
|
# Changed frame: rewritten at once.
|
|
self.browser.frame = "NEWBYTES"
|
|
self.bridge.poll()
|
|
with open(frame_path, "rb") as fh:
|
|
self.assertEqual(fh.read(), b"NEWBYTES")
|
|
# Changed holder: state rewritten at once, bypassing
|
|
# the heartbeat throttle.
|
|
os.utime(state_path, (old, old))
|
|
write_control(self.tmp.name, "user")
|
|
self.bridge.poll()
|
|
state, err = read_state(self.tmp.name)
|
|
self.assertIsNone(err)
|
|
self.assertEqual(state["holder"], "user")
|
|
|
|
def test_read_state_missing_and_stale(self):
|
|
state, err = read_state(self.tmp.name)
|
|
self.assertEqual(err[0], 503)
|
|
self.bridge.poll()
|
|
path = os.path.join(self.tmp.name, ".browser-remote-state.json")
|
|
with open(path, "rb") as fh:
|
|
doc = json.loads(fh.read())
|
|
doc["updated_at"] = time.time() - 120
|
|
with open(path, "wb") as fh:
|
|
fh.write(json.dumps(doc).encode())
|
|
state, err = read_state(self.tmp.name)
|
|
self.assertEqual(err[0], 503)
|
|
|
|
def test_effective_holder_control_race(self):
|
|
# State file says agent; a newer control file wins.
|
|
self.bridge.poll()
|
|
state, _ = read_state(self.tmp.name)
|
|
self.assertEqual(effective_holder(self.tmp.name, state),
|
|
"agent")
|
|
# Gitea runner filesystem mtime lags wall clock by ~1ms
|
|
# (measured 0.2-1.0ms in runner container), so a
|
|
# back-to-back write can appear older than updated_at.
|
|
# Sleep BEFORE writing, so the control file is genuinely
|
|
# newer (sleeping after the write does not change its
|
|
# mtime). The test's intent is "newer control wins",
|
|
# not "same-millisecond writes are ordered".
|
|
time.sleep(0.05)
|
|
write_control(self.tmp.name, "user")
|
|
self.assertEqual(effective_holder(self.tmp.name, state),
|
|
"user")
|
|
# Once the MCP polls, the state file catches up and both
|
|
# agree again.
|
|
self.bridge.poll()
|
|
state, _ = read_state(self.tmp.name)
|
|
self.assertEqual(state["holder"], "user")
|
|
self.assertEqual(effective_holder(self.tmp.name, state),
|
|
"user")
|
|
|
|
def test_validate_input(self):
|
|
item, err = validate_input({"type": "mouse_click", "x": 1,
|
|
"y": 2})
|
|
self.assertIsNone(err)
|
|
_, err = validate_input({"type": "mouse_click", "x": 2000,
|
|
"y": 2})
|
|
self.assertEqual(err[0], 400)
|
|
_, err = validate_input({"type": "mouse_click", "x": 1.5,
|
|
"y": 2})
|
|
self.assertEqual(err[0], 400)
|
|
_, err = validate_input({"type": "teleport"})
|
|
self.assertEqual(err[0], 400)
|
|
_, err = validate_input("nope")
|
|
self.assertEqual(err[0], 400)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|