@@ -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;
}