summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/nix-build.yml8
-rw-r--r--.github/workflows/nix-ci.yml6
-rw-r--r--.github/workflows/nix-test.yml59
-rw-r--r--CMakeLists.txt26
-rw-r--r--flake.lock6
-rw-r--r--flake.nix24
-rw-r--r--hyprtester/CMakeLists.txt30
-rw-r--r--hyprtester/plugin/Makefile16
-rwxr-xr-xhyprtester/plugin/build.sh4
-rw-r--r--hyprtester/plugin/src/globals.hpp5
-rw-r--r--hyprtester/plugin/src/main.cpp43
-rw-r--r--hyprtester/src/Log.hpp17
-rw-r--r--hyprtester/src/hyprctlCompat.cpp136
-rw-r--r--hyprtester/src/hyprctlCompat.hpp16
-rw-r--r--hyprtester/src/main.cpp251
-rw-r--r--hyprtester/src/shared.hpp90
-rw-r--r--hyprtester/src/tests/main/groups.cpp177
-rw-r--r--hyprtester/src/tests/main/misc.cpp142
-rw-r--r--hyprtester/src/tests/main/tests.hpp6
-rw-r--r--hyprtester/src/tests/main/window.cpp96
-rw-r--r--hyprtester/src/tests/main/workspaces.cpp349
-rw-r--r--hyprtester/src/tests/plugin/plugin.cpp21
-rw-r--r--hyprtester/src/tests/plugin/plugin.hpp3
-rw-r--r--hyprtester/src/tests/shared.cpp92
-rw-r--r--hyprtester/src/tests/shared.hpp17
-rw-r--r--hyprtester/test.conf305
-rw-r--r--nix/hyprtester.nix58
-rw-r--r--nix/overlays.nix22
-rw-r--r--nix/tests/default.nix86
29 files changed, 2096 insertions, 15 deletions
diff --git a/.github/workflows/nix-build.yml b/.github/workflows/nix-build.yml
index 55ac485e..7c61d775 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 75c19790..cf5430ac 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 00000000..a4e32b56
--- /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 365b08d5..1079e0c8 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 e0071ff6..de187cfd 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 dde2860c..cc4d87fc 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 00000000..10907ce7
--- /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 00000000..e77921d4
--- /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 00000000..5bb5d017
--- /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 00000000..37e8363b
--- /dev/null
+++ b/hyprtester/plugin/src/globals.hpp
@@ -0,0 +1,5 @@
+#pragma once
+
+#include <src/plugins/PluginAPI.hpp>
+
+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 00000000..1a7136d9
--- /dev/null
+++ b/hyprtester/plugin/src/main.cpp
@@ -0,0 +1,43 @@
+#include <unistd.h>
+#include <src/includes.hpp>
+#include <sstream>
+#include <any>
+
+#define private public
+#include <src/config/ConfigManager.hpp>
+#include <src/config/ConfigDescriptions.hpp>
+#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 00000000..7eeb3ece
--- /dev/null
+++ b/hyprtester/src/Log.hpp
@@ -0,0 +1,17 @@
+#pragma once
+#include <string>
+#include <format>
+#include <print>
+
+namespace NLog {
+ template <typename... Args>
+ //NOLINTNEXTLINE
+ void log(std::format_string<Args...> 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 00000000..6e6cfc0f
--- /dev/null
+++ b/hyprtester/src/hyprctlCompat.cpp
@@ -0,0 +1,136 @@
+#include "hyprctlCompat.hpp"
+#include "shared.hpp"
+
+#include <pwd.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <sys/un.h>
+#include <unistd.h>
+
+#include <filesystem>
+#include <fstream>
+#include <algorithm>
+#include <csignal>
+#include <cerrno>
+#include <print>
+
+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<SInstanceData> instances() {
+ std::vector<SInstanceData> 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 00000000..bf4ce2a7
--- /dev/null
+++ b/hyprtester/src/hyprctlCompat.hpp
@@ -0,0 +1,16 @@
+#pragma once
+
+#include <vector>
+#include <string>
+#include <cstdint>
+
+struct SInstanceData {
+ std::string id;
+ uint64_t time;
+ uint64_t pid;
+ std::string wlSocket;
+ bool valid = true;
+};
+
+std::vector<SInstanceData> 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 00000000..176eb702
--- /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 <filesystem>
+#include <hyprutils/os/Process.hpp>
+#include <hyprutils/memory/WeakPtr.hpp>
+#include <csignal>
+#include <cerrno>
+#include <chrono>
+#include <thread>
+#include <print>
+
+#include "Log.hpp"
+
+using namespace Hyprutils::OS;
+using namespace Hyprutils::Memory;
+
+#define SP CSharedPointer
+
+static int ret = 0;
+static SP<CProcess> 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<CProcess>(binaryPath, std::vector<std::string>{"--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<std::string> 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 00000000..cfce1ba7
--- /dev/null
+++ b/hyprtester/src/shared.hpp
@@ -0,0 +1,90 @@
+// Stolen from hyprutils
+
+#pragma once
+#include <iostream>
+
+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 00000000..ed8e4ba4
--- /dev/null
+++ b/hyprtester/src/tests/main/groups.cpp
@@ -0,0 +1,177 @@
+#include "tests.hpp"
+#include "../../shared.hpp"
+#include "../../hyprctlCompat.hpp"
+#include <print>
+#include <thread>
+#include <chrono>
+#include <hyprutils/os/Process.hpp>
+#include <hyprutils/memory/WeakPtr.hpp>
+#include <csignal>
+#include <cerrno>
+#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 00000000..db0d9092
--- /dev/null
+++ b/hyprtester/src/tests/main/misc.cpp
@@ -0,0 +1,142 @@
+#include "tests.hpp"
+#include "../../shared.hpp"
+#include "../../hyprctlCompat.hpp"
+#include <print>
+#include <thread>
+#include <chrono>
+#include <hyprutils/os/Process.hpp>
+#include <hyprutils/memory/WeakPtr.hpp>
+#include <csignal>
+#include <cerrno>
+#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 00000000..accde951
--- /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 00000000..4eecd68d
--- /dev/null
+++ b/hyprtester/src/tests/main/window.cpp
@@ -0,0 +1,96 @@
+#include "tests.hpp"
+#include "../../shared.hpp"
+#include "../../hyprctlCompat.hpp"
+#include <print>
+#include <thread>
+#include <chrono>
+#include <hyprutils/os/Process.hpp>
+#include <hyprutils/memory/WeakPtr.hpp>
+#include <csignal>
+#include <cerrno>
+#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 00000000..ccacff28
--- /dev/null
+++ b/hyprtester/src/tests/main/workspaces.cpp
@@ -0,0 +1,349 @@
+#include "tests.hpp"
+#include "../../shared.hpp"
+#include "../../hyprctlCompat.hpp"
+#include <print>
+#include <thread>
+#include <chrono>
+#include <hyprutils/os/Process.hpp>
+#include <hyprutils/memory/WeakPtr.hpp>
+#include <csignal>
+#include <cerrno>
+#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 00000000..94470d6f
--- /dev/null
+++ b/hyprtester/src/tests/plugin/plugin.cpp
@@ -0,0 +1,21 @@
+#include "plugin.hpp"
+#include "../../shared.hpp"
+#include "../../hyprctlCompat.hpp"
+#include <print>
+#include <thread>
+#include <chrono>
+#include <hyprutils/os/Process.hpp>
+#include <hyprutils/memory/WeakPtr.hpp>
+#include <csignal>
+#include <cerrno>
+#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 00000000..bd93c087
--- /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 00000000..b7aa325c
--- /dev/null
+++ b/hyprtester/src/tests/shared.cpp
@@ -0,0 +1,92 @@
+#include "shared.hpp"
+#include <csignal>
+#include <cerrno>
+#include <thread>
+#include <print>
+#include "../shared.hpp"
+#include "../hyprctlCompat.hpp"
+
+using namespace Hyprutils::OS;
+using namespace Hyprutils::Memory;
+
+CUniquePointer<CProcess> Tests::spawnKitty(const std::string& class_) {
+ const auto COUNT_BEFORE = windowCount();
+
+ CUniquePointer<CProcess> kitty = makeUnique<CProcess>("kitty", class_.empty() ? std::vector<std::string>{} : std::vector<std::string>{"--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 00000000..ef0b9a60
--- /dev/null
+++ b/hyprtester/src/tests/shared.hpp
@@ -0,0 +1,17 @@
+#pragma once
+
+#include <hyprutils/os/Process.hpp>
+#include <hyprutils/memory/WeakPtr.hpp>
+#include <sys/types.h>
+
+#include "../Log.hpp"
+
+//NOLINTNEXTLINE
+namespace Tests {
+ Hyprutils::Memory::CUniquePointer<Hyprutils::OS::CProcess> 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 00000000..60fd43fb
--- /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 00000000..7b729427
--- /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 9165fb3c..18a52464 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 00000000..4d71aec4
--- /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()
+ '';
+ };
+}