Merge pull request #33 from VioletTides/room-view-tests

Test RoomView against a fake Steam debug port
This commit is contained in:
Robin Anderson authored and GitHub committed 2026-10-16 20:58:43 -04:00
commit 1a7485c8fa
2 files changed
+277 -2

No files matched your search

+8 -2
View File
@@ -14,7 +14,7 @@ endif()
include(cmake/OpenVRLoader.cmake)
find_package(Threads REQUIRED)
add_library(knockthru_core STATIC src/knock_detector.cpp src/config.cpp src/calibrator.cpp src/update.cpp src/report.cpp src/report_server.cpp)
add_library(knockthru_core STATIC src/knock_detector.cpp src/config.cpp src/calibrator.cpp src/update.cpp src/report.cpp src/report_server.cpp src/room_view.cpp)
target_include_directories(knockthru_core PUBLIC src third_party/json)
target_compile_options(knockthru_core PRIVATE -Wall -Wextra -Wpedantic)
@@ -24,7 +24,7 @@ target_include_directories(knockthru_ui SYSTEM PRIVATE third_party/stb third_par
target_link_libraries(knockthru_ui PUBLIC knockthru_core)
target_compile_options(knockthru_ui PRIVATE -Wall -Wextra -Wpedantic)
add_executable(knockthru src/main.cpp src/app.cpp src/steamvr.cpp src/poses.cpp src/diagnostics.cpp src/install.cpp src/room_view.cpp src/style.cpp src/sound.cpp src/overlay_texture.cpp)
add_executable(knockthru src/main.cpp src/app.cpp src/steamvr.cpp src/poses.cpp src/diagnostics.cpp src/install.cpp src/style.cpp src/sound.cpp src/overlay_texture.cpp)
target_include_directories(knockthru SYSTEM PRIVATE third_party/vulkan/include)
target_link_libraries(knockthru PRIVATE knockthru_core knockthru_ui openvr_loader Threads::Threads ${CMAKE_DL_LIBS})
target_compile_definitions(knockthru PRIVATE KNOCKTHRU_VERSION="${PROJECT_VERSION}")
@@ -54,6 +54,12 @@ target_link_libraries(update_test PRIVATE knockthru_core)
target_compile_options(update_test PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME update COMMAND update_test)
add_executable(room_view_test tests/room_view_test.cpp)
target_link_libraries(room_view_test PRIVATE knockthru_core Threads::Threads)
target_compile_options(room_view_test PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME room_view COMMAND room_view_test)
set_tests_properties(room_view PROPERTIES SKIP_RETURN_CODE 77)
# Renders the dashboard panel in sample states to PNG files, for review.
add_executable(panel_preview tests/panel_preview.cpp)
target_link_libraries(panel_preview PRIVATE knockthru_ui)
+269
View File
@@ -0,0 +1,269 @@
// Room View tests: RoomView talks to a fake Steam debug port, which runs each
// script in Node against a fake dashboard bar. Skips with exit code 77 when
// node is not on PATH.
#include "check.hpp"
#include "room_view.hpp"
#include <json.hpp>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <unistd.h>
#include <atomic>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <string>
#include <thread>
using namespace knockthru;
using json = nlohmann::json;
namespace fs = std::filesystem;
namespace {
// The fake bar. Buttons find their React action through __reactFiber, as on the Frame.
// The state file carries whether Room View is on between scripts.
constexpr const char* kPrelude = R"JS(
const fs = require('fs');
const statePath = process.argv[2];
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
const button = (action, onClick) => {
const element = {click: onClick};
element['__reactFiber$test'] = {memoizedProps: {}, return: {memoizedProps: {action}, return: null}};
return element;
};
const buttons = [button({invocation: 2, icon: {enum: 3}, icon_active: {enum: 1}, active: true}, () => {})];
if (state.present)
buttons.push(button({invocation: 2, icon: {enum: 2}, icon_active: {enum: 1}, active: state.on},
() => { state.on = !state.on; }));
globalThis.document = {querySelectorAll: (selector) => {
if (state.broken) throw new Error('querySelectorAll is gone');
return selector === '[role="button"]' ? buttons : [];
}};
let value;
try { value = (EXPRESSION); } finally { fs.writeFileSync(statePath, JSON.stringify(state)); }
process.stdout.write(JSON.stringify(value));
)JS";
std::string read(const fs::path& path) {
std::ifstream stream(path, std::ios::binary);
return {std::istreambuf_iterator<char>(stream), std::istreambuf_iterator<char>()};
}
// A Steam client debug port on 127.0.0.1 with two targets, the bar and its popup.
class FakeDebugPort {
public:
explicit FakeDebugPort(fs::path directory) : directory_(std::move(directory)) {
set_state(false, true, false);
listener_ = ::socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
::bind(listener_, reinterpret_cast<sockaddr*>(&address), sizeof address);
::listen(listener_, 4);
socklen_t size = sizeof address;
::getsockname(listener_, reinterpret_cast<sockaddr*>(&address), &size);
port_ = ntohs(address.sin_port);
timeval timeout{0, 100000}; // accept wakes up to see stop_
::setsockopt(listener_, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof timeout);
thread_ = std::thread([this] { loop(); });
}
~FakeDebugPort() {
stop_ = true;
thread_.join();
::close(listener_);
}
int port() const { return port_; }
bool bar = true; // whether the target list has the bar page
void set_state(bool on, bool present, bool broken) {
std::ofstream(directory_ / "state.json", std::ios::trunc)
<< json{{"on", on}, {"present", present}, {"broken", broken}}.dump();
}
bool on() const { return json::parse(read(directory_ / "state.json")).value("on", false); }
private:
void loop() {
while (!stop_) {
const int client = ::accept(listener_, nullptr, nullptr);
if (client < 0) continue;
answer(client);
::close(client);
}
}
static std::string receive(int client, std::size_t count) {
std::string data(count, '\0');
for (std::size_t got = 0; got < count;) {
const auto n = ::recv(client, data.data() + got, count - got, 0);
if (n <= 0) return {};
got += static_cast<std::size_t>(n);
}
return data;
}
static void send_all(int client, const std::string& data) { ::send(client, data.data(), data.size(), MSG_NOSIGNAL); }
// An unmasked text frame, as the Steam client sends.
static std::string frame(const std::string& text) {
std::string out(1, static_cast<char>(0x81));
if (text.size() < 126) {
out += static_cast<char>(text.size());
} else {
out += static_cast<char>(126);
out += static_cast<char>(text.size() >> 8);
out += static_cast<char>(text.size() & 0xFF);
}
return out + text;
}
// Reads one masked client frame.
static std::string read_frame(int client) {
const auto head = receive(client, 2);
if (head.size() != 2) return {};
std::size_t length = static_cast<unsigned char>(head[1]) & 0x7F;
if (length == 126) {
const auto bytes = receive(client, 2);
length = (static_cast<unsigned char>(bytes[0]) << 8) | static_cast<unsigned char>(bytes[1]);
}
const auto mask = receive(client, 4);
auto payload = receive(client, length);
for (std::size_t i = 0; i < payload.size(); ++i) payload[i] = static_cast<char>(payload[i] ^ mask[i % 4]);
return payload;
}
void answer(int client) {
std::string headers;
while (headers.find("\r\n\r\n") == std::string::npos) {
const auto byte = receive(client, 1);
if (byte.empty()) return;
headers += byte;
}
const auto ws = "ws://127.0.0.1:" + std::to_string(port_) + "/devtools/page/";
if (headers.rfind("GET /json ", 0) == 0) {
json targets = json::array();
targets.push_back({{"url", "https://steamloopback.host/?vrOverlayKey=valve.steam.gamepadui.barpopup"},
{"webSocketDebuggerUrl", ws + "POPUP"}});
if (bar)
targets.push_back({{"url", "https://steamloopback.host/?vrOverlayKey=valve.steam.gamepadui.bar"},
{"webSocketDebuggerUrl", ws + "BAR"}});
const auto body = targets.dump();
// The connection stays open, as Steam's does, so the client must read by length.
send_all(client, "HTTP/1.1 200 OK\r\nContent-Length: " + std::to_string(body.size()) + "\r\n\r\n" + body);
return;
}
if (headers.rfind("GET /devtools/page/BAR ", 0) != 0) {
send_all(client, "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n");
return;
}
send_all(client, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n");
const auto request = json::parse(read_frame(client), nullptr, false);
if (request.is_discarded()) return;
// An event comes first, longer than 125 bytes, which the client must skip.
send_all(client, frame(json{{"method", "Runtime.consoleAPICalled"}, {"params", {{"pad", std::string(200, 'x')}}}}.dump()));
send_all(client, frame(evaluate(request["id"], request["params"]["expression"])));
}
// Runs `expression` in Node and returns the reply Runtime.evaluate gives.
std::string evaluate(const json& id, const std::string& expression) {
std::string script = kPrelude;
script.replace(script.find("EXPRESSION"), 10, expression);
std::ofstream(directory_ / "script.js", std::ios::trunc) << script;
const auto command = "node '" + (directory_ / "script.js").string() + "' '" +
(directory_ / "state.json").string() + "' 2>/dev/null";
std::string output;
int status = -1;
if (FILE* pipe = ::popen(command.c_str(), "r")) {
char buffer[256];
for (std::size_t n; (n = std::fread(buffer, 1, sizeof buffer, pipe)) > 0;) output.append(buffer, n);
status = ::pclose(pipe);
}
json result;
if (status == 0) result = {{"type", "string"}, {"value", json::parse(output)}};
else result = {{"type", "object"}, {"subtype", "error"}, {"description", "Error: script threw"}};
return json{{"id", id}, {"result", {{"result", result}}}}.dump();
}
fs::path directory_;
int listener_ = -1;
int port_ = 0;
std::atomic<bool> stop_{false};
std::thread thread_;
};
// The message RoomView threw, and whether it was DashboardChanged.
template <typename F> std::pair<std::string, bool> error_of(F f) {
try {
f();
} catch (const DashboardChanged& error) {
return {error.what(), true};
} catch (const std::exception& error) {
return {error.what(), false};
}
return {"", false};
}
} // namespace
int main() {
if (std::system("node --version >/dev/null 2>&1") != 0) {
std::cout << "node is not on PATH, so the Room View tests are skipped\n";
return 77;
}
const auto root = fs::temp_directory_path() / ("knockthru-room-view-test-" + std::to_string(getpid()));
fs::remove_all(root);
fs::create_directories(root);
{
FakeDebugPort steam(root);
const RoomView room_view(steam.port());
check(room_view.active() == false, "active reads off from the bar, skipping the popup page");
steam.set_state(true, true, false);
check(room_view.active() == true, "active reads on");
steam.set_state(false, true, false);
check(room_view.toggle() == true && steam.on(), "toggle clicks the Room View button and returns on");
check(room_view.toggle() == false && !steam.on(), "toggle again returns off");
steam.set_state(false, false, false);
const auto [missing, missing_changed] = error_of([&] { room_view.active(); });
check(missing_changed && missing.find("not found") != std::string::npos,
"a bar without the button throws DashboardChanged: " + missing);
const auto [click, click_changed] = error_of([&] { room_view.toggle(); });
check(click_changed, "toggle without the button throws DashboardChanged");
steam.set_state(false, true, true);
const auto [broken, broken_changed] = error_of([&] { room_view.active(); });
check(broken_changed && broken.find("Dashboard script failed") != std::string::npos,
"a script error throws DashboardChanged: " + broken);
steam.set_state(false, true, false);
steam.bar = false;
const auto [no_bar, no_bar_changed] = error_of([&] { room_view.active(); });
check(!no_bar_changed && no_bar.find("dashboard bar not found") != std::string::npos,
"a target list without the bar says SteamVR may not be running: " + no_bar);
}
// The fake port is closed now.
{
int probe = ::socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
::bind(probe, reinterpret_cast<sockaddr*>(&address), sizeof address);
socklen_t size = sizeof address;
::getsockname(probe, reinterpret_cast<sockaddr*>(&address), &size);
const int closed_port = ntohs(address.sin_port);
::close(probe); // nothing listens on this port
const auto [closed, closed_changed] = error_of([&] { RoomView(closed_port).active(); });
check(!closed_changed && closed.find("CEF Remote Debugging") != std::string::npos,
"a closed port says to turn on CEF Remote Debugging: " + closed);
}
fs::remove_all(root);
return finish();
}