Add see & drive tools: screenshot, keyboard/mouse injection, serial console
This commit is contained in:
parent
08f5e4329e
commit
9d20f28998
@ -28,6 +28,7 @@ claude mcp add mcqemu -- uv run --directory /path/to/mcqemu mcqemu
|
|||||||
| Lifecycle | `launch_vm`, `stop_vm`, `pause_vm`, `resume_vm`, `attach_vm`, `forget_vm` |
|
| Lifecycle | `launch_vm`, `stop_vm`, `pause_vm`, `resume_vm`, `attach_vm`, `forget_vm` |
|
||||||
| Inspect | `list_vms`, `vm_info` |
|
| Inspect | `list_vms`, `vm_info` |
|
||||||
| Live snapshots | `vm_snapshot_create` / `restore` / `delete` / `list` |
|
| Live snapshots | `vm_snapshot_create` / `restore` / `delete` / `list` |
|
||||||
|
| See & drive | `vm_screenshot` (PNG), `vm_send_keys`, `vm_type_text`, `vm_click`, `vm_serial_read` |
|
||||||
| Disk images | `image_create`, `image_info`, `image_convert`, `image_resize`, `image_snapshot_*` |
|
| Disk images | `image_create`, `image_info`, `image_convert`, `image_resize`, `image_snapshot_*` |
|
||||||
| Guest agent | `guest_ping`, `guest_info`, `guest_exec`, `guest_file_read`, `guest_file_write` |
|
| Guest agent | `guest_ping`, `guest_info`, `guest_exec`, `guest_file_read`, `guest_file_write` |
|
||||||
|
|
||||||
|
|||||||
160
src/mcqemu/keymap.py
Normal file
160
src/mcqemu/keymap.py
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
"""US-layout translation from characters and key names to QEMU qcodes.
|
||||||
|
|
||||||
|
QMP's send-key command speaks "qcodes" (QKeyCode enum values). Typing text
|
||||||
|
means mapping each character to a qcode plus an optional shift modifier;
|
||||||
|
key chords like "ctrl-alt-f2" map each part through the alias table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Friendly names -> canonical qcodes.
|
||||||
|
KEY_ALIASES: dict[str, str] = {
|
||||||
|
"enter": "ret",
|
||||||
|
"return": "ret",
|
||||||
|
"space": "spc",
|
||||||
|
"escape": "esc",
|
||||||
|
"del": "delete",
|
||||||
|
"ins": "insert",
|
||||||
|
"pageup": "pgup",
|
||||||
|
"pagedown": "pgdn",
|
||||||
|
"win": "meta_l",
|
||||||
|
"super": "meta_l",
|
||||||
|
"meta": "meta_l",
|
||||||
|
"control": "ctrl",
|
||||||
|
"lctrl": "ctrl",
|
||||||
|
"rctrl": "ctrl_r",
|
||||||
|
"lalt": "alt",
|
||||||
|
"ralt": "alt_r",
|
||||||
|
"lshift": "shift",
|
||||||
|
"rshift": "shift_r",
|
||||||
|
"capslock": "caps_lock",
|
||||||
|
"printscreen": "print",
|
||||||
|
"hyphen": "minus",
|
||||||
|
"dash": "minus",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keys that are already valid qcodes (subset we advertise; QEMU knows more).
|
||||||
|
KNOWN_QCODES = {
|
||||||
|
"ret",
|
||||||
|
"esc",
|
||||||
|
"spc",
|
||||||
|
"tab",
|
||||||
|
"backspace",
|
||||||
|
"delete",
|
||||||
|
"insert",
|
||||||
|
"home",
|
||||||
|
"end",
|
||||||
|
"pgup",
|
||||||
|
"pgdn",
|
||||||
|
"up",
|
||||||
|
"down",
|
||||||
|
"left",
|
||||||
|
"right",
|
||||||
|
"ctrl",
|
||||||
|
"ctrl_r",
|
||||||
|
"alt",
|
||||||
|
"alt_r",
|
||||||
|
"shift",
|
||||||
|
"shift_r",
|
||||||
|
"meta_l",
|
||||||
|
"meta_r",
|
||||||
|
"caps_lock",
|
||||||
|
"num_lock",
|
||||||
|
"scroll_lock",
|
||||||
|
"print",
|
||||||
|
"pause",
|
||||||
|
"minus",
|
||||||
|
"equal",
|
||||||
|
"bracket_left",
|
||||||
|
"bracket_right",
|
||||||
|
"backslash",
|
||||||
|
"semicolon",
|
||||||
|
"apostrophe",
|
||||||
|
"comma",
|
||||||
|
"dot",
|
||||||
|
"slash",
|
||||||
|
"grave_accent",
|
||||||
|
*[f"f{i}" for i in range(1, 13)],
|
||||||
|
*[chr(c) for c in range(ord("a"), ord("z") + 1)],
|
||||||
|
*[str(d) for d in range(10)],
|
||||||
|
}
|
||||||
|
|
||||||
|
# char -> (qcode, needs_shift), US layout.
|
||||||
|
_UNSHIFTED = {
|
||||||
|
" ": "spc",
|
||||||
|
"\t": "tab",
|
||||||
|
"\n": "ret",
|
||||||
|
"-": "minus",
|
||||||
|
"=": "equal",
|
||||||
|
"[": "bracket_left",
|
||||||
|
"]": "bracket_right",
|
||||||
|
"\\": "backslash",
|
||||||
|
";": "semicolon",
|
||||||
|
"'": "apostrophe",
|
||||||
|
",": "comma",
|
||||||
|
".": "dot",
|
||||||
|
"/": "slash",
|
||||||
|
"`": "grave_accent",
|
||||||
|
}
|
||||||
|
_SHIFTED = {
|
||||||
|
"!": "1",
|
||||||
|
"@": "2",
|
||||||
|
"#": "3",
|
||||||
|
"$": "4",
|
||||||
|
"%": "5",
|
||||||
|
"^": "6",
|
||||||
|
"&": "7",
|
||||||
|
"*": "8",
|
||||||
|
"(": "9",
|
||||||
|
")": "0",
|
||||||
|
"_": "minus",
|
||||||
|
"+": "equal",
|
||||||
|
"{": "bracket_left",
|
||||||
|
"}": "bracket_right",
|
||||||
|
"|": "backslash",
|
||||||
|
":": "semicolon",
|
||||||
|
'"': "apostrophe",
|
||||||
|
"<": "comma",
|
||||||
|
">": "dot",
|
||||||
|
"?": "slash",
|
||||||
|
"~": "grave_accent",
|
||||||
|
}
|
||||||
|
|
||||||
|
CHAR_QCODES: dict[str, tuple[str, bool]] = {}
|
||||||
|
for _c in "abcdefghijklmnopqrstuvwxyz0123456789":
|
||||||
|
CHAR_QCODES[_c] = (_c, False)
|
||||||
|
for _c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
|
||||||
|
CHAR_QCODES[_c] = (_c.lower(), True)
|
||||||
|
for _c, _q in _UNSHIFTED.items():
|
||||||
|
CHAR_QCODES[_c] = (_q, False)
|
||||||
|
for _c, _q in _SHIFTED.items():
|
||||||
|
CHAR_QCODES[_c] = (_q, True)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_key(name: str) -> str:
|
||||||
|
"""One key name -> qcode. Raises ValueError for unknown keys."""
|
||||||
|
key = name.strip().lower()
|
||||||
|
key = KEY_ALIASES.get(key, key)
|
||||||
|
if key in KNOWN_QCODES:
|
||||||
|
return key
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown key {name!r}. Use qcode names like 'ret', 'esc', 'tab', 'f1', "
|
||||||
|
"'ctrl', 'alt', letters, digits, or aliases like 'enter'/'space'."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_chord(chord: str) -> list[str]:
|
||||||
|
"""'ctrl-alt-f2' -> ['ctrl', 'alt', 'f2']. A bare '-' means the minus key."""
|
||||||
|
if chord == "-":
|
||||||
|
return ["minus"]
|
||||||
|
return [resolve_key(part) for part in chord.split("-") if part]
|
||||||
|
|
||||||
|
|
||||||
|
def char_to_keys(char: str) -> tuple[str, bool]:
|
||||||
|
"""One character -> (qcode, needs_shift). Raises ValueError if untypeable."""
|
||||||
|
if char in CHAR_QCODES:
|
||||||
|
return CHAR_QCODES[char]
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot type character {char!r} with the US-layout key map "
|
||||||
|
"(only printable ASCII, tab and newline are supported)."
|
||||||
|
)
|
||||||
@ -122,6 +122,9 @@ def build_cmdline(
|
|||||||
"virtio-serial",
|
"virtio-serial",
|
||||||
"-device",
|
"-device",
|
||||||
"virtserialport,chardev=qga0,name=org.qemu.guest_agent.0",
|
"virtserialport,chardev=qga0,name=org.qemu.guest_agent.0",
|
||||||
|
# Absolute-coordinate pointer so vm_click can target exact pixels.
|
||||||
|
"-device",
|
||||||
|
"virtio-tablet-pci",
|
||||||
"-daemonize",
|
"-daemonize",
|
||||||
"-pidfile",
|
"-pidfile",
|
||||||
str(paths.pidfile),
|
str(paths.pidfile),
|
||||||
|
|||||||
@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
from . import guest, images, lifecycle, query, snapshots
|
from . import display, guest, images, lifecycle, query, snapshots
|
||||||
|
|
||||||
|
|
||||||
def register_all(mcp: FastMCP) -> None:
|
def register_all(mcp: FastMCP) -> None:
|
||||||
for module in (lifecycle, query, snapshots, images, guest):
|
for module in (lifecycle, query, snapshots, images, guest, display):
|
||||||
module.register(mcp)
|
module.register(mcp)
|
||||||
|
|||||||
171
src/mcqemu/tools/display.py
Normal file
171
src/mcqemu/tools/display.py
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
"""See & drive: screenshots, keyboard/mouse injection, serial console."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import struct
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastmcp import Context, FastMCP
|
||||||
|
from fastmcp.exceptions import ToolError
|
||||||
|
from fastmcp.utilities.types import Image
|
||||||
|
|
||||||
|
from ..config import Config
|
||||||
|
from ..keymap import char_to_keys, parse_chord
|
||||||
|
from ..qmp import execute, qmp_session
|
||||||
|
from ._common import require_vm
|
||||||
|
|
||||||
|
_ABS_MAX = 32767 # QEMU absolute-pointer coordinate space
|
||||||
|
|
||||||
|
|
||||||
|
def _png_dimensions(data: bytes) -> tuple[int, int]:
|
||||||
|
if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n":
|
||||||
|
raise ToolError("screendump did not produce a valid PNG — is a display device present?")
|
||||||
|
width, height = struct.unpack(">II", data[16:24])
|
||||||
|
return width, height
|
||||||
|
|
||||||
|
|
||||||
|
async def _screendump(ctx: Context, name: str) -> bytes:
|
||||||
|
state, record = require_vm(ctx, name)
|
||||||
|
config: Config = state.config
|
||||||
|
dest = config.vm_state_dir(name) / "screenshot.png"
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
async with qmp_session(record) as client:
|
||||||
|
await execute(client, "screendump", {"filename": str(dest), "format": "png"})
|
||||||
|
try:
|
||||||
|
return dest.read_bytes()
|
||||||
|
except OSError as e:
|
||||||
|
raise ToolError(f"screendump reported success but {dest} is unreadable: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
async def vm_screenshot(name: str, ctx: Context = None) -> Image:
|
||||||
|
"""Capture the VM's current display as a PNG image. Works on any running
|
||||||
|
VM with a display device (the default for launched x86 VMs) — no guest
|
||||||
|
software needed. Use this to watch installers, read console output, or
|
||||||
|
verify GUI state before sending keys with vm_send_keys / vm_type_text."""
|
||||||
|
data = await _screendump(ctx, name)
|
||||||
|
return Image(data=data, format="png")
|
||||||
|
|
||||||
|
|
||||||
|
async def vm_send_keys(
|
||||||
|
name: str, keys: list[str], hold_ms: int = 100, delay_ms: int = 50, ctx: Context = None
|
||||||
|
) -> dict:
|
||||||
|
"""Press keys or key combos in the VM. Each list entry is one press: a
|
||||||
|
single key ("ret", "esc", "f2", "a") or a chord pressed together
|
||||||
|
("ctrl-alt-f2", "ctrl-c"). Entries are sent in order with delay_ms between
|
||||||
|
them. Aliases like "enter", "space", "escape" work. To type prose, use
|
||||||
|
vm_type_text instead."""
|
||||||
|
_, record = require_vm(ctx, name)
|
||||||
|
chords = []
|
||||||
|
for entry in keys:
|
||||||
|
try:
|
||||||
|
chords.append(parse_chord(entry))
|
||||||
|
except ValueError as e:
|
||||||
|
raise ToolError(str(e)) from e
|
||||||
|
|
||||||
|
async with qmp_session(record) as client:
|
||||||
|
for chord in chords:
|
||||||
|
await execute(
|
||||||
|
client,
|
||||||
|
"send-key",
|
||||||
|
{
|
||||||
|
"keys": [{"type": "qcode", "data": q} for q in chord],
|
||||||
|
"hold-time": hold_ms,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay_ms / 1000)
|
||||||
|
return {"name": name, "keys_sent": len(chords)}
|
||||||
|
|
||||||
|
|
||||||
|
async def vm_type_text(
|
||||||
|
name: str, text: str, enter: bool = False, delay_ms: int = 30, ctx: Context = None
|
||||||
|
) -> dict:
|
||||||
|
"""Type a string into the VM, character by character (US keyboard layout;
|
||||||
|
printable ASCII plus tab and newline). Set enter=True to press Enter at
|
||||||
|
the end — handy for shell commands at a console login or terminal."""
|
||||||
|
_, record = require_vm(ctx, name)
|
||||||
|
presses: list[list[str]] = []
|
||||||
|
for char in text:
|
||||||
|
try:
|
||||||
|
qcode, shifted = char_to_keys(char)
|
||||||
|
except ValueError as e:
|
||||||
|
raise ToolError(str(e)) from e
|
||||||
|
presses.append(["shift", qcode] if shifted else [qcode])
|
||||||
|
if enter:
|
||||||
|
presses.append(["ret"])
|
||||||
|
|
||||||
|
async with qmp_session(record) as client:
|
||||||
|
for chord in presses:
|
||||||
|
await execute(
|
||||||
|
client,
|
||||||
|
"send-key",
|
||||||
|
{"keys": [{"type": "qcode", "data": q} for q in chord], "hold-time": 60},
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay_ms / 1000)
|
||||||
|
return {"name": name, "characters_typed": len(presses)}
|
||||||
|
|
||||||
|
|
||||||
|
async def vm_click(
|
||||||
|
name: str, x: int, y: int, button: str = "left", double: bool = False, ctx: Context = None
|
||||||
|
) -> dict:
|
||||||
|
"""Click at pixel coordinates (x, y) on the VM display — coordinates match
|
||||||
|
what vm_screenshot shows. Requires the VM's tablet device for absolute
|
||||||
|
positioning (present on VMs launched by this server). button: left,
|
||||||
|
right, or middle."""
|
||||||
|
if button not in ("left", "right", "middle"):
|
||||||
|
raise ToolError(f"Unsupported button {button!r}: use left, right, or middle.")
|
||||||
|
# Screenshot first: it proves a display exists and gives us the resolution
|
||||||
|
# to scale pixel coords into QEMU's 0-32767 absolute space.
|
||||||
|
data = await _screendump(ctx, name)
|
||||||
|
width, height = _png_dimensions(data)
|
||||||
|
if not (0 <= x < width and 0 <= y < height):
|
||||||
|
raise ToolError(f"({x}, {y}) is outside the {width}x{height} display.")
|
||||||
|
_, record = require_vm(ctx, name)
|
||||||
|
abs_x = int(x * _ABS_MAX / max(width - 1, 1))
|
||||||
|
abs_y = int(y * _ABS_MAX / max(height - 1, 1))
|
||||||
|
|
||||||
|
async with qmp_session(record) as client:
|
||||||
|
await execute(
|
||||||
|
client,
|
||||||
|
"input-send-event",
|
||||||
|
{
|
||||||
|
"events": [
|
||||||
|
{"type": "abs", "data": {"axis": "x", "value": abs_x}},
|
||||||
|
{"type": "abs", "data": {"axis": "y", "value": abs_y}},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
clicks = 2 if double else 1
|
||||||
|
for _ in range(clicks):
|
||||||
|
for down in (True, False):
|
||||||
|
await execute(
|
||||||
|
client,
|
||||||
|
"input-send-event",
|
||||||
|
{"events": [{"type": "btn", "data": {"down": down, "button": button}}]},
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
return {"name": name, "clicked": f"{button} at ({x}, {y})", "double": double}
|
||||||
|
|
||||||
|
|
||||||
|
async def vm_serial_read(name: str, tail_lines: int = 50, ctx: Context = None) -> dict:
|
||||||
|
"""Read the last lines of the VM's serial console log. Only useful when
|
||||||
|
the guest writes to its serial port (kernel console=ttyS0, or text-mode
|
||||||
|
installers); GUI-only guests log nothing here — use vm_screenshot for
|
||||||
|
those."""
|
||||||
|
_, record = require_vm(ctx, name)
|
||||||
|
if not record.serial_log:
|
||||||
|
raise ToolError(
|
||||||
|
f"VM {name!r} has no serial log registered (attached VMs manage their own serial)."
|
||||||
|
)
|
||||||
|
path = Path(record.serial_log)
|
||||||
|
if not path.exists():
|
||||||
|
raise ToolError(f"Serial log {path} does not exist yet.")
|
||||||
|
lines = path.read_text(errors="replace").splitlines()
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"total_lines": len(lines),
|
||||||
|
"tail": "\n".join(lines[-tail_lines:]) if lines else "(serial log is empty)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def register(mcp: FastMCP) -> None:
|
||||||
|
for fn in (vm_screenshot, vm_send_keys, vm_type_text, vm_click, vm_serial_read):
|
||||||
|
mcp.tool(fn)
|
||||||
121
tests/test_display.py
Normal file
121
tests/test_display.py
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
"""See & drive tools with fake QMP; screendump writes a synthetic PNG."""
|
||||||
|
|
||||||
|
import struct
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastmcp import Client
|
||||||
|
from fastmcp.exceptions import ToolError
|
||||||
|
|
||||||
|
from conftest import FakeQMPClient, result_data, write_registry
|
||||||
|
from mcqemu.server import mcp
|
||||||
|
from test_lifecycle import seeded_record
|
||||||
|
|
||||||
|
|
||||||
|
def fake_png(width: int, height: int) -> bytes:
|
||||||
|
"""Just enough PNG for signature + IHDR dimension parsing."""
|
||||||
|
return (
|
||||||
|
b"\x89PNG\r\n\x1a\n"
|
||||||
|
+ b"\x00\x00\x00\x0dIHDR"
|
||||||
|
+ struct.pack(">II", width, height)
|
||||||
|
+ b"\x08\x06\x00\x00\x00"
|
||||||
|
+ b"\x00" * 16
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def arm_screendump(width=640, height=480):
|
||||||
|
def handler(args):
|
||||||
|
with open(args["filename"], "wb") as f:
|
||||||
|
f.write(fake_png(width, height))
|
||||||
|
return {}
|
||||||
|
|
||||||
|
FakeQMPClient.responses["screendump"] = handler
|
||||||
|
|
||||||
|
|
||||||
|
async def test_screenshot_returns_png_image(dirs, fake_qmp):
|
||||||
|
arm_screendump()
|
||||||
|
write_registry(dirs, seeded_record(dirs, "vm1"))
|
||||||
|
async with Client(mcp) as client:
|
||||||
|
result = await client.call_tool("vm_screenshot", {"name": "vm1"})
|
||||||
|
block = result.content[0]
|
||||||
|
assert block.type == "image"
|
||||||
|
assert block.mimeType == "image/png"
|
||||||
|
call = next(a for c, a in FakeQMPClient.calls if c == "screendump")
|
||||||
|
assert call["format"] == "png"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_send_keys_chords(dirs, fake_qmp):
|
||||||
|
FakeQMPClient.responses["send-key"] = {}
|
||||||
|
write_registry(dirs, seeded_record(dirs, "vm1"))
|
||||||
|
async with Client(mcp) as client:
|
||||||
|
data = result_data(
|
||||||
|
await client.call_tool(
|
||||||
|
"vm_send_keys", {"name": "vm1", "keys": ["ctrl-alt-f2", "enter"], "delay_ms": 0}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert data["keys_sent"] == 2
|
||||||
|
sent = [a["keys"] for c, a in FakeQMPClient.calls if c == "send-key"]
|
||||||
|
assert sent[0] == [
|
||||||
|
{"type": "qcode", "data": "ctrl"},
|
||||||
|
{"type": "qcode", "data": "alt"},
|
||||||
|
{"type": "qcode", "data": "f2"},
|
||||||
|
]
|
||||||
|
assert sent[1] == [{"type": "qcode", "data": "ret"}]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_send_keys_unknown_key(dirs, fake_qmp):
|
||||||
|
write_registry(dirs, seeded_record(dirs, "vm1"))
|
||||||
|
async with Client(mcp) as client:
|
||||||
|
with pytest.raises(ToolError, match="Unknown key"):
|
||||||
|
await client.call_tool("vm_send_keys", {"name": "vm1", "keys": ["warpdrive"]})
|
||||||
|
|
||||||
|
|
||||||
|
async def test_type_text_with_shift_and_enter(dirs, fake_qmp):
|
||||||
|
FakeQMPClient.responses["send-key"] = {}
|
||||||
|
write_registry(dirs, seeded_record(dirs, "vm1"))
|
||||||
|
async with Client(mcp) as client:
|
||||||
|
data = result_data(
|
||||||
|
await client.call_tool(
|
||||||
|
"vm_type_text", {"name": "vm1", "text": "Hi!", "enter": True, "delay_ms": 0}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert data["characters_typed"] == 4 # H, i, !, Enter
|
||||||
|
sent = [a["keys"] for c, a in FakeQMPClient.calls if c == "send-key"]
|
||||||
|
assert sent[0] == [{"type": "qcode", "data": "shift"}, {"type": "qcode", "data": "h"}]
|
||||||
|
assert sent[1] == [{"type": "qcode", "data": "i"}]
|
||||||
|
assert sent[2] == [{"type": "qcode", "data": "shift"}, {"type": "qcode", "data": "1"}]
|
||||||
|
assert sent[3] == [{"type": "qcode", "data": "ret"}]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_click_scales_to_abs_space(dirs, fake_qmp):
|
||||||
|
arm_screendump(width=640, height=480)
|
||||||
|
FakeQMPClient.responses["input-send-event"] = {}
|
||||||
|
write_registry(dirs, seeded_record(dirs, "vm1"))
|
||||||
|
async with Client(mcp) as client:
|
||||||
|
data = result_data(await client.call_tool("vm_click", {"name": "vm1", "x": 320, "y": 240}))
|
||||||
|
assert "left at (320, 240)" in data["clicked"]
|
||||||
|
events = [a["events"] for c, a in FakeQMPClient.calls if c == "input-send-event"]
|
||||||
|
move = events[0]
|
||||||
|
assert move[0]["data"] == {"axis": "x", "value": int(320 * 32767 / 639)}
|
||||||
|
assert move[1]["data"] == {"axis": "y", "value": int(240 * 32767 / 479)}
|
||||||
|
assert events[1][0]["data"] == {"down": True, "button": "left"}
|
||||||
|
assert events[2][0]["data"] == {"down": False, "button": "left"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_click_outside_display_rejected(dirs, fake_qmp):
|
||||||
|
arm_screendump(width=640, height=480)
|
||||||
|
write_registry(dirs, seeded_record(dirs, "vm1"))
|
||||||
|
async with Client(mcp) as client:
|
||||||
|
with pytest.raises(ToolError, match="outside"):
|
||||||
|
await client.call_tool("vm_click", {"name": "vm1", "x": 9999, "y": 10})
|
||||||
|
|
||||||
|
|
||||||
|
async def test_serial_read_tail(dirs, fake_qmp):
|
||||||
|
record = seeded_record(dirs, "vm1", serial_log=str(dirs.state / "serial.log"))
|
||||||
|
(dirs.state / "serial.log").write_text("\n".join(f"line{i}" for i in range(100)))
|
||||||
|
write_registry(dirs, record)
|
||||||
|
async with Client(mcp) as client:
|
||||||
|
data = result_data(
|
||||||
|
await client.call_tool("vm_serial_read", {"name": "vm1", "tail_lines": 3})
|
||||||
|
)
|
||||||
|
assert data["total_lines"] == 100
|
||||||
|
assert data["tail"] == "line97\nline98\nline99"
|
||||||
@ -3,6 +3,7 @@
|
|||||||
Run with: uv run pytest -m integration
|
Run with: uv run pytest -m integration
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@ -46,6 +47,20 @@ async def test_real_vm_lifecycle(dirs):
|
|||||||
|
|
||||||
await client.call_tool("resume_vm", {"name": "itest"})
|
await client.call_tool("resume_vm", {"name": "itest"})
|
||||||
|
|
||||||
|
# See & drive against the live SeaBIOS display.
|
||||||
|
result = await client.call_tool("vm_screenshot", {"name": "itest"})
|
||||||
|
block = result.content[0]
|
||||||
|
assert block.type == "image"
|
||||||
|
png = base64.b64decode(block.data)
|
||||||
|
assert png[:8] == b"\x89PNG\r\n\x1a\n"
|
||||||
|
|
||||||
|
await client.call_tool(
|
||||||
|
"vm_send_keys", {"name": "itest", "keys": ["f2", "enter"], "delay_ms": 0}
|
||||||
|
)
|
||||||
|
await client.call_tool("vm_click", {"name": "itest", "x": 10, "y": 10})
|
||||||
|
data = result_data(await client.call_tool("vm_serial_read", {"name": "itest"}))
|
||||||
|
assert "tail" in data
|
||||||
|
|
||||||
data = result_data(await client.call_tool("stop_vm", {"name": "itest", "force": True}))
|
data = result_data(await client.call_tool("stop_vm", {"name": "itest", "force": True}))
|
||||||
assert data["status"] == "stopped"
|
assert data["status"] == "stopped"
|
||||||
assert not pid_alive(pid)
|
assert not pid_alive(pid)
|
||||||
|
|||||||
51
tests/test_keymap.py
Normal file
51
tests/test_keymap.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
"""Key/character translation tables."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mcqemu.keymap import char_to_keys, parse_chord, resolve_key
|
||||||
|
|
||||||
|
|
||||||
|
def test_aliases():
|
||||||
|
assert resolve_key("enter") == "ret"
|
||||||
|
assert resolve_key("Escape") == "esc"
|
||||||
|
assert resolve_key("space") == "spc"
|
||||||
|
assert resolve_key("win") == "meta_l"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_qcodes_pass_through():
|
||||||
|
for key in ("ret", "f11", "a", "9", "ctrl", "grave_accent"):
|
||||||
|
assert resolve_key(key) == key
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_key_raises():
|
||||||
|
with pytest.raises(ValueError, match="Unknown key"):
|
||||||
|
resolve_key("hyperspace")
|
||||||
|
|
||||||
|
|
||||||
|
def test_chords():
|
||||||
|
assert parse_chord("ctrl-alt-f2") == ["ctrl", "alt", "f2"]
|
||||||
|
assert parse_chord("ctrl-c") == ["ctrl", "c"]
|
||||||
|
assert parse_chord("-") == ["minus"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_char_lowercase():
|
||||||
|
assert char_to_keys("a") == ("a", False)
|
||||||
|
assert char_to_keys("5") == ("5", False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_char_shifted():
|
||||||
|
assert char_to_keys("A") == ("a", True)
|
||||||
|
assert char_to_keys("!") == ("1", True)
|
||||||
|
assert char_to_keys("_") == ("minus", True)
|
||||||
|
assert char_to_keys('"') == ("apostrophe", True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_char_specials():
|
||||||
|
assert char_to_keys(" ") == ("spc", False)
|
||||||
|
assert char_to_keys("\n") == ("ret", False)
|
||||||
|
assert char_to_keys("/") == ("slash", False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_untypeable_char_raises():
|
||||||
|
with pytest.raises(ValueError, match="Cannot type"):
|
||||||
|
char_to_keys("é")
|
||||||
@ -36,6 +36,7 @@ def test_defaults_kvm():
|
|||||||
assert "-daemonize" in s
|
assert "-daemonize" in s
|
||||||
assert "-qmp unix:/run/t/qmp.sock,server=on,wait=off" in s
|
assert "-qmp unix:/run/t/qmp.sock,server=on,wait=off" in s
|
||||||
assert "org.qemu.guest_agent.0" in s
|
assert "org.qemu.guest_agent.0" in s
|
||||||
|
assert "-device virtio-tablet-pci" in s
|
||||||
assert "-netdev user,id=net0 -device virtio-net-pci,netdev=net0" in s
|
assert "-netdev user,id=net0 -device virtio-net-pci,netdev=net0" in s
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user