ref:82c2d38e780dc9652bdc9914e9afa0b6f9b7ca66

test(e2e): check that the guest receives Moonlight keyboard, mouse and touch input

E2E_INPUT=absolute|relative|touch boots the Linux test guest with virtio-tablet + virtio-keyboard, PS/2 (vmport=off) or virtio-multitouch (run_vm.sh VM_INPUT). After 30 frames the client sends "a", Shift+"A", mouse position/relative moves, a left click and a wheel step, or a touch down/move/up, through moonlight-common-c. check_guest_input.py then requires the matching evdev events in the guest's serial log: key codes in order, ABS_X/ABS_Y or REL_X/REL_Y totals, BTN_LEFT, REL_WHEEL, and ABS_MT positions with the tracking id released. Refs #5 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SHA: 82c2d38e780dc9652bdc9914e9afa0b6f9b7ca66
Author: Cole Christensen <cole.christensen@gmail.com>
Date: 2026-09-13 00:42
Parents: b5ae5cd
6 files changed +367 -14
Type
tests/e2e/moonlight_client/main.cpp +110 −2
@@ -18,6 +18,11 @@
* With `--audio-freq`, the client also decodes the Opus audio stream and requires
* `--audio-seconds` of audio whose dominant frequency, in each channel, is within
* `--audio-tolerance` Hz of the expected one.
*
* With `--input absolute|relative|touch`, the client sends a fixed input sequence once
* `--input-after-frames` frames are decoded and keeps streaming `--input-settle-ms` longer, so the
* events reach the guest. The sequence is printed as `e2e: input ...` lines; the test script checks
* what the guest logged (see `tests/e2e/qemu/check_guest_input.py`).
*/
// standard includes
#include <algorithm>
@@ -81,6 +86,9 @@
int audio_freq {0}; ///< Expected dominant audio frequency in hertz; 0 skips the audio check.
int audio_tolerance {10}; ///< Allowed difference from `audio_freq` in hertz.
int audio_seconds_x10 {20}; ///< Decoded audio required for the check, in tenths of a second.
std::string input; ///< Input sequence to send: "absolute", "relative", "touch", or empty for none.
int input_after_frames {30}; ///< Decoded frames before the input is sent.
int input_settle_ms {2000}; ///< Time to keep streaming after the input was sent.
};
/**
@@ -381,6 +389,71 @@
}
/**
* @brief Report the result of one input call.
*
* @param what Description for the log.
* @param result moonlight-common-c return value.
* @param errors Incremented when the call failed.
*/
void input_step(const char *what, int result, int &errors) {
std::fprintf(stderr, "e2e: input %s -> %d\n", what, result);
if (result != 0) {
errors += 1;
}
std::this_thread::sleep_for(40ms);
}
/**
* @brief Send the input sequence the guest input test expects.
* @details Every mode types "a" and Shift+"A" (VK_LSHIFT held with the modifier flag), except touch.
* - absolute: mouse position to the stream center, a relative move of (+100, +50), a left click
* and one wheel step up.
* - relative: three relative moves of (+40, -25), a left click and one wheel step up.
* - touch: a contact down at (0.25, 0.75), moved to (0.5, 0.5), then lifted.
*
* @param mode Sequence to send.
* @param width Stream width, the reference size for positions.
* @param height Stream height.
* @return Number of calls moonlight-common-c rejected.
*/
int send_input(const std::string &mode, int width, int height) {
int errors = 0;
if (mode == "touch") {
input_step("touch down 0.25,0.75", LiSendTouchEvent(LI_TOUCH_EVENT_DOWN, 1, 0.25f, 0.75f, 1.0f, 0.0f, 0.0f, LI_ROT_UNKNOWN), errors);
std::this_thread::sleep_for(100ms);
input_step("touch move 0.5,0.5", LiSendTouchEvent(LI_TOUCH_EVENT_MOVE, 1, 0.5f, 0.5f, 1.0f, 0.0f, 0.0f, LI_ROT_UNKNOWN), errors);
std::this_thread::sleep_for(100ms);
input_step("touch up", LiSendTouchEvent(LI_TOUCH_EVENT_UP, 1, 0.5f, 0.5f, 0.0f, 0.0f, 0.0f, LI_ROT_UNKNOWN), errors);
return errors;
}
// Moonlight clients set the high bit on virtual-key codes; Sunshine uses the low byte
constexpr short vk_a = (short) 0x8041;
constexpr short vk_lshift = (short) 0x80A0;
input_step("key a down", LiSendKeyboardEvent(vk_a, KEY_ACTION_DOWN, 0), errors);
input_step("key a up", LiSendKeyboardEvent(vk_a, KEY_ACTION_UP, 0), errors);
input_step("key lshift down", LiSendKeyboardEvent(vk_lshift, KEY_ACTION_DOWN, MODIFIER_SHIFT), errors);
input_step("key a down (shift)", LiSendKeyboardEvent(vk_a, KEY_ACTION_DOWN, MODIFIER_SHIFT), errors);
input_step("key a up (shift)", LiSendKeyboardEvent(vk_a, KEY_ACTION_UP, MODIFIER_SHIFT), errors);
input_step("key lshift up", LiSendKeyboardEvent(vk_lshift, KEY_ACTION_UP, 0), errors);
if (mode == "absolute") {
input_step("mouse position center", LiSendMousePositionEvent((short) (width / 2), (short) (height / 2), (short) width, (short) height), errors);
std::this_thread::sleep_for(100ms);
input_step("mouse move +100,+50", LiSendMouseMoveEvent(100, 50), errors);
} else {
for (int i = 0; i < 3; ++i) {
input_step("mouse move +40,-25", LiSendMouseMoveEvent(40, -25), errors);
}
}
std::this_thread::sleep_for(100ms);
input_step("left button press", LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_LEFT), errors);
input_step("left button release", LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_LEFT), errors);
input_step("scroll up one step", LiSendScrollEvent(1), errors);
return errors;
}
/**
* @brief Parse "r,g,b;r,g,b;r,g,b;r,g,b".
*
* @param text Expectation text.
@@ -548,6 +621,7 @@
{"--summary", &opts.summary},
{"--ready-file", &opts.ready_file},
{"--wait-file", &opts.wait_file},
{"--input", &opts.input},
};
std::map<std::string, int *> ints {
{"--port", &opts.port},
@@ -562,6 +636,8 @@
{"--audio-freq", &opts.audio_freq},
{"--audio-tolerance", &opts.audio_tolerance},
{"--audio-seconds-x10", &opts.audio_seconds_x10},
{"--input-after-frames", &opts.input_after_frames},
{"--input-settle-ms", &opts.input_settle_ms},
};
for (int i = 1; i < argc; ++i) {
std::string arg = argv[i];
@@ -588,6 +664,10 @@
return 2;
}
const auto opts = *parsed;
if (!opts.input.empty() && opts.input != "absolute" && opts.input != "relative" && opts.input != "touch") {
std::fprintf(stderr, "e2e: bad --input value\n");
return 2;
}
std::optional<std::vector<std::array<int, 3>>> expected;
if (!opts.expect.empty()) {
@@ -704,6 +784,10 @@
int changes_at_wait = 0;
std::optional<std::chrono::steady_clock::time_point> wait_start;
std::vector<std::array<int, 3>> last_colors;
std::thread input_thread;
std::atomic<int> input_errors {0};
std::atomic<bool> input_sent {false};
std::optional<std::chrono::steady_clock::time_point> input_done;
const auto deadline = stream_start + std::chrono::seconds(opts.timeout_s);
while (std::chrono::steady_clock::now() < deadline && !state.terminated) {
std::this_thread::sleep_for(100ms);
@@ -713,6 +797,22 @@
ready_written = true;
std::fprintf(stderr, "e2e: ready after %d frames\n", state.decoded);
}
if (!opts.input.empty()) {
// the decoder callbacks need state.mutex, so the sequence runs on its own thread
if (!input_thread.joinable() && state.decoded >= opts.input_after_frames) {
std::fprintf(stderr, "e2e: sending %s input after %d frames\n", opts.input.c_str(), state.decoded);
input_thread = std::thread([&]() {
input_errors = send_input(opts.input, opts.width, opts.height);
input_sent = true;
});
}
if (input_sent && !input_done) {
input_done = std::chrono::steady_clock::now();
}
if (!input_done || std::chrono::steady_clock::now() - *input_done < std::chrono::milliseconds(opts.input_settle_ms)) {
continue;
}
}
if (!opts.wait_file.empty() && !frames_at_wait) {
if (!std::ifstream {opts.wait_file}) {
continue;
@@ -741,5 +841,12 @@
const auto elapsed = std::chrono::duration<double>(std::chrono::steady_clock::now() - stream_start).count();
write_ppm(opts.out);
if (input_thread.joinable()) {
input_thread.join();
}
const bool input_matched = opts.input.empty() || (input_sent && input_errors == 0);
if (!input_matched) {
std::fprintf(stderr, "e2e: input not sent completely: sent=%d, %d rejected call(s)\n", (int) input_sent.load(), input_errors.load());
}
LiStopConnection();
client.cancel();
@@ -796,11 +903,12 @@
for (std::size_t i = 0; i < audio_result.dominant_hz.size(); ++i) {
summary << (i ? "," : "") << audio_result.dominant_hz[i];
}
summary << "],\"rms\":" << audio_result.rms << ",\"silent_blocks\":" << audio_result.silent_blocks << ",\"blocks\":" << audio_result.blocks << "}";
summary << ",\"input\":{\"mode\":\"" << opts.input << "\",\"sent\":" << (input_sent ? "true" : "false") << ",\"rejected_calls\":" << input_errors << "}}";
summary << "],\"rms\":" << audio_result.rms << ",\"silent_blocks\":" << audio_result.silent_blocks << ",\"blocks\":" << audio_result.blocks << "}}";
std::printf("%s\n", summary.str().c_str());
if (!opts.summary.empty()) {
std::ofstream {opts.summary} << summary.str() << "\n";
}
return matched && audio_matched && input_matched ? 0 : 1;
return matched && audio_matched ? 0 : 1;
}
tests/e2e/qemu/check_guest_input.py +152 −0
@@ -1,0 +1,152 @@
#!/usr/bin/env python3
# @tag requirements: [REQ-INP-001, REQ-INP-002, REQ-INP-003]
"""Check that the Linux test guest logged the input the E2E client sent (tests/e2e/qemu/e2e_stream.sh).
The guest writes one "SQGUEST EVDEV" line per evdev event to its serial console (see
guest/linux/README.md). The client's sequences are in moonlight_client/main.cpp (send_input):
- absolute (virtio-tablet): "a", Shift+"A", mouse position at the stream center, a relative move
of (+100, +50) that Sunshine turns into an absolute position, a left click, one wheel step up
- relative (PS/2 mouse): "a", Shift+"A", three relative moves of (+40, -25), a left click, one
wheel step up
- touch (virtio-multitouch): a contact down at (0.25, 0.75), moved to (0.5, 0.5), lifted
Usage: check_guest_input.py <serial.log> <mode> --stream WxH --console WxH
Prints a JSON summary and exits 0 when every check passed.
"""
import argparse
import json
import re
import sys
EVDEV = re.compile(
r"^SQGUEST EVDEV dev=(event[0-9]+) time=([0-9]+)\.([0-9]{6}) type=([0-9]+) code=([0-9]+) value=(-?[0-9]+) name=(.*)$"
)
EV_KEY, EV_REL, EV_ABS = 1, 2, 3
KEY_A, KEY_LEFTSHIFT, BTN_LEFT, BTN_TOUCH = 30, 42, 272, 330
REL_X, REL_Y, REL_WHEEL = 0, 1, 8
ABS_X, ABS_Y, ABS_MT_POSITION_X, ABS_MT_POSITION_Y, ABS_MT_TRACKING_ID = 0, 1, 53, 54, 57
ABS_MAX = 32767 # QEMU scales absolute and multi-touch positions to 0..0x7fff
ABS_TOLERANCE = 64 # a few console pixels
def parse_size(text):
"""Parse WxH."""
width, height = text.lower().split("x")
return int(width), int(height)
def read_events(path):
"""Read the guest's evdev events, ordered by kernel timestamp."""
events = []
with open(path, "rb") as log:
for raw in log:
match = EVDEV.match(raw.decode("utf-8", "replace").rstrip("\n"))
if match:
dev, sec, usec, typ, code, value, name = match.groups()
events.append(
{
"time": int(sec) * 1000000 + int(usec),
"dev": dev,
"type": int(typ),
"code": int(code),
"value": int(value),
"name": name,
}
)
# one logger per device: lines of different devices can interleave out of order
events.sort(key=lambda e: e["time"])
return events
def values(events, typ, code):
"""Values of all events with a type and code, in order."""
return [e["value"] for e in events if e["type"] == typ and e["code"] == code]
def near(value, expected):
"""Whether an absolute axis value is within tolerance."""
return abs(value - expected) <= ABS_TOLERANCE
def check_keys(events, checks):
"""'a' then Shift+'A': press/release of KEY_A, then shift held around another KEY_A."""
keys = [(e["code"], e["value"]) for e in events if e["type"] == EV_KEY and e["code"] in (KEY_A, KEY_LEFTSHIFT) and e["value"] in (0, 1)]
expected = [(KEY_A, 1), (KEY_A, 0), (KEY_LEFTSHIFT, 1), (KEY_A, 1), (KEY_A, 0), (KEY_LEFTSHIFT, 0)]
devices = sorted({e["name"] for e in events if e["type"] == EV_KEY and e["code"] in (KEY_A, KEY_LEFTSHIFT)})
checks["keys"] = {"ok": keys == expected, "got": keys, "devices": devices}
def check_click_and_wheel(events, checks):
"""One left click and one wheel step up."""
buttons = values(events, EV_KEY, BTN_LEFT)
checks["left_click"] = {"ok": buttons == [1, 0], "got": buttons}
wheel = values(events, EV_REL, REL_WHEEL)
checks["wheel_up"] = {"ok": wheel == [1], "got": wheel}
def abs_value(position, size):
"""Axis value QEMU reports for a console position (qemu_input_scale_axis)."""
return position * ABS_MAX // size
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("serial_log")
parser.add_argument("mode", choices=["absolute", "relative", "touch"])
parser.add_argument("--stream", type=parse_size, required=True, help="stream size WxH")
parser.add_argument("--console", type=parse_size, required=True, help="guest console size WxH")
args = parser.parse_args()
events = read_events(args.serial_log)
stream_w, stream_h = args.stream
console_w, console_h = args.console
checks = {}
if args.mode == "absolute":
check_keys(events, checks)
# the position maps the stream center to the console center; the relative move then continues
# from there in client pixels (Sunshine doesn't scale relative motion)
center_x = (stream_w // 2) * console_w // stream_w
center_y = (stream_h // 2) * console_h // stream_h
first = (abs_value(center_x, console_w), abs_value(center_y, console_h))
final = (abs_value(center_x + 100, console_w), abs_value(center_y + 50, console_h))
xs = values(events, EV_ABS, ABS_X)
ys = values(events, EV_ABS, ABS_Y)
# evdev drops repeated values, so the center may already be the last value before the move
centered = any(near(v, first[0]) for v in xs[:-1]) and any(near(v, first[1]) for v in ys[:-1])
moved = bool(xs) and bool(ys) and near(xs[-1], final[0]) and near(ys[-1], final[1])
checks["absolute_position"] = {"ok": centered and moved, "expected_center": first, "expected_final": final, "abs_x": xs, "abs_y": ys}
check_click_and_wheel(events, checks)
elif args.mode == "relative":
check_keys(events, checks)
# Sunshine's connect nudge (+1,+1 then -1,-1) cancels out
dx = sum(values(events, EV_REL, REL_X))
dy = sum(values(events, EV_REL, REL_Y))
checks["relative_motion"] = {"ok": (dx, dy) == (120, -75), "expected": (120, -75), "got": (dx, dy)}
checks["no_absolute_axes"] = {"ok": not values(events, EV_ABS, ABS_X), "abs_x": values(events, EV_ABS, ABS_X)}
check_click_and_wheel(events, checks)
else:
xs = values(events, EV_ABS, ABS_MT_POSITION_X)
ys = values(events, EV_ABS, ABS_MT_POSITION_Y)
down = (abs_value(console_w // 4, console_w), abs_value(console_h * 3 // 4, console_h))
moved = (abs_value(console_w // 2, console_w), abs_value(console_h // 2, console_h))
positions_ok = (
len(xs) >= 2 and len(ys) >= 2 and near(xs[0], down[0]) and near(ys[0], down[1]) and near(xs[-1], moved[0]) and near(ys[-1], moved[1])
)
checks["touch_positions"] = {"ok": positions_ok, "expected_down": down, "expected_moved": moved, "mt_x": xs, "mt_y": ys}
tracking = values(events, EV_ABS, ABS_MT_TRACKING_ID)
checks["touch_contact"] = {"ok": len(tracking) >= 2 and tracking[0] >= 0 and tracking[-1] == -1, "tracking_ids": tracking}
devices = sorted({e["name"] for e in events if e["type"] == EV_ABS and e["code"] == ABS_MT_POSITION_X})
checks["touch_device"] = {"ok": bool(devices), "devices": devices}
ok = all(check["ok"] for check in checks.values())
print(json.dumps({"ok": ok, "mode": args.mode, "events": len(events), "checks": checks}))
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
tests/e2e/qemu/e2e_stream.sh +61 −6
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# @tag requirements: [REQ-E2E-001, REQ-NFR-001, REQ-CAP-004, REQ-AUD-001]
# @tag requirements: [REQ-E2E-001, REQ-NFR-001, REQ-CAP-004, REQ-AUD-001, REQ-INP-001, REQ-INP-002, REQ-INP-003]
# End-to-end test (REQ-E2E-001): an unmodified Moonlight client streams a QEMU VM that has no guest agent.
#
# 1. start a private dbus-daemon and a QEMU guest that draws a known pattern (run_vm.sh)
@@ -37,6 +37,13 @@
# E2E_AUDIO_TOLERANCE allowed frequency error in Hz (default: 10)
# E2E_AUDIO_SECONDS decoded audio to analyze, in seconds with one decimal (default: 3)
# E2E_AUDIO_MAX_SILENT maximum 10 ms blocks of silence (below -40 dBFS) in the analyzed audio (default: 0)
# E2E_INPUT boot the Linux test guest and have the client send keyboard, mouse or touch input, then require
# the guest's evdev log (serial console) to show the matching events (REQ-INP-001..003):
# absolute virtio-tablet + virtio-keyboard: "a", Shift+"A", mouse position, relative move
# (converted to an absolute position), left click, wheel
# relative PS/2 keyboard and mouse (vmport=off): "a", Shift+"A", relative moves, left click, wheel
# touch virtio-multitouch: a contact down, moved and lifted
# Checked by check_guest_input.py; the summary is written to <artifacts>/input.json.
# E2E_SESSIONS run the client this many times in a row against the same Sunshine and VM (default: 1); every
# stream must pass, so capture and audio have to come back after a client disconnects
# E2E_KEEP set to 1 to keep the work directory
@@ -64,6 +71,7 @@
audio_seconds="${E2E_AUDIO_SECONDS:-3}"
audio_max_silent="${E2E_AUDIO_MAX_SILENT:-0}"
sessions="${E2E_SESSIONS:-1}"
input_mode="${E2E_INPUT:-}"
if [[ "${sessions}" != 1 && -n "${reset_after}" ]]; then
echo "e2e: E2E_SESSIONS and E2E_RESET_AFTER_FRAMES can't be combined" >&2
@@ -74,6 +82,21 @@
exit 2
fi
case "${input_mode}" in
"") ;;
absolute) export VM_INPUT=virtio ;;
relative) export VM_INPUT=ps2 ;;
touch) export VM_INPUT=touch ;;
*)
echo "e2e: E2E_INPUT must be absolute, relative or touch" >&2
exit 2
;;
esac
if [[ -n "${input_mode}" && ( -n "${reset_after}" || "${sessions}" != 1 ) ]]; then
echo "e2e: E2E_INPUT can't be combined with E2E_RESET_AFTER_FRAMES or E2E_SESSIONS" >&2
exit 2
fi
if [[ "${E2E_GL:-0}" == 1 ]]; then
if ! compgen -G "/dev/dri/renderD*" > /dev/null; then
echo "e2e: SKIP: E2E_GL=1 needs a DRM render node (/dev/dri/renderD*) for QEMU's GL display" >&2
@@ -118,14 +141,22 @@
trap cleanup EXIT
# 1. guest
if [[ "${audio}" == 1 || -n "${input_mode}" ]]; then
if [[ "${audio}" == 1 ]]; then
# the Linux test guest plays a seamless sine to its HDA codec as soon as it boots
# the Linux test guest plays a seamless sine to its HDA codec as soon as it boots, and logs every evdev event
guest_append="sq.tone=off"
if [[ "${audio}" == 1 ]]; then
guest_append="sq.tone=sine sq.tone_freq=${audio_freq}"
fi
bus_address="$(GUEST_APPEND="${guest_append}" E2E_CACHE="${cache}" "${script_dir}/guest/linux/run.sh" "${work}/vm")"
bus_address="$(GUEST_APPEND="sq.tone=sine sq.tone_freq=${audio_freq}" E2E_CACHE="${cache}" "${script_dir}/guest/linux/run.sh" "${work}/vm")"
ready_timeout=60
[[ "${VM_ACCEL:-}" == tcg ]] && ready_timeout=300
ready="$("${script_dir}/guest/linux/wait_ready.sh" "${work}/vm/serial.log" "${ready_timeout}")"
echo "e2e: guest ${ready}" >&2
if [[ -n "${input_mode}" && "${ready}" != *"evdev=on"* ]]; then
echo "e2e: FAIL: the guest isn't logging input events" >&2
exit 1
fi
if [[ "${ready}" != *"tone=running"* ]]; then
if [[ "${audio}" == 1 && "${ready}" != *"tone=running"* ]]; then
echo "e2e: FAIL: the guest's tone isn't playing" >&2
grep -a '^SQGUEST ' "${work}/vm/serial.log" >&2 || true
exit 1
@@ -194,7 +225,10 @@
client_extra=()
if [[ "${audio}" == 1 ]]; then
client_extra=(--audio-freq "${audio_freq}" --audio-tolerance "${audio_tolerance}" --audio-seconds-x10 "$(awk -v s="${audio_seconds}" 'BEGIN { printf "%d", s * 10 }')")
fi
if [[ -n "${input_mode}" ]]; then
else
client_extra+=(--input "${input_mode}")
elif [[ "${audio}" != 1 ]]; then
client_extra=(--expect "255,0,0;0,255,0;0,0,255;255,255,255" --tolerance 48)
fi
if [[ -n "${reset_after}" ]]; then
@@ -272,5 +306,26 @@
result=1
else
echo "e2e: guest tone received: $(grep -o '"audio":{[^}]*}' "${artifacts}/summary.json")" >&2
fi
fi
if [[ ${result} == 0 && -n "${input_mode}" ]]; then
# the guest must have logged exactly the keys, motion, buttons and touches the client sent
console_size="$(sed -n 's/.*qemu: streaming VM .* at \([0-9]*x[0-9]*\)$/\1/p' "${work}/sunshine.log" | tail -1)"
if [[ -z "${console_size}" ]]; then
echo "e2e: FAIL: Sunshine never streamed the VM console" >&2
result=1
else
# give the guest's loggers a moment to write the last events
sleep 1
set +e
python3 "${script_dir}/check_guest_input.py" "${work}/vm/serial.log" "${input_mode}" --stream "${width}x${height}" --console "${console_size}" > "${artifacts}/input.json"
result=$?
set -e
if [[ ${result} == 0 ]]; then
echo "e2e: guest input received: $(cat "${artifacts}/input.json")" >&2
else
echo "e2e: FAIL: the guest's input events don't match: $(cat "${artifacts}/input.json")" >&2
fi
fi
fi
tests/e2e/qemu/guest/linux/run.sh +3 −2
@@ -1,14 +1,15 @@
#!/usr/bin/env bash
# Boot the Linux test guest through run_vm.sh: private dbus-daemon, -display dbus, -audiodev dbus,
# intel-hda + hda-output, virtio-vga, virtio-tablet-pci, virtio-keyboard-pci (plus the built-in PS/2
# keyboard and mouse), and the serial console written to <work-dir>/serial.log.
# keyboard and mouse; run_vm.sh's VM_INPUT changes the input devices), and the serial console written
# to <work-dir>/serial.log.
#
# Usage: run.sh <work-dir> [extra qemu args...]
#
# Prints the D-Bus address on stdout, like run_vm.sh. It doesn't wait for the guest; use
# wait_ready.sh <work-dir>/serial.log for that.
#
# Environment (plus everything run_vm.sh reads: QEMU, VM_NAME, VM_ACCEL, VM_START_WAIT):
# Environment (plus everything run_vm.sh reads: QEMU, VM_NAME, VM_ACCEL, VM_START_WAIT, VM_INPUT):
# GUEST_DIR a build.sh output directory (default: build or reuse the cached one)
# GUEST_APPEND extra kernel command line options, e.g. "sq.tone=off sq.tone_freq=440"
set -euo pipefail
tests/e2e/qemu/run_vm.sh +14 −2
@@ -12,6 +12,9 @@
# VM_START_WAIT seconds to wait for org.qemu on the bus (default: 30)
# VM_GL set to 1 for a GL display: virtio-vga-gl with -display dbus,gl=on (DMABUF scanouts)
# VM_RENDERNODE render node for VM_GL=1 (default: QEMU's choice, the first /dev/dri/renderD*)
# VM_INPUT guest input devices: "virtio" (default: virtio-tablet-pci and virtio-keyboard-pci, an absolute
# mouse), "ps2" (only the built-in PS/2 keyboard and mouse, with vmport=off so the mouse is
# relative) or "touch" (virtio plus virtio-multitouch-pci)
#
# Writes into <work-dir>:
# bus.sock the private bus socket; bus address is unix:path=<work-dir>/bus.sock
@@ -47,6 +50,16 @@
display_device=virtio-vga-gl
display_opts="dbus,gl=on,addr=${bus_address},audiodev=snd0${VM_RENDERNODE:+,rendernode=${VM_RENDERNODE}}"
fi
case "${VM_INPUT:-virtio}" in
virtio) input_args=(-device virtio-tablet-pci -device virtio-keyboard-pci) ;;
# without vmport=off, the guest's VMMouse driver makes the PS/2 mouse absolute under KVM
ps2) input_args=(-machine "pc,vmport=off") ;;
touch) input_args=(-device virtio-tablet-pci -device virtio-keyboard-pci -device virtio-multitouch-pci) ;;
*)
echo "unknown VM_INPUT=${VM_INPUT}" >&2
exit 2
;;
esac
rm -f "${bus_socket}" "${work}/qmp.sock"
dbus-daemon --session --nofork --nopidfile --address="${bus_address}" > "${work}/dbus.log" 2>&1 &
@@ -70,8 +83,7 @@
-display "${display_opts}" \
-audiodev dbus,id=snd0 \
-device intel-hda -device hda-output,audiodev=snd0 \
-device virtio-tablet-pci \
-device virtio-keyboard-pci \
"${input_args[@]}" \
-drive file="${image}",format=raw,if=ide,snapshot=on \
-qmp unix:"${work}/qmp.sock",server=on,wait=off \
"$@" > "${work}/qemu.log" 2>&1 &
tests/e2e/README.md +27 −2
@@ -11,7 +11,8 @@
| `qemu/guest/pattern.S` | 512-byte boot sector: one second of text mode, then VGA mode 13h with red, green, blue, white quadrants and a blinking square |
| `qemu/guest/build_guest.sh` | Builds the guest disk image with GNU `as`/`ld` (no binaries are committed) |
| `qemu/guest/linux/` | Linux test guest (Alpine kernel + initramfs built from pinned downloads): plays a sine tone, logs input events; see its README |
| `qemu/run_vm.sh` | Starts a private `dbus-daemon` and QEMU with `-display dbus,addr=...,audiodev=snd0` and `-audiodev dbus` |
| `qemu/e2e_stream.sh` | The end-to-end test (REQ-E2E-001), also used for latency numbers (REQ-NFR-001), the guest reboot test (REQ-CAP-004) and the guest audio test (REQ-AUD-001) |
| `qemu/run_vm.sh` | Starts a private `dbus-daemon` and QEMU with `-display dbus,addr=...,audiodev=snd0` and `-audiodev dbus`; `VM_INPUT` picks virtio, PS/2 or multi-touch input devices |
| `qemu/e2e_stream.sh` | The end-to-end test (REQ-E2E-001), also used for latency numbers (REQ-NFR-001), the guest reboot test (REQ-CAP-004), the guest audio test (REQ-AUD-001) and the guest input tests (REQ-INP-001..003) |
| `qemu/check_guest_input.py` | Checks the Linux guest's evdev log against the input the client sent |
| `qemu/qmp.py` | Sends one QMP command to the VM (used for `system_reset`) |
| `moonlight_client/` | Headless client: GameStream pairing/launch plus moonlight-common-c and FFmpeg decoding |
@@ -91,5 +92,29 @@
`E2E_SESSIONS=3` runs the client three times against the same Sunshine and VM, so the audio
listener has to be released and registered again between streams.
## Guest input
```bash
E2E_INPUT=absolute tests/e2e/qemu/e2e_stream.sh # virtio-tablet + virtio-keyboard
E2E_INPUT=relative tests/e2e/qemu/e2e_stream.sh # PS/2 keyboard and mouse, vmport=off
E2E_INPUT=touch tests/e2e/qemu/e2e_stream.sh # plus virtio-multitouch-pci
```
Boots the Linux test guest with the chosen input devices; its init logs every evdev event to the
serial console. After 30 decoded frames the client sends input through moonlight-common-c and keeps
streaming for two seconds:
| Mode | Client sends | The guest log must show |
|------------|--------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------|
| `absolute` | "a", Shift+"A", mouse position at the stream center, relative move (+100, +50), left click, one wheel step up | `KEY_A` 1/0, `KEY_LEFTSHIFT` 1, `KEY_A` 1/0, `KEY_LEFTSHIFT` 0; `ABS_X`/`ABS_Y` at the center, then at center + (100, 50) (Sunshine turns the relative move into an absolute position); `BTN_LEFT` 1/0; `REL_WHEEL` 1 |
| `relative` | "a", Shift+"A", three relative moves of (+40, -25), left click, one wheel step up | the same keys on the AT keyboard; `REL_X` adding up to 120 and `REL_Y` to -75; no absolute axes; `BTN_LEFT` 1/0; `REL_WHEEL` 1 |
| `touch` | a contact down at (0.25, 0.75), moved to (0.5, 0.5), lifted | `ABS_MT_POSITION_X`/`Y` at both points and `ABS_MT_TRACKING_ID` going back to -1 on "QEMU Virtio MultiTouch" |
Absolute values are QEMU's 0..32767 scale with a tolerance of 64. `check_guest_input.py` writes its
result to `input.json` in the artifacts. A PS/2 guest has no hardware cursor, so absolute moves for a
relative mouse (which need QEMU's `MouseSet` position) aren't covered end to end; the unit tests cover
them. With `virtio-multitouch-pci`, QEMU delivers mouse buttons to the touch device and drops wheel
events, which is why touch has its own mode.
## GL display (DMABUF)