From 3d6476c9021519995239ec93bbb11f0dce6c60a0 Mon Sep 17 00:00:00 2001 From: Vaxry <43317083+vaxerski@users.noreply.github.com> Date: Thu, 26 Jun 2025 19:43:39 +0200 Subject: [PATCH] Core: Add a test suite (#9297) Adds a test suite for testing hyprland's features with a runtime tester --------- Co-authored-by: Mihai Fufezan --- .github/workflows/nix-build.yml | 8 +- .github/workflows/nix-ci.yml | 6 + .github/workflows/nix-test.yml | 59 ++++ CMakeLists.txt | 26 +- flake.lock | 6 +- flake.nix | 24 +- hyprtester/CMakeLists.txt | 30 ++ hyprtester/plugin/Makefile | 16 ++ hyprtester/plugin/build.sh | 4 + hyprtester/plugin/src/globals.hpp | 5 + hyprtester/plugin/src/main.cpp | 43 +++ hyprtester/src/Log.hpp | 17 ++ hyprtester/src/hyprctlCompat.cpp | 136 +++++++++ hyprtester/src/hyprctlCompat.hpp | 16 ++ hyprtester/src/main.cpp | 251 ++++++++++++++++ hyprtester/src/shared.hpp | 90 ++++++ hyprtester/src/tests/main/groups.cpp | 177 ++++++++++++ hyprtester/src/tests/main/misc.cpp | 142 +++++++++ hyprtester/src/tests/main/tests.hpp | 6 + hyprtester/src/tests/main/window.cpp | 96 +++++++ hyprtester/src/tests/main/workspaces.cpp | 349 +++++++++++++++++++++++ hyprtester/src/tests/plugin/plugin.cpp | 21 ++ hyprtester/src/tests/plugin/plugin.hpp | 3 + hyprtester/src/tests/shared.cpp | 92 ++++++ hyprtester/src/tests/shared.hpp | 17 ++ hyprtester/test.conf | 305 ++++++++++++++++++++ nix/hyprtester.nix | 58 ++++ nix/overlays.nix | 22 +- nix/tests/default.nix | 86 ++++++ 29 files changed, 2096 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/nix-test.yml create mode 100644 hyprtester/CMakeLists.txt create mode 100644 hyprtester/plugin/Makefile create mode 100755 hyprtester/plugin/build.sh create mode 100644 hyprtester/plugin/src/globals.hpp create mode 100644 hyprtester/plugin/src/main.cpp create mode 100644 hyprtester/src/Log.hpp create mode 100644 hyprtester/src/hyprctlCompat.cpp create mode 100644 hyprtester/src/hyprctlCompat.hpp create mode 100644 hyprtester/src/main.cpp create mode 100644 hyprtester/src/shared.hpp create mode 100644 hyprtester/src/tests/main/groups.cpp create mode 100644 hyprtester/src/tests/main/misc.cpp create mode 100644 hyprtester/src/tests/main/tests.hpp create mode 100644 hyprtester/src/tests/main/window.cpp create mode 100644 hyprtester/src/tests/main/workspaces.cpp create mode 100644 hyprtester/src/tests/plugin/plugin.cpp create mode 100644 hyprtester/src/tests/plugin/plugin.hpp create mode 100644 hyprtester/src/tests/shared.cpp create mode 100644 hyprtester/src/tests/shared.hpp create mode 100644 hyprtester/test.conf create mode 100644 nix/hyprtester.nix create mode 100644 nix/tests/default.nix diff --git a/.github/workflows/nix-build.yml b/.github/workflows/nix-build.yml index 55ac485ef..7c61d7751 100644 --- a/.github/workflows/nix-build.yml +++ b/.github/workflows/nix-build.yml @@ -29,17 +29,17 @@ jobs: uses: nix-community/cache-nix-action@v6 with: # restore and save a cache using this key - primary-key: nix-${{ runner.os }}-${{ hashFiles('**/*.nix', '**/flake.lock') }} + primary-key: nix-${{ runner.os }} # if there's no cache hit, restore a cache by this prefix - restore-prefixes-first-match: nix-${{ runner.os }}- + restore-prefixes-first-match: nix-${{ runner.os }} # collect garbage until the Nix store size (in bytes) is at most this number # before trying to save a new cache # 1G = 1073741824 - gc-max-store-size-linux: 1G + gc-max-store-size-linux: 5G # do purge caches purge: true # purge all versions of the cache - purge-prefixes: nix-${{ runner.os }}- + purge-prefixes: nix-${{ runner.os }} # created more than this number of seconds ago purge-created: 0 # or, last accessed more than this number of seconds ago diff --git a/.github/workflows/nix-ci.yml b/.github/workflows/nix-ci.yml index 75c19790f..cf5430acd 100644 --- a/.github/workflows/nix-ci.yml +++ b/.github/workflows/nix-ci.yml @@ -12,3 +12,9 @@ jobs: if: (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork) uses: ./.github/workflows/nix-build.yml secrets: inherit + + test: + if: (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork) + needs: build + uses: ./.github/workflows/nix-test.yml + secrets: inherit diff --git a/.github/workflows/nix-test.yml b/.github/workflows/nix-test.yml new file mode 100644 index 000000000..a4e32b566 --- /dev/null +++ b/.github/workflows/nix-test.yml @@ -0,0 +1,59 @@ +name: Nix (Test) + +on: + workflow_call: + secrets: + CACHIX_AUTH_TOKEN: + required: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Install Nix + uses: nixbuild/nix-quick-install-action@v31 + with: + nix_conf: | + keep-env-derivations = true + keep-outputs = true + + - name: Restore and save Nix store + uses: nix-community/cache-nix-action@v6 + with: + # restore and save a cache using this key + primary-key: nix-${{ runner.os }} + # if there's no cache hit, restore a cache by this prefix + restore-prefixes-first-match: nix-${{ runner.os }} + # collect garbage until the Nix store size (in bytes) is at most this number + # before trying to save a new cache + # 1G = 1073741824 + gc-max-store-size-linux: 5G + # do purge caches + purge: true + # purge all versions of the cache + purge-prefixes: nix-${{ runner.os }} + # created more than this number of seconds ago + purge-created: 0 + # or, last accessed more than this number of seconds ago + # relative to the start of the `Post Restore and save Nix store` phase + purge-last-accessed: 0 + # except any version with the key that is the same as the `primary-key` + purge-primary-key: never + + - uses: cachix/cachix-action@v15 + with: + name: hyprland + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Run test VM + run: nix build 'github:hyprwm/Hyprland?ref=${{ github.ref }}#checks.x86_64-linux.tests' -L --extra-substituters "https://hyprland.cachix.org" + + - name: Check exit status + run: grep 0 result/exit_status + + - name: Upload artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: logs + path: result diff --git a/CMakeLists.txt b/CMakeLists.txt index 365b08d53..1079e0c87 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,6 +9,7 @@ project( DESCRIPTION "A Modern C++ Wayland Compositor" VERSION ${VER}) +include(CTest) include(CheckIncludeFile) include(GNUInstallDirs) @@ -340,7 +341,8 @@ protocolnew("protocols" "wayland-drm" true) protocolnew("${HYPRLAND_PROTOCOLS}/protocols" "hyprland-ctm-control-v1" true) protocolnew("${HYPRLAND_PROTOCOLS}/protocols" "hyprland-surface-v1" true) protocolnew("${HYPRLAND_PROTOCOLS}/protocols" "hyprland-lock-notify-v1" true) -protocolnew("${HYPRLAND_PROTOCOLS}/protocols" "hyprland-toplevel-mapping-v1" true) +protocolnew("${HYPRLAND_PROTOCOLS}/protocols" "hyprland-toplevel-mapping-v1" + true) protocolnew("staging/tearing-control" "tearing-control-v1" false) protocolnew("staging/fractional-scale" "fractional-scale-v1" false) @@ -391,6 +393,12 @@ else() message(STATUS "hyprpm is enabled (NO_HYPRPM not defined)") endif() +if(NO_TESTS) + message(STATUS "building tests is disabled") +else() + message(STATUS "building tests is enabled (NO_TESTS not defined)") +endif() + # binary and symlink install(TARGETS Hyprland) @@ -445,5 +453,17 @@ install( FILES_MATCHING PATTERN "*.h" PATTERN "*.hpp" - PATTERN "*.inc" - ) + PATTERN "*.inc") + +if(NOT NO_TESTS) + enable_testing() + add_custom_target(tests) + + add_subdirectory(hyprtester) + add_test( + NAME "Main Test" + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/hyprtester + COMMAND hyprtester) + + add_dependencies(tests hyprtester) +endif() diff --git a/flake.lock b/flake.lock index e0071ff64..de187cfdd 100644 --- a/flake.lock +++ b/flake.lock @@ -238,11 +238,11 @@ ] }, "locked": { - "lastModified": 1750371096, - "narHash": "sha256-JB1IeJ41y7kWc/dPGV6RMcCUM0Xj2NEK26A2Ap7EM9c=", + "lastModified": 1750703126, + "narHash": "sha256-zJHmLsiW6P8h9HaH5eMKhEh/gvym3k6/Ywr4UHKpJfc=", "owner": "hyprwm", "repo": "hyprutils", - "rev": "38f3a211657ce82a1123bf19402199b67a410f08", + "rev": "d46bd32da554c370f98180a1e465f052b9584805", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index dde2860ca..cc4d87fca 100644 --- a/flake.nix +++ b/flake.nix @@ -91,6 +91,7 @@ overlays = with self.overlays; [ hyprland-packages hyprland-extras + hyprland-debug ]; }); pkgsCrossFor = eachSystem (system: crossSystem: @@ -100,6 +101,22 @@ overlays = with self.overlays; [ hyprland-packages hyprland-extras + hyprland-debug + ]; + }); + pkgsDebugFor = eachSystem (system: + import nixpkgs { + localSystem = system; + overlays = with self.overlays; [ + hyprland-debug + ]; + }); + pkgsDebugCrossFor = eachSystem (system: crossSystem: + import nixpkgs { + localSystem = system; + inherit crossSystem; + overlays = with self.overlays; [ + hyprland-debug ]; }); in { @@ -123,7 +140,8 @@ }; }; }; - }); + } + // (import ./nix/tests inputs pkgsFor.${system})); packages = eachSystem (system: { default = self.packages.${system}.hyprland; @@ -131,12 +149,14 @@ (pkgsFor.${system}) # hyprland-packages hyprland - hyprland-debug hyprland-unwrapped + hyprtester # hyprland-extras xdg-desktop-portal-hyprland ; + inherit (pkgsDebugFor.${system}) hyprland-debug; hyprland-cross = (pkgsCrossFor.${system} "aarch64-linux").hyprland; + hyprland-debug-cross = (pkgsDebugCrossFor.${system} "aarch64-linux").hyprland-debug; }); devShells = eachSystem (system: { diff --git a/hyprtester/CMakeLists.txt b/hyprtester/CMakeLists.txt new file mode 100644 index 000000000..10907ce7a --- /dev/null +++ b/hyprtester/CMakeLists.txt @@ -0,0 +1,30 @@ +cmake_minimum_required(VERSION 3.19) + +project(hyprtester DESCRIPTION "Hyprland test suite") + +include(GNUInstallDirs) + +set(CMAKE_CXX_STANDARD 26) + +find_package(PkgConfig REQUIRED) + +pkg_check_modules(hyprtester_deps REQUIRED IMPORTED_TARGET hyprutils>=0.5.0) + +file(GLOB_RECURSE SRCFILES CONFIGURE_DEPENDS "src/*.cpp") + +add_executable(hyprtester ${SRCFILES}) +add_custom_command( + TARGET hyprtester + POST_BUILD + COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/plugin/build.sh + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/plugin) + +target_link_libraries(hyprtester PUBLIC PkgConfig::hyprtester_deps) + +install(TARGETS hyprtester) + +install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/test.conf + DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/hypr) + +install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/plugin/hyprtestplugin.so + DESTINATION ${CMAKE_INSTALL_PREFIX}/lib) diff --git a/hyprtester/plugin/Makefile b/hyprtester/plugin/Makefile new file mode 100644 index 000000000..e77921d4d --- /dev/null +++ b/hyprtester/plugin/Makefile @@ -0,0 +1,16 @@ +CXXFLAGS = -shared -fPIC --no-gnu-unique -g -std=c++2b -Wno-c++11-narrowing +INCLUDES = `pkg-config --cflags pixman-1 libdrm pangocairo libinput libudev wayland-server xkbcommon` +LIBS = `pkg-config --libs pangocairo` + +SRC = src/main.cpp +TARGET = hyprtestplugin.so + +all: $(TARGET) + +$(TARGET): $(SRC) + $(CXX) $(CXXFLAGS) -I../.. -I../../protocols $(INCLUDES) $^ $> -o $@ $(LIBS) -O2 + +clean: + rm -f ./$(TARGET) + +.PHONY: all clean diff --git a/hyprtester/plugin/build.sh b/hyprtester/plugin/build.sh new file mode 100755 index 000000000..5bb5d0178 --- /dev/null +++ b/hyprtester/plugin/build.sh @@ -0,0 +1,4 @@ +#!/bin/sh + +make clean +make all diff --git a/hyprtester/plugin/src/globals.hpp b/hyprtester/plugin/src/globals.hpp new file mode 100644 index 000000000..37e8363b6 --- /dev/null +++ b/hyprtester/plugin/src/globals.hpp @@ -0,0 +1,5 @@ +#pragma once + +#include + +inline HANDLE PHANDLE = nullptr; \ No newline at end of file diff --git a/hyprtester/plugin/src/main.cpp b/hyprtester/plugin/src/main.cpp new file mode 100644 index 000000000..1a7136d9e --- /dev/null +++ b/hyprtester/plugin/src/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include +#include + +#define private public +#include +#include +#undef private + +#include "globals.hpp" + +// Do NOT change this function. +APICALL EXPORT std::string PLUGIN_API_VERSION() { + return HYPRLAND_API_VERSION; +} + +static SDispatchResult test(std::string in) { + bool success = true; + std::string errors = ""; + + if (g_pConfigManager->m_configValueNumber != CONFIG_OPTIONS.size() + 1 /* autogenerated is special */) { + errors += "config value number mismatches descriptions size\n"; + success = false; + } + + return SDispatchResult{ + .success = success, + .error = errors, + }; +} + +APICALL EXPORT PLUGIN_DESCRIPTION_INFO PLUGIN_INIT(HANDLE handle) { + PHANDLE = handle; + + HyprlandAPI::addDispatcherV2(PHANDLE, "plugin:test:test", ::test); + + return {"hyprtestplugin", "hyprtestplugin", "Vaxry", "1.0"}; +} + +APICALL EXPORT void PLUGIN_EXIT() { + ; +} diff --git a/hyprtester/src/Log.hpp b/hyprtester/src/Log.hpp new file mode 100644 index 000000000..7eeb3ece0 --- /dev/null +++ b/hyprtester/src/Log.hpp @@ -0,0 +1,17 @@ +#pragma once +#include +#include +#include + +namespace NLog { + template + //NOLINTNEXTLINE + void log(std::format_string fmt, Args&&... args) { + std::string logMsg = ""; + + logMsg += std::vformat(fmt.get(), std::make_format_args(args...)); + + std::println("{}", logMsg); + std::fflush(stdout); + } +} \ No newline at end of file diff --git a/hyprtester/src/hyprctlCompat.cpp b/hyprtester/src/hyprctlCompat.cpp new file mode 100644 index 000000000..6e6cfc0f6 --- /dev/null +++ b/hyprtester/src/hyprctlCompat.cpp @@ -0,0 +1,136 @@ +#include "hyprctlCompat.hpp" +#include "shared.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +static int getUID() { + const auto UID = getuid(); + const auto PWUID = getpwuid(UID); + return PWUID ? PWUID->pw_uid : UID; +} + +static std::string getRuntimeDir() { + const auto XDG = getenv("XDG_RUNTIME_DIR"); + + if (!XDG) { + const std::string USERID = std::to_string(getUID()); + return "/run/user/" + USERID + "/hypr"; + } + + return std::string{XDG} + "/hypr"; +} + +std::vector instances() { + std::vector result; + + try { + if (!std::filesystem::exists(getRuntimeDir())) + return {}; + } catch (std::exception& e) { return {}; } + + for (const auto& el : std::filesystem::directory_iterator(getRuntimeDir())) { + if (!el.is_directory() || !std::filesystem::exists(el.path().string() + "/hyprland.lock")) + continue; + + // read lock + SInstanceData* data = &result.emplace_back(); + data->id = el.path().filename().string(); + + try { + data->time = std::stoull(data->id.substr(data->id.find_first_of('_') + 1, data->id.find_last_of('_') - (data->id.find_first_of('_') + 1))); + } catch (std::exception& e) { continue; } + + // read file + std::ifstream ifs(el.path().string() + "/hyprland.lock"); + + int i = 0; + for (std::string line; std::getline(ifs, line); ++i) { + if (i == 0) { + try { + data->pid = std::stoull(line); + } catch (std::exception& e) { continue; } + } else if (i == 1) { + data->wlSocket = line; + } else + break; + } + + ifs.close(); + } + + std::erase_if(result, [&](const auto& el) { return kill(el.pid, 0) != 0 && errno == ESRCH; }); + + std::sort(result.begin(), result.end(), [&](const auto& a, const auto& b) { return a.time < b.time; }); + + return result; +} + +std::string getFromSocket(const std::string& cmd) { + const auto SERVERSOCKET = socket(AF_UNIX, SOCK_STREAM, 0); + + auto t = timeval{.tv_sec = 5, .tv_usec = 0}; + setsockopt(SERVERSOCKET, SOL_SOCKET, SO_RCVTIMEO, &t, sizeof(struct timeval)); + + if (SERVERSOCKET < 0) { + std::println("socket: Couldn't open a socket (1)"); + return ""; + } + + sockaddr_un serverAddress = {0}; + serverAddress.sun_family = AF_UNIX; + + std::string socketPath = getRuntimeDir() + "/" + HIS + "/.socket.sock"; + + strncpy(serverAddress.sun_path, socketPath.c_str(), sizeof(serverAddress.sun_path) - 1); + + if (connect(SERVERSOCKET, (sockaddr*)&serverAddress, SUN_LEN(&serverAddress)) < 0) { + std::println("Couldn't connect to {}. (3)", socketPath); + return ""; + } + + auto sizeWritten = write(SERVERSOCKET, cmd.c_str(), cmd.length()); + + if (sizeWritten < 0) { + std::println("Couldn't write (4)"); + return ""; + } + + std::string reply = ""; + char buffer[8192] = {0}; + + sizeWritten = read(SERVERSOCKET, buffer, 8192); + + if (sizeWritten < 0) { + if (errno == EWOULDBLOCK) + std::println("Hyprland IPC didn't respond in time"); + std::println("Couldn't read (5)"); + return ""; + } + + reply += std::string(buffer, sizeWritten); + + while (sizeWritten == 8192) { + sizeWritten = read(SERVERSOCKET, buffer, 8192); + if (sizeWritten < 0) { + std::println("Couldn't read (5)"); + return ""; + } + reply += std::string(buffer, sizeWritten); + } + + close(SERVERSOCKET); + + return reply; +} \ No newline at end of file diff --git a/hyprtester/src/hyprctlCompat.hpp b/hyprtester/src/hyprctlCompat.hpp new file mode 100644 index 000000000..bf4ce2a7e --- /dev/null +++ b/hyprtester/src/hyprctlCompat.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include + +struct SInstanceData { + std::string id; + uint64_t time; + uint64_t pid; + std::string wlSocket; + bool valid = true; +}; + +std::vector instances(); +std::string getFromSocket(const std::string& cmd); \ No newline at end of file diff --git a/hyprtester/src/main.cpp b/hyprtester/src/main.cpp new file mode 100644 index 000000000..176eb7020 --- /dev/null +++ b/hyprtester/src/main.cpp @@ -0,0 +1,251 @@ + +// This is a tester for Hyprland. It will launch the built binary in ./build/Hyprland +// in headless mode and test various things. +// for now it's quite basic and limited, but will be expanded in the future. + +// NOTE: This tester has to be ran from its directory!! + +// Some TODO: +// - Add a plugin built alongside so that we can do more detailed tests (e.g. simulating keystrokes) +// - test coverage +// - maybe figure out a way to do some visual tests too? + +// Required runtime deps for checks: +// - kitty +// - xeyes + +#include "shared.hpp" +#include "hyprctlCompat.hpp" +#include "tests/main/tests.hpp" +#include "tests/plugin/plugin.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Log.hpp" + +using namespace Hyprutils::OS; +using namespace Hyprutils::Memory; + +#define SP CSharedPointer + +static int ret = 0; +static SP hyprlandProc; +static const std::string cwd = std::filesystem::current_path().string(); + +// +static bool launchHyprland(std::string configPath, std::string binaryPath) { + if (binaryPath == "") { + std::error_code ec; + if (!std::filesystem::exists(cwd + "/../build/Hyprland", ec) || ec) { + NLog::log("{}No Hyprland binary", Colors::RED); + return false; + } + + binaryPath = cwd + "/../build/Hyprland"; + } + + if (configPath == "") { + std::error_code ec; + if (!std::filesystem::exists(cwd + "/test.conf", ec) || ec) { + NLog::log("{}No test config", Colors::RED); + return false; + } + + configPath = cwd + "/test.conf"; + } + + NLog::log("{}Launching Hyprland", Colors::YELLOW); + hyprlandProc = makeShared(binaryPath, std::vector{"--config", configPath}); + hyprlandProc->addEnv("HYPRLAND_HEADLESS_ONLY", "1"); + + NLog::log("{}Launched async process", Colors::YELLOW); + + return hyprlandProc->runAsync(); +} + +static bool hyprlandAlive() { + NLog::log("{}hyprlandAlive", Colors::YELLOW); + kill(hyprlandProc->pid(), 0); + return errno != ESRCH; +} + +static void help() { + NLog::log("usage: hyprtester [arg [...]].\n"); + NLog::log(R"(Arguments: + --help -h - Show this message again + --config FILE -c FILE - Specify config file to use + --binary FILE -b FILE - Specify Hyprland binary to use + --plugin FILE -p FILE - Specify the location of the test plugin)"); +} + +int main(int argc, char** argv, char** envp) { + + std::string configPath = ""; + std::string binaryPath = ""; + std::string pluginPath = std::filesystem::current_path().string(); + + std::vector args{argv + 1, argv + argc}; + + for (auto it = args.begin(); it != args.end(); it++) { + if (*it == "--config" || *it == "-c") { + if (std::next(it) == args.end()) { + help(); + + return 1; + } + + configPath = *std::next(it); + + try { + configPath = std::filesystem::canonical(configPath); + + if (!std::filesystem::is_regular_file(configPath)) { + throw std::exception(); + } + } catch (...) { + std::println(stderr, "[ ERROR ] Config file '{}' doesn't exist!", configPath); + help(); + + return 1; + } + + it++; + + continue; + } else if (*it == "--binary" || *it == "-b") { + if (std::next(it) == args.end()) { + help(); + + return 1; + } + + binaryPath = *std::next(it); + + try { + binaryPath = std::filesystem::canonical(binaryPath); + + if (!std::filesystem::is_regular_file(binaryPath)) { + throw std::exception(); + } + } catch (...) { + std::println(stderr, "[ ERROR ] Binary '{}' doesn't exist!", binaryPath); + help(); + + return 1; + } + + it++; + + continue; + } else if (*it == "--plugin" || *it == "-p") { + if (std::next(it) == args.end()) { + help(); + + return 1; + } + + pluginPath = *std::next(it); + + try { + pluginPath = std::filesystem::canonical(pluginPath); + + if (!std::filesystem::is_regular_file(pluginPath)) { + throw std::exception(); + } + } catch (...) { + std::println(stderr, "[ ERROR ] plugin '{}' doesn't exist!", pluginPath); + help(); + + return 1; + } + + it++; + + continue; + } else if (*it == "--help" || *it == "-h") { + help(); + + return 0; + } else { + std::println(stderr, "[ ERROR ] Unknown option '{}' !", *it); + help(); + + return 1; + } + } + + NLog::log("{}launching hl", Colors::YELLOW); + if (!launchHyprland(configPath, binaryPath)) { + NLog::log("{}well it failed", Colors::RED); + return 1; + } + + // hyprland has launched, let's check if it's alive after 10s + std::this_thread::sleep_for(std::chrono::milliseconds(10000)); + NLog::log("{}slept for 10s", Colors::YELLOW); + if (!hyprlandAlive()) { + NLog::log("{}Hyprland failed to launch", Colors::RED); + return 1; + } + + // wonderful, we are in. Let's get the instance signature. + NLog::log("{}trying to get INSTANCES", Colors::YELLOW); + const auto INSTANCES = instances(); + if (INSTANCES.empty()) { + NLog::log("{}Hyprland failed to launch (2)", Colors::RED); + return 1; + } + + HIS = INSTANCES.back().id; + WLDISPLAY = INSTANCES.back().wlSocket; + + NLog::log("{}trying to get create headless output", Colors::YELLOW); + getFromSocket("/output create headless"); + + NLog::log("{}trying to load plugin", Colors::YELLOW); + if (const auto R = getFromSocket(std::format("/plugin load {}", pluginPath)); R != "ok") { + NLog::log("{}Failed to load the test plugin: {}", Colors::RED, R); + getFromSocket("/dispatch exit 1"); + return 1; + } + + NLog::log("{}Loaded plugin", Colors::YELLOW); + + // now we can start issuing stuff. + NLog::log("{}testing windows", Colors::YELLOW); + EXPECT(testWindows(), true); + + std::this_thread::sleep_for(std::chrono::milliseconds(2000)); + + NLog::log("{}testing groups", Colors::YELLOW); + EXPECT(testGroups(), true); + + NLog::log("{}testing workspaces", Colors::YELLOW); + EXPECT(testWorkspaces(), true); + + NLog::log("{}testing misc variables", Colors::YELLOW); + EXPECT(testMisc(), true); + + NLog::log("{}running plugin test", Colors::YELLOW); + EXPECT(testPlugin(), true); + + // kill hyprland + NLog::log("{}dispatching exit", Colors::YELLOW); + getFromSocket("/dispatch exit"); + + NLog::log("\n{}Summary:\n\tPASSED: {}{}{}/{}\n\tFAILED: {}{}{}/{}\n{}", Colors::RESET, Colors::GREEN, TESTS_PASSED, Colors::RESET, TESTS_PASSED + TESTS_FAILED, Colors::RED, + TESTS_FAILED, Colors::RESET, TESTS_PASSED + TESTS_FAILED, (TESTS_FAILED > 0 ? std::string{Colors::RED} + "\nSome tests failed.\n" : "")); + + kill(hyprlandProc->pid(), SIGKILL); + + hyprlandProc.reset(); + + return ret || TESTS_FAILED; +} diff --git a/hyprtester/src/shared.hpp b/hyprtester/src/shared.hpp new file mode 100644 index 000000000..cfce1ba7e --- /dev/null +++ b/hyprtester/src/shared.hpp @@ -0,0 +1,90 @@ +// Stolen from hyprutils + +#pragma once +#include + +inline std::string HIS = ""; +inline std::string WLDISPLAY = ""; +inline int TESTS_PASSED = 0; +inline int TESTS_FAILED = 0; + +namespace Colors { + constexpr const char* RED = "\x1b[31m"; + constexpr const char* GREEN = "\x1b[32m"; + constexpr const char* YELLOW = "\x1b[33m"; + constexpr const char* BLUE = "\x1b[34m"; + constexpr const char* MAGENTA = "\x1b[35m"; + constexpr const char* CYAN = "\x1b[36m"; + constexpr const char* RESET = "\x1b[0m"; +}; + +#define EXPECT(expr, val) \ + if (const auto RESULT = expr; RESULT != (val)) { \ + NLog::log("{}Failed: {}{}, expected {}, got {}. Source: {}@{}.", Colors::RED, Colors::RESET, #expr, val, RESULT, __FILE__, __LINE__); \ + ret = 1; \ + TESTS_FAILED++; \ + } else { \ + NLog::log("{}Passed: {}{}. Got {}", Colors::GREEN, Colors::RESET, #expr, val); \ + TESTS_PASSED++; \ + } + +#define EXPECT_VECTOR2D(expr, val) \ + do { \ + const auto& RESULT = expr; \ + const auto& EXPECTED = val; \ + if (!(std::abs(RESULT.x - EXPECTED.x) < 1e-6 && std::abs(RESULT.y - EXPECTED.y) < 1e-6)) { \ + NLog::log("{}Failed: {}{}, expected [{}, {}], got [{}, {}]. Source: {}@{}.", Colors::RED, Colors::RESET, #expr, EXPECTED.x, EXPECTED.y, RESULT.x, RESULT.y, __FILE__, \ + __LINE__); \ + ret = 1; \ + TESTS_FAILED++; \ + } else { \ + NLog::log("{}Passed: {}{}. Got [{}, {}].", Colors::GREEN, Colors::RESET, #expr, RESULT.x, RESULT.y); \ + TESTS_PASSED++; \ + } \ + } while (0) + +#define EXPECT_CONTAINS(haystack, needle) \ + if (!std::string{haystack}.contains(needle)) { \ + NLog::log("{}Failed: {}{} should contain {} but doesn't. Source: {}@{}. Haystack is:\n{}", Colors::RED, Colors::RESET, #haystack, #needle, __FILE__, __LINE__, \ + std::string{haystack}); \ + ret = 1; \ + TESTS_FAILED++; \ + } else { \ + NLog::log("{}Passed: {}{} contains {}.", Colors::GREEN, Colors::RESET, #haystack, #needle); \ + TESTS_PASSED++; \ + } + +#define EXPECT_NOT_CONTAINS(haystack, needle) \ + if (std::string{haystack}.contains(needle)) { \ + NLog::log("{}Failed: {}{} shouldn't contain {} but does. Source: {}@{}. Haystack is:\n{}", Colors::RED, Colors::RESET, #haystack, #needle, __FILE__, __LINE__, \ + std::string{haystack}); \ + ret = 1; \ + TESTS_FAILED++; \ + } else { \ + NLog::log("{}Passed: {}{} doesn't contain {}.", Colors::GREEN, Colors::RESET, #haystack, #needle); \ + TESTS_PASSED++; \ + } + +#define EXPECT_STARTS_WITH(str, what) \ + if (!std::string{str}.starts_with(what)) { \ + NLog::log("{}Failed: {}{} should start with {} but doesn't. Source: {}@{}. String is:\n{}", Colors::RED, Colors::RESET, #str, #what, __FILE__, __LINE__, \ + std::string{str}); \ + ret = 1; \ + TESTS_FAILED++; \ + } else { \ + NLog::log("{}Passed: {}{} starts with {}.", Colors::GREEN, Colors::RESET, #str, #what); \ + TESTS_PASSED++; \ + } + +#define EXPECT_COUNT_STRING(str, what, no) \ + if (Tests::countOccurrences(str, what) != no) { \ + NLog::log("{}Failed: {}{} should contain {} {} times, but doesn't. Source: {}@{}. String is:\n{}", Colors::RED, Colors::RESET, #str, #what, no, __FILE__, __LINE__, \ + std::string{str}); \ + ret = 1; \ + TESTS_FAILED++; \ + } else { \ + NLog::log("{}Passed: {}{} contains {} {} times.", Colors::GREEN, Colors::RESET, #str, #what, no); \ + TESTS_PASSED++; \ + } + +#define OK(x) EXPECT(x, "ok") diff --git a/hyprtester/src/tests/main/groups.cpp b/hyprtester/src/tests/main/groups.cpp new file mode 100644 index 000000000..ed8e4ba47 --- /dev/null +++ b/hyprtester/src/tests/main/groups.cpp @@ -0,0 +1,177 @@ +#include "tests.hpp" +#include "../../shared.hpp" +#include "../../hyprctlCompat.hpp" +#include +#include +#include +#include +#include +#include +#include +#include "../shared.hpp" + +static int ret = 0; + +using namespace Hyprutils::OS; +using namespace Hyprutils::Memory; + +#define UP CUniquePointer +#define SP CSharedPointer + +bool testGroups() { + NLog::log("{}Testing groups", Colors::GREEN); + + // test on workspace "window" + NLog::log("{}Dispatching workspace `groups`", Colors::YELLOW); + getFromSocket("/dispatch workspace name:groups"); + + NLog::log("{}Spawning kittyProcA", Colors::YELLOW); + auto kittyProcA = Tests::spawnKitty(); + if (!kittyProcA) { + NLog::log("{}Error: kitty did not spawn", Colors::RED); + return false; + } + + NLog::log("{}Expecting 1 window", Colors::YELLOW); + EXPECT(Tests::windowCount(), 1); + + // check kitty properties. One kitty should take the entire screen, minus the gaps. + NLog::log("{}Check kitty dimensions", Colors::YELLOW); + { + auto str = getFromSocket("/clients"); + EXPECT_COUNT_STRING(str, "at: 22,22", 1); + EXPECT_COUNT_STRING(str, "size: 1876,1036", 1); + EXPECT_COUNT_STRING(str, "fullscreen: 0", 1); + } + + // group the kitty + NLog::log("{}Enable group and groupbar", Colors::YELLOW); + OK(getFromSocket("/dispatch togglegroup")); + OK(getFromSocket("/keyword group:groupbar:enabled 1")); + + // check the height of the window now + NLog::log("{}Recheck kitty dimensions", Colors::YELLOW); + { + auto str = getFromSocket("/clients"); + EXPECT_CONTAINS(str, "at: 22,43"); + EXPECT_CONTAINS(str, "size: 1876,1015"); + } + + // disable the groupbar for ease of testing for now + NLog::log("{}Disable groupbar", Colors::YELLOW); + OK(getFromSocket("r/keyword group:groupbar:enabled 0")); + + // kill all + NLog::log("{}Kill windows", Colors::YELLOW); + Tests::killAllWindows(); + + NLog::log("{}Spawn kitty again", Colors::YELLOW); + kittyProcA = Tests::spawnKitty(); + if (!kittyProcA) { + NLog::log("{}Error: kitty did not spawn", Colors::RED); + return false; + } + + NLog::log("{}Group kitty", Colors::YELLOW); + OK(getFromSocket("/dispatch togglegroup")); + + // check the height of the window now + NLog::log("{}Check kitty dimensions 2", Colors::YELLOW); + { + auto str = getFromSocket("/clients"); + EXPECT_CONTAINS(str, "at: 22,22"); + EXPECT_CONTAINS(str, "size: 1876,1036"); + } + + NLog::log("{}Spawn kittyProcB", Colors::YELLOW); + auto kittyProcB = Tests::spawnKitty(); + if (!kittyProcB) { + NLog::log("{}Error: kitty did not spawn", Colors::RED); + return false; + } + + NLog::log("{}Expecting 2 windows", Colors::YELLOW); + EXPECT(Tests::windowCount(), 2); + + size_t lastActiveKittyIdx = 0; + + NLog::log("{}Get last active kitty id", Colors::YELLOW); + try { + auto str = getFromSocket("/activewindow"); + lastActiveKittyIdx = std::stoull(str.substr(7, str.find(" -> ") - 7), nullptr, 16); + } catch (...) { + NLog::log("{}Fail at getting prop", Colors::RED); + ret = 1; + } + + // test cycling through + + NLog::log("{}Test cycling through grouped windows", Colors::YELLOW); + OK(getFromSocket("/dispatch changegroupactive f")); + + try { + auto str = getFromSocket("/activewindow"); + EXPECT(lastActiveKittyIdx != std::stoull(str.substr(7, str.find(" -> ") - 7), nullptr, 16), true); + } catch (...) { + NLog::log("{}Fail at getting prop", Colors::RED); + ret = 1; + } + + getFromSocket("/dispatch changegroupactive f"); + + try { + auto str = getFromSocket("/activewindow"); + EXPECT(lastActiveKittyIdx, std::stoull(str.substr(7, str.find(" -> ") - 7), nullptr, 16)); + } catch (...) { + NLog::log("{}Fail at getting prop", Colors::RED); + ret = 1; + } + + NLog::log("{}Disable autogrouping", Colors::YELLOW); + OK(getFromSocket("/keyword group:auto_group false")); + + NLog::log("{}Spawn kittyProcC", Colors::YELLOW); + auto kittyProcC = Tests::spawnKitty(); + if (!kittyProcC) { + NLog::log("{}Error: kitty did not spawn", Colors::RED); + return false; + } + + NLog::log("{}Expecting 3 windows 2", Colors::YELLOW); + EXPECT(Tests::windowCount(), 3); + { + auto str = getFromSocket("/clients"); + EXPECT_COUNT_STRING(str, "at: 22,22", 2); + } + + OK(getFromSocket("/dispatch movefocus l")); + OK(getFromSocket("/dispatch changegroupactive 1")); + OK(getFromSocket("/keyword group:auto_group true")); + OK(getFromSocket("/keyword group:insert_after_current false")); + + NLog::log("{}Spawn kittyProcD", Colors::YELLOW); + auto kittyProcD = Tests::spawnKitty(); + if (!kittyProcD) { + NLog::log("{}Error: kitty did not spawn", Colors::RED); + return false; + } + + NLog::log("{}Expecting 4 windows", Colors::YELLOW); + EXPECT(Tests::windowCount(), 4); + + OK(getFromSocket("/dispatch changegroupactive 3")); + + { + auto str = getFromSocket("/activewindow"); + EXPECT_CONTAINS(str, std::format("pid: {}", kittyProcD->pid())); + } + + // kill all + NLog::log("{}Kill windows", Colors::YELLOW); + Tests::killAllWindows(); + + NLog::log("{}Expecting 0 windows", Colors::YELLOW); + EXPECT(Tests::windowCount(), 0); + + return !ret; +} diff --git a/hyprtester/src/tests/main/misc.cpp b/hyprtester/src/tests/main/misc.cpp new file mode 100644 index 000000000..db0d9092a --- /dev/null +++ b/hyprtester/src/tests/main/misc.cpp @@ -0,0 +1,142 @@ +#include "tests.hpp" +#include "../../shared.hpp" +#include "../../hyprctlCompat.hpp" +#include +#include +#include +#include +#include +#include +#include +#include "../shared.hpp" + +static int ret = 0; + +using namespace Hyprutils::OS; +using namespace Hyprutils::Memory; + +#define UP CUniquePointer +#define SP CSharedPointer + +bool testMisc() { + NLog::log("{}Testing config: misc:", Colors::GREEN); + + NLog::log("{}Testing close_special_on_empty", Colors::YELLOW); + + OK(getFromSocket("/keyword misc:close_special_on_empty false")); + OK(getFromSocket("/dispatch workspace special:test")); + + Tests::spawnKitty(); + + { + auto str = getFromSocket("/monitors"); + EXPECT_CONTAINS(str, "special workspace: -"); + } + + Tests::killAllWindows(); + + { + auto str = getFromSocket("/monitors"); + EXPECT_CONTAINS(str, "special workspace: -"); + } + + Tests::spawnKitty(); + + OK(getFromSocket("/keyword misc:close_special_on_empty true")); + + Tests::killAllWindows(); + + { + auto str = getFromSocket("/monitors"); + EXPECT_NOT_CONTAINS(str, "special workspace: -"); + } + + NLog::log("{}Testing new_window_takes_over_fullscreen", Colors::YELLOW); + + OK(getFromSocket("/keyword misc:new_window_takes_over_fullscreen 0")); + + Tests::spawnKitty("kitty_A"); + + OK(getFromSocket("/dispatch fullscreen 0")); + + { + auto str = getFromSocket("/activewindow"); + EXPECT_CONTAINS(str, "fullscreen: 2"); + EXPECT_CONTAINS(str, "kitty_A"); + } + + Tests::spawnKitty("kitty_B"); + + { + auto str = getFromSocket("/activewindow"); + EXPECT_CONTAINS(str, "fullscreen: 2"); + EXPECT_CONTAINS(str, "kitty_A"); + } + + OK(getFromSocket("/keyword misc:new_window_takes_over_fullscreen 1")); + + Tests::spawnKitty("kitty_C"); + + { + auto str = getFromSocket("/activewindow"); + EXPECT_CONTAINS(str, "fullscreen: 2"); + EXPECT_CONTAINS(str, "kitty_C"); + } + + OK(getFromSocket("/keyword misc:new_window_takes_over_fullscreen 2")); + + Tests::spawnKitty("kitty_D"); + + { + auto str = getFromSocket("/activewindow"); + EXPECT_CONTAINS(str, "fullscreen: 0"); + EXPECT_CONTAINS(str, "kitty_D"); + } + + OK(getFromSocket("/keyword misc:new_window_takes_over_fullscreen 0")); + + Tests::killAllWindows(); + + NLog::log("{}Testing exit_window_retains_fullscreen", Colors::YELLOW); + + OK(getFromSocket("/keyword misc:exit_window_retains_fullscreen false")); + + Tests::spawnKitty("kitty_A"); + Tests::spawnKitty("kitty_B"); + + OK(getFromSocket("/dispatch fullscreen 0")); + + { + auto str = getFromSocket("/activewindow"); + EXPECT_CONTAINS(str, "fullscreen: 2"); + } + + OK(getFromSocket("/dispatch killwindow activewindow")); + Tests::waitUntilWindowsN(1); + + { + auto str = getFromSocket("/activewindow"); + EXPECT_CONTAINS(str, "fullscreen: 0"); + } + + Tests::spawnKitty("kitty_B"); + OK(getFromSocket("/dispatch fullscreen 0")); + OK(getFromSocket("/keyword misc:exit_window_retains_fullscreen true")); + + OK(getFromSocket("/dispatch killwindow activewindow")); + Tests::waitUntilWindowsN(1); + + { + auto str = getFromSocket("/activewindow"); + EXPECT_CONTAINS(str, "fullscreen: 2"); + } + + // kill all + NLog::log("{}Killing all windows", Colors::YELLOW); + Tests::killAllWindows(); + + NLog::log("{}Expecting 0 windows", Colors::YELLOW); + EXPECT(Tests::windowCount(), 0); + + return !ret; +} diff --git a/hyprtester/src/tests/main/tests.hpp b/hyprtester/src/tests/main/tests.hpp new file mode 100644 index 000000000..accde951f --- /dev/null +++ b/hyprtester/src/tests/main/tests.hpp @@ -0,0 +1,6 @@ +#pragma once + +bool testGroups(); +bool testWindows(); +bool testWorkspaces(); +bool testMisc(); \ No newline at end of file diff --git a/hyprtester/src/tests/main/window.cpp b/hyprtester/src/tests/main/window.cpp new file mode 100644 index 000000000..4eecd68d0 --- /dev/null +++ b/hyprtester/src/tests/main/window.cpp @@ -0,0 +1,96 @@ +#include "tests.hpp" +#include "../../shared.hpp" +#include "../../hyprctlCompat.hpp" +#include +#include +#include +#include +#include +#include +#include +#include "../shared.hpp" + +static int ret = 0; + +using namespace Hyprutils::OS; +using namespace Hyprutils::Memory; + +#define UP CUniquePointer +#define SP CSharedPointer + +bool testWindows() { + NLog::log("{}Testing windows", Colors::GREEN); + + // test on workspace "window" + NLog::log("{}Switching to workspace `window`", Colors::YELLOW); + getFromSocket("/dispatch workspace name:window"); + + NLog::log("{}Spawning kittyProcA", Colors::YELLOW); + auto kittyProcA = Tests::spawnKitty(); + + if (!kittyProcA) { + NLog::log("{}Error: kitty did not spawn", Colors::RED); + return false; + } + + NLog::log("{}Expecting 1 window", Colors::YELLOW); + EXPECT(Tests::windowCount(), 1); + + // check kitty properties. One kitty should take the entire screen, as this is smart gaps + NLog::log("{}Expecting kitty to take up the whole screen", Colors::YELLOW); + { + auto str = getFromSocket("/clients"); + EXPECT(str.contains("at: 0,0"), true); + EXPECT(str.contains("size: 1920,1080"), true); + EXPECT(str.contains("fullscreen: 0"), true); + } + + NLog::log("{}Spawning kittyProcB", Colors::YELLOW); + auto kittyProcB = Tests::spawnKitty(); + if (!kittyProcB) { + NLog::log("{}Error: kitty did not spawn", Colors::RED); + return false; + } + + NLog::log("{}Expecting 2 windows", Colors::YELLOW); + EXPECT(Tests::windowCount(), 2); + + // open xeyes + NLog::log("{}Spawning xeyes", Colors::YELLOW); + getFromSocket("/dispatch exec xeyes"); + + NLog::log("{}Keep checking if xeyes spawned", Colors::YELLOW); + int counter = 0; + while (Tests::windowCount() != 3) { + counter++; + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + if (counter > 50) { + EXPECT(Tests::windowCount(), 3); + return !ret; + } + } + + NLog::log("{}Expecting 3 windows", Colors::YELLOW); + EXPECT(Tests::windowCount(), 3); + + NLog::log("{}Checking props of xeyes", Colors::YELLOW); + // check some window props of xeyes, try to tile them + { + auto str = getFromSocket("/clients"); + EXPECT_CONTAINS(str, "floating: 1"); + getFromSocket("/dispatch settiled class:XEyes"); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + str = getFromSocket("/clients"); + EXPECT_NOT_CONTAINS(str, "floating: 1"); + } + + // kill all + NLog::log("{}Killing all windows", Colors::YELLOW); + Tests::killAllWindows(); + + NLog::log("{}Expecting 0 windows", Colors::YELLOW); + EXPECT(Tests::windowCount(), 0); + + return !ret; +} diff --git a/hyprtester/src/tests/main/workspaces.cpp b/hyprtester/src/tests/main/workspaces.cpp new file mode 100644 index 000000000..ccacff28d --- /dev/null +++ b/hyprtester/src/tests/main/workspaces.cpp @@ -0,0 +1,349 @@ +#include "tests.hpp" +#include "../../shared.hpp" +#include "../../hyprctlCompat.hpp" +#include +#include +#include +#include +#include +#include +#include +#include "../shared.hpp" + +static int ret = 0; + +using namespace Hyprutils::OS; +using namespace Hyprutils::Memory; + +#define UP CUniquePointer +#define SP CSharedPointer + +bool testWorkspaces() { + NLog::log("{}Testing workspaces", Colors::GREEN); + + // test on workspace "window" + NLog::log("{}Switching to workspace 1", Colors::YELLOW); + OK(getFromSocket("/dispatch workspace 1")); + + NLog::log("{}Spawning kittyProc on ws 1", Colors::YELLOW); + auto kittyProcA = Tests::spawnKitty(); + + if (!kittyProcA) { + NLog::log("{}Error: kitty did not spawn", Colors::RED); + return false; + } + + NLog::log("{}Switching to workspace 3", Colors::YELLOW); + OK(getFromSocket("/dispatch workspace 3")); + + NLog::log("{}Spawning kittyProc on ws 3", Colors::YELLOW); + auto kittyProcB = Tests::spawnKitty(); + + if (!kittyProcB) { + NLog::log("{}Error: kitty did not spawn", Colors::RED); + return false; + } + + NLog::log("{}Switching to workspace 1", Colors::YELLOW); + OK(getFromSocket("/dispatch workspace 1")); + + NLog::log("{}Switching to workspace +1", Colors::YELLOW); + OK(getFromSocket("/dispatch workspace +1")); + + { + auto str = getFromSocket("/activeworkspace"); + EXPECT_STARTS_WITH(str, "workspace ID 2 (2)"); + } + + // check if the other workspaces are alive + { + auto str = getFromSocket("/workspaces"); + EXPECT_CONTAINS(str, "workspace ID 3 (3)"); + EXPECT_CONTAINS(str, "workspace ID 1 (1)"); + } + + NLog::log("{}Switching to workspace 1", Colors::YELLOW); + OK(getFromSocket("/dispatch workspace 1")); + + NLog::log("{}Switching to workspace m+1", Colors::YELLOW); + OK(getFromSocket("/dispatch workspace m+1")); + + { + auto str = getFromSocket("/activeworkspace"); + EXPECT_STARTS_WITH(str, "workspace ID 3 (3)"); + } + + NLog::log("{}Switching to workspace -1", Colors::YELLOW); + OK(getFromSocket("/dispatch workspace -1")); + + { + auto str = getFromSocket("/activeworkspace"); + EXPECT_STARTS_WITH(str, "workspace ID 2 (2)"); + } + + NLog::log("{}Switching to workspace 1", Colors::YELLOW); + OK(getFromSocket("/dispatch workspace 1")); + + NLog::log("{}Switching to workspace r+1", Colors::YELLOW); + OK(getFromSocket("/dispatch workspace r+1")); + + { + auto str = getFromSocket("/activeworkspace"); + EXPECT_STARTS_WITH(str, "workspace ID 2 (2)"); + } + + NLog::log("{}Switching to workspace r+1", Colors::YELLOW); + OK(getFromSocket("/dispatch workspace r+1")); + + { + auto str = getFromSocket("/activeworkspace"); + EXPECT_STARTS_WITH(str, "workspace ID 3 (3)"); + } + + NLog::log("{}Switching to workspace r~1", Colors::YELLOW); + OK(getFromSocket("/dispatch workspace r~1")); + + { + auto str = getFromSocket("/activeworkspace"); + EXPECT_STARTS_WITH(str, "workspace ID 1 (1)"); + } + + NLog::log("{}Switching to workspace empty", Colors::YELLOW); + OK(getFromSocket("/dispatch workspace empty")); + + { + auto str = getFromSocket("/activeworkspace"); + EXPECT_STARTS_WITH(str, "workspace ID 2 (2)"); + } + + NLog::log("{}Switching to workspace previous", Colors::YELLOW); + OK(getFromSocket("/dispatch workspace previous")); + + { + auto str = getFromSocket("/activeworkspace"); + EXPECT_STARTS_WITH(str, "workspace ID 1 (1)"); + } + + NLog::log("{}Switching to workspace name:TEST_WORKSPACE_NULL", Colors::YELLOW); + OK(getFromSocket("/dispatch workspace name:TEST_WORKSPACE_NULL")); + + { + auto str = getFromSocket("/activeworkspace"); + EXPECT_STARTS_WITH(str, "workspace ID -1337 (TEST_WORKSPACE_NULL)"); + } + + NLog::log("{}Switching to workspace 1", Colors::YELLOW); + OK(getFromSocket("/dispatch workspace 1")); + + // add a new monitor + NLog::log("{}Adding a new monitor", Colors::YELLOW); + EXPECT(getFromSocket("/output create headless"), "ok") + + // should take workspace 2 + { + auto str = getFromSocket("/monitors"); + EXPECT_CONTAINS(str, "active workspace: 2 (2)"); + EXPECT_CONTAINS(str, "active workspace: 1 (1)"); + EXPECT_CONTAINS(str, "HEADLESS-3"); + } + + // focus the first monitor + OK(getFromSocket("/dispatch focusmonitor 0")); + + { + auto str = getFromSocket("/activeworkspace"); + EXPECT_STARTS_WITH(str, "workspace ID 1 (1)"); + } + + NLog::log("{}Switching to workspace r+1", Colors::YELLOW); + OK(getFromSocket("/dispatch workspace r+1")); + + { + auto str = getFromSocket("/activeworkspace"); + EXPECT_STARTS_WITH(str, "workspace ID 3 (3)"); + } + + NLog::log("{}Switching to workspace r~2", Colors::YELLOW); + OK(getFromSocket("/dispatch workspace 1")); + OK(getFromSocket("/dispatch workspace r~2")); + + { + auto str = getFromSocket("/activeworkspace"); + EXPECT_STARTS_WITH(str, "workspace ID 3 (3)"); + } + + NLog::log("{}Switching to workspace m+1", Colors::YELLOW); + OK(getFromSocket("/dispatch workspace m+1")); + + { + auto str = getFromSocket("/activeworkspace"); + EXPECT_STARTS_WITH(str, "workspace ID 1 (1)"); + } + + NLog::log("{}Switching to workspace 1", Colors::YELLOW); + // no OK: this will throw an error as it should + getFromSocket("/dispatch workspace 1"); + + { + auto str = getFromSocket("/activeworkspace"); + EXPECT_STARTS_WITH(str, "workspace ID 1 (1)"); + } + + NLog::log("{}Testing back_and_forth", Colors::YELLOW); + OK(getFromSocket("/keyword binds:workspace_back_and_forth true")); + OK(getFromSocket("/dispatch workspace 1")); + + { + auto str = getFromSocket("/activeworkspace"); + EXPECT_STARTS_WITH(str, "workspace ID 3 (3)"); + } + + OK(getFromSocket("/keyword binds:workspace_back_and_forth false")); + + NLog::log("{}Testing hide_special_on_workspace_change", Colors::YELLOW); + OK(getFromSocket("/keyword binds:hide_special_on_workspace_change true")); + OK(getFromSocket("/dispatch workspace special:HELLO")); + + { + auto str = getFromSocket("/monitors"); + EXPECT_CONTAINS(str, "special workspace: -"); + EXPECT_CONTAINS(str, "special:HELLO"); + } + + // no OK: will err (it shouldnt prolly but oh well) + getFromSocket("/dispatch workspace 3"); + + { + auto str = getFromSocket("/monitors"); + EXPECT_COUNT_STRING(str, "special workspace: 0 ()", 2); + } + + OK(getFromSocket("/keyword binds:hide_special_on_workspace_change false")); + + NLog::log("{}Testing allow_workspace_cycles", Colors::YELLOW); + OK(getFromSocket("/keyword binds:allow_workspace_cycles true")); + + OK(getFromSocket("/dispatch workspace 1")); + OK(getFromSocket("/dispatch workspace 3")); + OK(getFromSocket("/dispatch workspace 1")); + + OK(getFromSocket("/dispatch workspace previous")); + + { + auto str = getFromSocket("/activeworkspace"); + EXPECT_STARTS_WITH(str, "workspace ID 3 (3)"); + } + + OK(getFromSocket("/dispatch workspace previous")); + + { + auto str = getFromSocket("/activeworkspace"); + EXPECT_STARTS_WITH(str, "workspace ID 1 (1)"); + } + + OK(getFromSocket("/dispatch workspace previous")); + + { + auto str = getFromSocket("/activeworkspace"); + EXPECT_STARTS_WITH(str, "workspace ID 3 (3)"); + } + + OK(getFromSocket("/keyword binds:allow_workspace_cycles false")); + + OK(getFromSocket("/dispatch workspace 1")); + + NLog::log("{}Killing all windows", Colors::YELLOW); + Tests::killAllWindows(); + + // spawn 3 kitties + NLog::log("{}Testing focus_preferred_method", Colors::YELLOW); + OK(getFromSocket("/keyword dwindle:force_split 2")); + Tests::spawnKitty("kitty_A"); + Tests::spawnKitty("kitty_B"); + Tests::spawnKitty("kitty_C"); + OK(getFromSocket("/keyword dwindle:force_split 0")); + + // focus kitty 2: will be top right (dwindle) + OK(getFromSocket("/dispatch focuswindow class:kitty_B")); + + // resize it to be a bit taller + OK(getFromSocket("/dispatch resizeactive +20 +20")); + + // now we test focus methods. + OK(getFromSocket("/keyword binds:focus_preferred_method 0")); + + OK(getFromSocket("/dispatch focuswindow class:kitty_C")); + OK(getFromSocket("/dispatch focuswindow class:kitty_A")); + + OK(getFromSocket("/dispatch movefocus r")); + + { + auto str = getFromSocket("/activewindow"); + EXPECT_CONTAINS(str, "class: kitty_C"); + } + + OK(getFromSocket("/dispatch focuswindow class:kitty_A")); + + OK(getFromSocket("/keyword binds:focus_preferred_method 1")); + + OK(getFromSocket("/dispatch movefocus r")); + + { + auto str = getFromSocket("/activewindow"); + EXPECT_CONTAINS(str, "class: kitty_B"); + } + + NLog::log("{}Testing movefocus_cycles_fullscreen", Colors::YELLOW); + OK(getFromSocket("/dispatch focuswindow class:kitty_A")); + OK(getFromSocket("/dispatch focusmonitor HEADLESS-3")); + Tests::spawnKitty("kitty_D"); + { + auto str = getFromSocket("/activewindow"); + EXPECT_CONTAINS(str, "class: kitty_D"); + } + + OK(getFromSocket("/dispatch focusmonitor l")); + + { + auto str = getFromSocket("/activewindow"); + EXPECT_CONTAINS(str, "class: kitty_A"); + } + + OK(getFromSocket("/keyword binds:movefocus_cycles_fullscreen false")); + OK(getFromSocket("/dispatch fullscreen 0")); + + OK(getFromSocket("/dispatch movefocus r")); + + { + auto str = getFromSocket("/activewindow"); + EXPECT_CONTAINS(str, "class: kitty_D"); + } + + OK(getFromSocket("/dispatch focusmonitor l")); + + { + auto str = getFromSocket("/activewindow"); + EXPECT_CONTAINS(str, "class: kitty_A"); + } + + OK(getFromSocket("/keyword binds:movefocus_cycles_fullscreen true")); + + OK(getFromSocket("/dispatch movefocus r")); + + { + auto str = getFromSocket("/activewindow"); + EXPECT_CONTAINS(str, "class: kitty_B"); + } + + // destroy the headless output + OK(getFromSocket("/output remove HEADLESS-3")); + + // kill all + NLog::log("{}Killing all windows", Colors::YELLOW); + Tests::killAllWindows(); + + NLog::log("{}Expecting 0 windows", Colors::YELLOW); + EXPECT(Tests::windowCount(), 0); + + return !ret; +} diff --git a/hyprtester/src/tests/plugin/plugin.cpp b/hyprtester/src/tests/plugin/plugin.cpp new file mode 100644 index 000000000..94470d6ff --- /dev/null +++ b/hyprtester/src/tests/plugin/plugin.cpp @@ -0,0 +1,21 @@ +#include "plugin.hpp" +#include "../../shared.hpp" +#include "../../hyprctlCompat.hpp" +#include +#include +#include +#include +#include +#include +#include +#include "../shared.hpp" + +bool testPlugin() { + const auto RESPONSE = getFromSocket("/dispatch plugin:test:test"); + + if (RESPONSE != "ok") { + NLog::log("{}Plugin tests failed, plugin returned:\n{}{}", Colors::RED, Colors::RESET, RESPONSE); + return false; + } + return true; +} diff --git a/hyprtester/src/tests/plugin/plugin.hpp b/hyprtester/src/tests/plugin/plugin.hpp new file mode 100644 index 000000000..bd93c0877 --- /dev/null +++ b/hyprtester/src/tests/plugin/plugin.hpp @@ -0,0 +1,3 @@ +#pragma once + +bool testPlugin(); \ No newline at end of file diff --git a/hyprtester/src/tests/shared.cpp b/hyprtester/src/tests/shared.cpp new file mode 100644 index 000000000..b7aa325cd --- /dev/null +++ b/hyprtester/src/tests/shared.cpp @@ -0,0 +1,92 @@ +#include "shared.hpp" +#include +#include +#include +#include +#include "../shared.hpp" +#include "../hyprctlCompat.hpp" + +using namespace Hyprutils::OS; +using namespace Hyprutils::Memory; + +CUniquePointer Tests::spawnKitty(const std::string& class_) { + const auto COUNT_BEFORE = windowCount(); + + CUniquePointer kitty = makeUnique("kitty", class_.empty() ? std::vector{} : std::vector{"--class", class_}); + kitty->addEnv("WAYLAND_DISPLAY", WLDISPLAY); + kitty->runAsync(); + + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + + // wait while kitty spawns + int counter = 0; + while (processAlive(kitty->pid()) && windowCount() == COUNT_BEFORE) { + counter++; + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + if (counter > 50) + return nullptr; + } + + if (!processAlive(kitty->pid())) + return nullptr; + + return kitty; +} + +bool Tests::processAlive(pid_t pid) { + errno = 0; + int ret = kill(pid, 0); + return ret != -1 || errno != ESRCH; +} + +int Tests::windowCount() { + return countOccurrences(getFromSocket("/clients"), "focusHistoryID: "); +} + +int Tests::countOccurrences(const std::string& in, const std::string& what) { + int cnt = 0; + auto pos = in.find(what); + while (pos != std::string::npos) { + cnt++; + pos = in.find(what, pos + what.length() - 1); + } + + return cnt; +} + +bool Tests::killAllWindows() { + auto str = getFromSocket("/clients"); + auto pos = str.find("Window "); + while (pos != std::string::npos) { + auto pos2 = str.find(" -> ", pos); + getFromSocket("/dispatch killwindow address:0x" + str.substr(pos + 7, pos2 - pos - 7)); + pos = str.find("Window ", pos + 5); + } + + int counter = 0; + while (Tests::windowCount() != 0) { + counter++; + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + if (counter > 50) { + std::println("{}Timed out waiting for windows to close", Colors::RED); + return false; + } + } + + return true; +} + +void Tests::waitUntilWindowsN(int n) { + int counter = 0; + while (Tests::windowCount() != n) { + counter++; + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + if (counter > 50) { + std::println("{}Timed out waiting for windows", Colors::RED); + return; + } + } +} diff --git a/hyprtester/src/tests/shared.hpp b/hyprtester/src/tests/shared.hpp new file mode 100644 index 000000000..ef0b9a60e --- /dev/null +++ b/hyprtester/src/tests/shared.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include +#include +#include + +#include "../Log.hpp" + +//NOLINTNEXTLINE +namespace Tests { + Hyprutils::Memory::CUniquePointer spawnKitty(const std::string& class_ = ""); + bool processAlive(pid_t pid); + int windowCount(); + int countOccurrences(const std::string& in, const std::string& what); + bool killAllWindows(); + void waitUntilWindowsN(int n); +}; diff --git a/hyprtester/test.conf b/hyprtester/test.conf new file mode 100644 index 000000000..60fd43fb1 --- /dev/null +++ b/hyprtester/test.conf @@ -0,0 +1,305 @@ +# This is an example Hyprland config file. +# Refer to the wiki for more information. +# https://wiki.hyprland.org/Configuring/ + +# Please note not all available settings / options are set here. +# For a full list, see the wiki + +# You can split this configuration into multiple files +# Create your files separately and then link them to this file like this: +# source = ~/.config/hypr/myColors.conf + + +################ +### MONITORS ### +################ + +# See https://wiki.hyprland.org/Configuring/Monitors/ + +monitor=HEADLESS-1,1920x1080@60,auto-right,1 +monitor=HEADLESS-2,1920x1080@60,auto-right,1 +monitor=HEADLESS-3,1920x1080@60,auto-right,1 +monitor=HEADLESS-4,1920x1080@60,auto-right,1 +monitor=HEADLESS-5,1920x1080@60,auto-right,1 +monitor=HEADLESS-6,1920x1080@60,auto-right,1 + +monitor=,disabled + + +################### +### MY PROGRAMS ### +################### + +# See https://wiki.hyprland.org/Configuring/Keywords/ + +# Set programs that you use +$terminal = kitty +$fileManager = dolphin +$menu = wofi --show drun + + +################# +### AUTOSTART ### +################# + +# Autostart necessary processes (like notifications daemons, status bars, etc.) +# Or execute your favorite apps at launch like this: + +# exec-once = $terminal +# exec-once = nm-applet & +# exec-once = waybar & hyprpaper & firefox + + +############################# +### ENVIRONMENT VARIABLES ### +############################# + +# See https://wiki.hyprland.org/Configuring/Environment-variables/ + +env = XCURSOR_SIZE,24 +env = HYPRCURSOR_SIZE,24 + + +##################### +### LOOK AND FEEL ### +##################### + +# Refer to https://wiki.hyprland.org/Configuring/Variables/ + +# https://wiki.hyprland.org/Configuring/Variables/#general +general { + gaps_in = 5 + gaps_out = 20 + + border_size = 2 + + # https://wiki.hyprland.org/Configuring/Variables/#variable-types for info about colors + col.active_border = rgba(33ccffee) rgba(00ff99ee) 45deg + col.inactive_border = rgba(595959aa) + + # Set to true enable resizing windows by clicking and dragging on borders and gaps + resize_on_border = false + + # Please see https://wiki.hyprland.org/Configuring/Tearing/ before you turn this on + allow_tearing = false + + layout = dwindle +} + +# https://wiki.hyprland.org/Configuring/Variables/#decoration +decoration { + rounding = 10 + rounding_power = 2 + + # Change transparency of focused and unfocused windows + active_opacity = 1.0 + inactive_opacity = 1.0 + + shadow { + enabled = true + range = 4 + render_power = 3 + color = rgba(1a1a1aee) + } + + # https://wiki.hyprland.org/Configuring/Variables/#blur + blur { + enabled = true + size = 3 + passes = 1 + + vibrancy = 0.1696 + } +} + +# https://wiki.hyprland.org/Configuring/Variables/#animations +animations { + enabled = 0 + + # Default animations, see https://wiki.hyprland.org/Configuring/Animations/ for more + + bezier = easeOutQuint,0.23,1,0.32,1 + bezier = easeInOutCubic,0.65,0.05,0.36,1 + bezier = linear,0,0,1,1 + bezier = almostLinear,0.5,0.5,0.75,1.0 + bezier = quick,0.15,0,0.1,1 + + animation = global, 1, 10, default + animation = border, 1, 5.39, easeOutQuint + animation = windows, 1, 4.79, easeOutQuint + animation = windowsIn, 1, 4.1, easeOutQuint, popin 87% + animation = windowsOut, 1, 1.49, linear, popin 87% + animation = fadeIn, 1, 1.73, almostLinear + animation = fadeOut, 1, 1.46, almostLinear + animation = fade, 1, 3.03, quick + animation = layers, 1, 3.81, easeOutQuint + animation = layersIn, 1, 4, easeOutQuint, fade + animation = layersOut, 1, 1.5, linear, fade + animation = fadeLayersIn, 1, 1.79, almostLinear + animation = fadeLayersOut, 1, 1.39, almostLinear + animation = workspaces, 1, 1.94, almostLinear, fade + animation = workspacesIn, 1, 1.21, almostLinear, fade + animation = workspacesOut, 1, 1.94, almostLinear, fade +} + +# Ref https://wiki.hyprland.org/Configuring/Workspace-Rules/ +# "Smart gaps" / "No gaps when only" +# uncomment all if you wish to use that. +# workspace = w[tv1], gapsout:0, gapsin:0 +# workspace = f[1], gapsout:0, gapsin:0 +# windowrulev2 = bordersize 0, floating:0, onworkspace:w[tv1] +# windowrulev2 = rounding 0, floating:0, onworkspace:w[tv1] +# windowrulev2 = bordersize 0, floating:0, onworkspace:f[1] +# windowrulev2 = rounding 0, floating:0, onworkspace:f[1] + +# See https://wiki.hyprland.org/Configuring/Dwindle-Layout/ for more +dwindle { + pseudotile = true # Master switch for pseudotiling. Enabling is bound to mainMod + P in the keybinds section below + preserve_split = true # You probably want this +} + +# See https://wiki.hyprland.org/Configuring/Master-Layout/ for more +master { + new_status = master +} + +# https://wiki.hyprland.org/Configuring/Variables/#misc +misc { + force_default_wallpaper = -1 # Set to 0 or 1 to disable the anime mascot wallpapers + disable_hyprland_logo = false # If true disables the random hyprland logo / anime girl background. :( +} + + +############# +### INPUT ### +############# + +# https://wiki.hyprland.org/Configuring/Variables/#input +input { + kb_layout = us + kb_variant = + kb_model = + kb_options = + kb_rules = + + follow_mouse = 1 + + sensitivity = 0 # -1.0 - 1.0, 0 means no modification. + + touchpad { + natural_scroll = false + } +} + +# https://wiki.hyprland.org/Configuring/Variables/#gestures +gestures { + workspace_swipe = false +} + +# Example per-device config +# See https://wiki.hyprland.org/Configuring/Keywords/#per-device-input-configs for more +device { + name = epic-mouse-v1 + sensitivity = -0.5 +} + + +################### +### KEYBINDINGS ### +################### + +# See https://wiki.hyprland.org/Configuring/Keywords/ +$mainMod = SUPER # Sets "Windows" key as main modifier + +# Example binds, see https://wiki.hyprland.org/Configuring/Binds/ for more +bind = $mainMod, Q, exec, $terminal +bind = $mainMod, C, killactive, +bind = $mainMod, M, exit, +bind = $mainMod, E, exec, $fileManager +bind = $mainMod, V, togglefloating, +bind = $mainMod, R, exec, $menu +bind = $mainMod, P, pseudo, # dwindle +bind = $mainMod, J, togglesplit, # dwindle + +# Move focus with mainMod + arrow keys +bind = $mainMod, left, movefocus, l +bind = $mainMod, right, movefocus, r +bind = $mainMod, up, movefocus, u +bind = $mainMod, down, movefocus, d + +# Switch workspaces with mainMod + [0-9] +bind = $mainMod, 1, workspace, 1 +bind = $mainMod, 2, workspace, 2 +bind = $mainMod, 3, workspace, 3 +bind = $mainMod, 4, workspace, 4 +bind = $mainMod, 5, workspace, 5 +bind = $mainMod, 6, workspace, 6 +bind = $mainMod, 7, workspace, 7 +bind = $mainMod, 8, workspace, 8 +bind = $mainMod, 9, workspace, 9 +bind = $mainMod, 0, workspace, 10 + +# Move active window to a workspace with mainMod + SHIFT + [0-9] +bind = $mainMod SHIFT, 1, movetoworkspace, 1 +bind = $mainMod SHIFT, 2, movetoworkspace, 2 +bind = $mainMod SHIFT, 3, movetoworkspace, 3 +bind = $mainMod SHIFT, 4, movetoworkspace, 4 +bind = $mainMod SHIFT, 5, movetoworkspace, 5 +bind = $mainMod SHIFT, 6, movetoworkspace, 6 +bind = $mainMod SHIFT, 7, movetoworkspace, 7 +bind = $mainMod SHIFT, 8, movetoworkspace, 8 +bind = $mainMod SHIFT, 9, movetoworkspace, 9 +bind = $mainMod SHIFT, 0, movetoworkspace, 10 + +# Example special workspace (scratchpad) +bind = $mainMod, S, togglespecialworkspace, magic +bind = $mainMod SHIFT, S, movetoworkspace, special:magic + +# Scroll through existing workspaces with mainMod + scroll +bind = $mainMod, mouse_down, workspace, e+1 +bind = $mainMod, mouse_up, workspace, e-1 + +# Move/resize windows with mainMod + LMB/RMB and dragging +bindm = $mainMod, mouse:272, movewindow +bindm = $mainMod, mouse:273, resizewindow + +# Laptop multimedia keys for volume and LCD brightness +bindel = ,XF86AudioRaiseVolume, exec, wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+ +bindel = ,XF86AudioLowerVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%- +bindel = ,XF86AudioMute, exec, wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle +bindel = ,XF86AudioMicMute, exec, wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle +bindel = ,XF86MonBrightnessUp, exec, brightnessctl s 10%+ +bindel = ,XF86MonBrightnessDown, exec, brightnessctl s 10%- + +# Requires playerctl +bindl = , XF86AudioNext, exec, playerctl next +bindl = , XF86AudioPause, exec, playerctl play-pause +bindl = , XF86AudioPlay, exec, playerctl play-pause +bindl = , XF86AudioPrev, exec, playerctl previous + +############################## +### WINDOWS AND WORKSPACES ### +############################## + +# See https://wiki.hyprland.org/Configuring/Window-Rules/ for more +# See https://wiki.hyprland.org/Configuring/Workspace-Rules/ for workspace rules + +# Example windowrule v1 +# windowrule = float, ^(kitty)$ + +# Example windowrule v2 +# windowrulev2 = float,class:^(kitty)$,title:^(kitty)$ + +# Ignore maximize requests from apps. You'll probably like this. +windowrulev2 = suppressevent maximize, class:.* + +# Fix some dragging issues with XWayland +windowrulev2 = nofocus,class:^$,title:^$,xwayland:1,floating:1,fullscreen:0,pinned:0 + +# Workspace "windows" is a smart gaps one +workspace = n[s:window] w[tv1], gapsout:0, gapsin:0 +workspace = n[s:window] f[1], gapsout:0, gapsin:0 +windowrulev2 = bordersize 0, floating:0, onworkspace:n[s:window] w[tv1] +windowrulev2 = rounding 0, floating:0, onworkspace:n[s:window] w[tv1] +windowrulev2 = bordersize 0, floating:0, onworkspace:n[s:window] f[1] +windowrulev2 = rounding 0, floating:0, onworkspace:n[s:window] f[1] \ No newline at end of file diff --git a/nix/hyprtester.nix b/nix/hyprtester.nix new file mode 100644 index 000000000..7b729427b --- /dev/null +++ b/nix/hyprtester.nix @@ -0,0 +1,58 @@ +{ + lib, + stdenv, + stdenvAdapters, + cmake, + pkg-config, + hyprland, + hyprwayland-scanner, + version ? "git", +}: let + inherit (lib.lists) flatten foldl'; + inherit (lib.sources) cleanSourceWith cleanSource; + inherit (lib.strings) hasSuffix; + + adapters = flatten [ + stdenvAdapters.useMoldLinker + stdenvAdapters.keepDebugInfo + ]; + + customStdenv = foldl' (acc: adapter: adapter acc) stdenv adapters; +in + customStdenv.mkDerivation (finalAttrs: { + pname = "hyprtester"; + inherit version; + + src = cleanSourceWith { + filter = name: _type: let + baseName = baseNameOf (toString name); + in + ! (hasSuffix ".nix" baseName); + src = cleanSource ../.; + }; + + nativeBuildInputs = [ + cmake + pkg-config + hyprwayland-scanner + ]; + + buildInputs = hyprland.buildInputs; + + preConfigure = '' + cmake -S . -B . + cmake --build . --target generate-protocol-headers -j`nproc 2>/dev/null || getconf NPROCESSORS_CONF` + + cd hyprtester + ''; + + cmakeBuildType = "Debug"; + + meta = { + homepage = "https://github.com/hyprwm/Hyprland"; + description = "Hyprland testing framework"; + license = lib.licenses.bsd3; + platforms = hyprland.meta.platforms; + mainProgram = "hyprtester"; + }; + }) diff --git a/nix/overlays.nix b/nix/overlays.nix index 9165fb3c5..18a524642 100644 --- a/nix/overlays.nix +++ b/nix/overlays.nix @@ -8,7 +8,7 @@ (builtins.substring 4 2 longDate) (builtins.substring 6 2 longDate) ]); - version = lib.removeSuffix "\n" (builtins.readFile ../VERSION); + ver = lib.removeSuffix "\n" (builtins.readFile ../VERSION); in { # Contains what a user is most likely to care about: # Hyprland itself, XDPH and the Share Picker. @@ -33,13 +33,13 @@ in { # Hyprland packages themselves (final: _prev: let date = mkDate (self.lastModifiedDate or "19700101"); + version = "${ver}+date=${date}_${self.shortRev or "dirty"}"; in { hyprland = final.callPackage ./default.nix { stdenv = final.gcc15Stdenv; - version = "${version}+date=${date}_${self.shortRev or "dirty"}"; commit = self.rev or ""; revCount = self.sourceInfo.revCount or ""; - inherit date; + inherit date version; }; hyprland-unwrapped = final.hyprland.override {wrapRuntimeDeps = false;}; @@ -50,6 +50,10 @@ in { debug = true; }; + hyprtester = final.callPackage ./hyprtester.nix { + inherit version; + }; + # deprecated packages hyprland-legacy-renderer = builtins.trace '' @@ -74,6 +78,18 @@ in { }) ]; + # Debug + hyprland-debug = lib.composeManyExtensions [ + # Dependencies + self.overlays.hyprland-packages + + (final: prev: { + aquamarine = prev.aquamarine.override {debug = true;}; + hyprutils = prev.hyprutils.override {debug = true;}; + hyprland-debug = prev.hyprland.override {debug = true;}; + }) + ]; + # Packages for extra software recommended for usage with Hyprland, # including forked or patched packages for compatibility. hyprland-extras = lib.composeManyExtensions [ diff --git a/nix/tests/default.nix b/nix/tests/default.nix new file mode 100644 index 000000000..4d71aec46 --- /dev/null +++ b/nix/tests/default.nix @@ -0,0 +1,86 @@ +inputs: pkgs: let + flake = inputs.self.packages.${pkgs.stdenv.hostPlatform.system}; + hyprland = flake.hyprland; +in { + tests = pkgs.testers.runNixOSTest { + name = "hyprland-tests"; + + nodes.machine = {pkgs, ...}: { + environment.systemPackages = with pkgs; [ + flake.hyprtester + + # Programs needed for tests + kitty + xorg.xeyes + ]; + + # Enabled by default for some reason + services.speechd.enable = false; + + environment.variables = { + "AQ_TRACE" = "1"; + "HYPRLAND_TRACE" = "1"; + "XDG_RUNTIME_DIR" = "/tmp"; + "XDG_CACHE_HOME" = "/tmp"; + }; + + programs.hyprland = { + enable = true; + package = hyprland; + # We don't need portals in this test, so we don't set portalPackage + }; + + # Test configuration + environment.etc."test.conf".source = "${flake.hyprtester}/share/hypr/test.conf"; + + # Disable portals + xdg.portal.enable = pkgs.lib.mkForce false; + + # Autologin root into tty + services.getty.autologinUser = "alice"; + + system.stateVersion = "24.11"; + + users.users.alice = { + isNormalUser = true; + }; + + virtualisation = { + cores = 4; + # Might crash with less + memorySize = 8192; + resolution = { + x = 1920; + y = 1080; + }; + + # Doesn't seem to do much, thought it would fix XWayland crashing + qemu.options = ["-vga none -device virtio-gpu-pci"]; + }; + }; + + testScript = '' + # Wait for tty to be up + machine.wait_for_unit("multi-user.target") + + # Run hyprtester testing framework/suite + print("Running hyprtester") + exit_status, _out = machine.execute("su - alice -c 'hyprtester -b ${hyprland}/bin/Hyprland -c /etc/test.conf -p ${flake.hyprtester}/lib/hyprtestplugin.so 2>&1 | tee /tmp/testerlog; exit ''${PIPESTATUS[0]}'") + print(f"Hyprtester exited with {exit_status}") + + # Copy logs to host + machine.execute('cp "$(find /tmp/hypr -name *.log | head -1)" /tmp/hyprlog') + machine.execute(f'echo {exit_status} > /tmp/exit_status') + machine.copy_from_vm("/tmp/testerlog") + machine.copy_from_vm("/tmp/hyprlog") + machine.copy_from_vm("/tmp/exit_status") + + # Print logs for visibility in CI + _, out = machine.execute("cat /tmp/testerlog") + print(f"Hyprtester log:\n{out}") + + # Finally - shutdown + machine.shutdown() + ''; + }; +}