diff --git a/.github/workflows/nix.yml b/.github/workflows/nix-build.yml
similarity index 85%
rename from .github/workflows/nix.yml
rename to .github/workflows/nix-build.yml
index 94091468..199fa7b0 100644
--- a/.github/workflows/nix.yml
+++ b/.github/workflows/nix-build.yml
@@ -1,8 +1,13 @@
-name: Build
+name: Build (Nix)
+
+on:
+ workflow_call:
+ secrets:
+ CACHIX_AUTH_TOKEN:
+ required: false
-on: [push, pull_request, workflow_dispatch]
jobs:
- nix:
+ build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
@@ -42,7 +47,6 @@ jobs:
# with:
# name: hyprland
# authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
-
+ #
- name: Build
- run: nix flake check --print-build-logs --keep-going
-
+ run: nix build 'github:${{ github.repository }}?ref=${{ github.ref }}' -L --extra-substituters "https://hyprland.cachix.org"
diff --git a/.github/workflows/nix-ci.yml b/.github/workflows/nix-ci.yml
new file mode 100644
index 00000000..c07ae5ad
--- /dev/null
+++ b/.github/workflows/nix-ci.yml
@@ -0,0 +1,15 @@
+name: Nix
+
+on: [push, pull_request, workflow_dispatch]
+
+jobs:
+ hyprlock:
+ 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: hyprlock
+ 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..df1e74f0
--- /dev/null
+++ b/.github/workflows/nix-test.yml
@@ -0,0 +1,66 @@
+name: Test (Nix)
+
+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:${{ github.repository }}?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 logs
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: logs
+ path: result/logs
+
+ - name: Upload traces
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: traces
+ path: result/traces
diff --git a/CMakeLists.txt b/CMakeLists.txt
index b3d6e4b2..774d990a 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -162,3 +162,13 @@ install(
FILES ${CMAKE_SOURCE_DIR}/assets/example.conf
DESTINATION ${CMAKE_INSTALL_FULL_DATAROOTDIR}/hypr
RENAME hyprlock.conf)
+
+if(TESTS)
+ include(CTest)
+ message(STATUS "Building hyprlock test meta package")
+
+ enable_testing()
+ add_custom_target(tests)
+
+ add_subdirectory(tests)
+endif()
diff --git a/flake.lock b/flake.lock
index 449743c9..bcbf85a0 100644
--- a/flake.lock
+++ b/flake.lock
@@ -13,11 +13,11 @@
]
},
"locked": {
- "lastModified": 1758572180,
- "narHash": "sha256-Is8Rcp99Ynl3JFcU3k2lsmyf8WGacWKZtnVb0mVIZ6M=",
+ "lastModified": 1759490292,
+ "narHash": "sha256-T6iWzDOXp8Wv0KQOCTHpBcmAOdHJ6zc/l9xaztW6Ivc=",
"owner": "hyprwm",
"repo": "hyprgraphics",
- "rev": "32e6b8386f7dc70a4cc01607a826a281f3c52364",
+ "rev": "9431db625cd9bb66ac55525479dce694101d6d7a",
"type": "github"
},
"original": {
@@ -39,11 +39,11 @@
]
},
"locked": {
- "lastModified": 1756810301,
- "narHash": "sha256-wgZ3VW4VVtjK5dr0EiK9zKdJ/SOqGIBXVG85C3LVxQA=",
+ "lastModified": 1758927902,
+ "narHash": "sha256-LZgMds7M94+vuMql2bERQ6LiFFdhgsEFezE4Vn+Ys3A=",
"owner": "hyprwm",
"repo": "hyprlang",
- "rev": "3d63fb4a42c819f198deabd18c0c2c1ded1de931",
+ "rev": "4dafa28d4f79877d67a7d1a654cddccf8ebf15da",
"type": "github"
},
"original": {
@@ -62,11 +62,11 @@
]
},
"locked": {
- "lastModified": 1756117388,
- "narHash": "sha256-oRDel6pNl/T2tI+nc/USU9ZP9w08dxtl7hiZxa0C/Wc=",
+ "lastModified": 1759619523,
+ "narHash": "sha256-r1ed7AR2ZEb2U8gy321/Xcp1ho2tzn+gG1te/Wxsj1A=",
"owner": "hyprwm",
"repo": "hyprutils",
- "rev": "b2ae3204845f5f2f79b4703b441252d8ad2ecfd0",
+ "rev": "3df7bde01efb3a3e8e678d1155f2aa3f19e177ef",
"type": "github"
},
"original": {
@@ -100,11 +100,12 @@
},
"nixpkgs": {
"locked": {
- "lastModified": 1757745802,
- "narHash": "sha256-hLEO2TPj55KcUFUU1vgtHE9UEIOjRcH/4QbmfHNF820=",
- "path": "/nix/store/lvwgkdy9nrki8qcrdsqnpfrk7562m1dc-source",
- "rev": "c23193b943c6c689d70ee98ce3128239ed9e32d1",
- "type": "path"
+ "lastModified": 1759831965,
+ "narHash": "sha256-vgPm2xjOmKdZ0xKA6yLXPJpjOtQPHfaZDRtH+47XEBo=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "c9b6fb798541223bbb396d287d16f43520250518",
+ "type": "github"
},
"original": {
"owner": "NixOS",
diff --git a/flake.nix b/flake.nix
index 1cc9a00d..5e34668c 100644
--- a/flake.nix
+++ b/flake.nix
@@ -43,7 +43,12 @@
pkgsFor = eachSystem (system:
import nixpkgs {
localSystem.system = system;
- overlays = with self.overlays; [default];
+ overlays = [self.overlays.default];
+ });
+ pkgsDebugFor = eachSystem (system:
+ import nixpkgs {
+ localSystem = system;
+ overlays = [self.overlays.hyprlock-debug];
});
in {
overlays = import ./nix/overlays.nix {inherit inputs lib self;};
@@ -51,6 +56,7 @@
packages = eachSystem (system: {
default = self.packages.${system}.hyprlock;
inherit (pkgsFor.${system}) hyprlock;
+ inherit (pkgsDebugFor.${system}) hyprlock-debug hyprlock-test-meta;
});
homeManagerModules = {
@@ -58,7 +64,7 @@
hyprlock = builtins.throw "hyprlock: the flake HM module has been removed. Use the module from Home Manager upstream.";
};
- checks = eachSystem (system: self.packages.${system});
+ checks = eachSystem (system: self.packages.${system} // (import ./nix/tests/default.nix inputs pkgsFor.${system}));
formatter = eachSystem (system: pkgsFor.${system}.alejandra);
};
diff --git a/nix/default.nix b/nix/default.nix
index d3c51050..fb038cc4 100644
--- a/nix/default.nix
+++ b/nix/default.nix
@@ -1,6 +1,7 @@
{
lib,
stdenv,
+ stdenvAdapters,
cmake,
pkg-config,
cairo,
@@ -19,14 +20,33 @@
wayland,
wayland-protocols,
wayland-scanner,
+ debug ? false,
version ? "git",
shortRev ? "",
-}:
-stdenv.mkDerivation {
- pname = "hyprlock";
+}: let
+ inherit (builtins) foldl';
+ inherit (lib.lists) flatten;
+ inherit (lib.sources) cleanSourceWith cleanSource;
+ inherit (lib.strings) hasSuffix optionalString;
+
+ adapters = flatten [
+ stdenvAdapters.useMoldLinker
+ (lib.optional debug stdenvAdapters.keepDebugInfo)
+ ];
+
+ customStdenv = foldl' (acc: adapter: adapter acc) stdenv adapters;
+ in
+customStdenv.mkDerivation {
+ pname = "hyprlock${optionalString debug "-debug"}";
inherit version;
- src = ../.;
+ src = cleanSourceWith {
+ filter = name: _type: let
+ baseName = baseNameOf (toString name);
+ in
+ ! (hasSuffix ".nix" baseName);
+ src = cleanSource ../.;
+ };
nativeBuildInputs = [
cmake
@@ -57,6 +77,11 @@ stdenv.mkDerivation {
HYPRLOCK_VERSION_COMMIT = ""; # Intentionally left empty (hyprlock --version will always print the commit)
};
+ cmakeBuildType =
+ if debug
+ then "Debug"
+ else "Release";
+
meta = {
homepage = "https://github.com/hyprwm/hyprlock";
description = "A gpu-accelerated screen lock for Hyprland";
diff --git a/nix/overlays.nix b/nix/overlays.nix
index e3869422..3f71ffb2 100644
--- a/nix/overlays.nix
+++ b/nix/overlays.nix
@@ -29,6 +29,22 @@ in {
})
];
+ hyprlock-debug = lib.composeManyExtensions [
+ self.overlays.hyprlock
+ # Dependencies
+ (final: prev: {
+ hyprutils = prev.hyprutils.override {debug = true;};
+ hyprgraphics = prev.hyprgraphics.override {debug = true;};
+ hyprlock-debug = prev.hyprlock.override {debug = true;};
+ hyprlock-test-meta = prev.callPackage ./test-meta.nix {
+ stdenv = prev.gcc14Stdenv;
+ version = version + "+date=" + (mkDate (inputs.self.lastModifiedDate or "19700101")) + "_" + (inputs.self.shortRev or "dirty");
+ hyprland-protocols = final.hyprland-protocols;
+ wayland-scanner = final.wayland-scanner;
+ };
+ })
+ ];
+
sdbuscpp = final: prev: {
sdbus-cpp = prev.sdbus-cpp.overrideAttrs (self: super: {
version = "2.0.0";
diff --git a/nix/test-meta.nix b/nix/test-meta.nix
new file mode 100644
index 00000000..c217aacf
--- /dev/null
+++ b/nix/test-meta.nix
@@ -0,0 +1,45 @@
+{
+ cmake,
+ egl-wayland,
+ hyprland-protocols,
+ hyprlock,
+ hyprwayland-scanner,
+ lib,
+ pkg-config,
+ stdenv,
+ stdenvAdapters,
+ wayland-scanner,
+ version ? "git",
+}: let
+ inherit (lib.sources) cleanSourceWith cleanSource;
+ inherit (lib.strings) hasSuffix;
+in
+ stdenv.mkDerivation (finalAttrs: {
+ pname = "hyprlock-test-meta";
+ inherit version;
+
+ src = cleanSourceWith {
+ filter = name: _type: let
+ baseName = baseNameOf (toString name);
+ in
+ ! (hasSuffix ".nix" baseName);
+ src = cleanSource ../tests;
+ };
+
+ nativeBuildInputs = [
+ cmake
+ hyprland-protocols
+ hyprwayland-scanner
+ pkg-config
+ wayland-scanner
+ ];
+
+ buildInputs = hyprlock.buildInputs;
+
+ meta = {
+ homepage = "https://github.com/hyprwm/hyprlock";
+ description = "Hyprlock testing utility";
+ license = lib.licenses.bsd3;
+ platforms = hyprlock.meta.platforms;
+ };
+ })
diff --git a/nix/tests/default.nix b/nix/tests/default.nix
new file mode 100644
index 00000000..9a9306f6
--- /dev/null
+++ b/nix/tests/default.nix
@@ -0,0 +1,164 @@
+inputs: pkgs: let
+ inherit (pkgs) lib;
+ inherit (lib.lists) flatten;
+ flake = inputs.self.packages.${pkgs.stdenv.hostPlatform.system};
+
+ env = {
+ #"AQ_TRACE" = "1";
+ #"HYPRLAND_TRACE" = "1";
+ "HYPRLAND_HEADLESS_ONLY" = "1";
+ "XDG_RUNTIME_DIR" = "/tmp";
+ "XDG_CACHE_HOME" = "/tmp";
+ };
+
+ envAddToSystemdRun = lib.concatStringsSep " " (
+ lib.mapAttrsToList (k: v: "--setenv ${k}=${v} ") env
+ );
+
+ APITRACE_RECORD = true;
+ APITRACE_RECORD_PY = if APITRACE_RECORD then "True" else "False";
+in {
+ tests = pkgs.testers.runNixOSTest {
+ name = "hyprlock-tests";
+
+ nodes.machine = {pkgs, ...}: {
+ environment.systemPackages = with pkgs; flatten [
+ # Programs needed for tests
+ coreutils # date command
+ procps # pidof
+ (lib.optional APITRACE_RECORD apitrace)
+ ];
+
+ # Enabled by default for some reason
+ services.speechd.enable = false;
+
+ environment.variables = env;
+
+ programs.hyprland = {
+ enable = true;
+ #withUWSM = true
+ };
+
+ programs.hyprlock = {
+ enable = true;
+ package = flake.hyprlock;
+ };
+
+ networking.dhcpcd.enable = false;
+
+ # Disable portals
+ xdg.portal.enable = lib.mkForce false;
+
+ # Autologin root into tty
+ services.getty.autologinUser = "alice";
+
+ system.stateVersion = "24.11";
+
+ environment.etc."hyprlock/assets".source = "${flake.hyprlock-test-meta}/share/hypr/assets/";
+
+ users.users.alice = {
+ isNormalUser = true;
+ # password: abcdefghijklmnopqrstuvwxyz1234567890-=!@#$%^&*()_+[]{};\':\\"]\\|,./<>?`~äöüćńóśź
+ hashedPassword = "$y$j9T$s.atBE5..ISB2OoPWrXnU1$.8yaRmR9iBV9e.Q9wM1hG0ciMMYLGhpmDqsJo8Sjiv2";
+ };
+
+ 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 = ''
+ from pathlib import Path
+ # Wait for tty to be up
+ machine.wait_for_unit("multi-user.target")
+ # Startup Hyprland as the test compositor for hyprlock
+ print("Running Hyprland")
+ machine.execute("systemd-run -q -u hyprland --uid $(id -u alice) -p RuntimeMaxSec=60 ${envAddToSystemdRun} --setenv PATH=$PATH ${pkgs.hyprland}/bin/Hyprland -c ${flake.hyprlock-test-meta}/share/hypr/hyprland.conf")
+ machine.wait_for_file("/tmp/hyprland_exec_once_notification")
+ machine.execute("sleep 1") # slack just to be save
+
+ _, systeminfo = machine.execute("hyprctl --instance 0 systeminfo")
+ print(systeminfo)
+
+ test_files = [Path("${flake.hyprlock}/share/hypr/hyprlock.conf")] # also test the example configuration
+ test_files += list(Path("${flake.hyprlock-test-meta}/share/hypr/configs/").iterdir())
+ for hyprlock_config in test_files:
+ print(f"Testing configuration file {hyprlock_config}")
+ log_file_path = "/tmp/hyprlock_test_" + hyprlock_config.stem
+
+ hyprlock_cmd = f"hyprlock --config {str(hyprlock_config)} -v 2>&1 >{log_file_path}; echo $? > /tmp/exit_status"
+ if ${APITRACE_RECORD_PY}:
+ hyprlock_cmd = f"${lib.getExe' pkgs.apitrace "apitrace"} trace --output {log_file_path}.trace --api egl {hyprlock_cmd}"
+ machine.execute(f"hyprctl --instance 0 dispatch exec '{hyprlock_cmd}'")
+
+ wait_for_lock_exit_status, out = machine.execute("WAYLAND_DISPLAY=wayland-1 ${lib.getExe' flake.hyprlock-test-meta "wait-for-lock"}")
+ print(f"Wait for lock exit code: {wait_for_lock_exit_status}")
+ if wait_for_lock_exit_status != 0:
+ break
+
+ _, hyprlock_pid = machine.execute("pidof hyprlock")
+ print(f"Hyprlock pid {hyprlock_pid}")
+
+ # wrong password
+ machine.send_chars("asdf\n")
+
+ machine.execute("sleep 3") # default fail_timeout is 2 seconds
+
+ # correct password
+ machine.send_chars("abcdefghijklmnopqrstuvwxyz1234567890-=!@#$%^&*()_+[]{};':\"]\\|,./<>?`~")
+ machine.send_key("alt_r-a")
+ machine.send_key("alt_r-o")
+ machine.send_key("alt_r-u")
+ machine.send_key("alt_r-apostrophe")
+ machine.send_key("c")
+ machine.send_key("alt_r-apostrophe")
+ machine.send_key("n")
+ machine.send_key("alt_r-apostrophe")
+ machine.send_key("o")
+ machine.send_key("alt_r-apostrophe")
+ machine.send_key("s")
+ machine.send_key("alt_r-apostrophe")
+ machine.send_key("z")
+ machine.send_chars("\n")
+
+ machine.execute(f"waitpid {hyprlock_pid}")
+ _, exit_status = machine.execute("cat /tmp/exit_status")
+ print(f"Hyprlock exited with {exit_status}")
+
+ machine.copy_from_vm(log_file_path, "logs")
+ if ${APITRACE_RECORD_PY}:
+ machine.copy_from_vm(log_file_path + ".trace", "traces")
+
+ _, out = machine.execute(f"cat {log_file_path}")
+ print(f"Hyprlock log:\n{out}")
+ _, out = machine.execute(f"cat {log_file_path}")
+
+ if not exit_status or int(exit_status) != 0:
+ break
+
+
+ machine.execute("hyprctl --instance 0 dispatch exit")
+
+ _, exit_status = machine.execute("cat /tmp/exit_status")
+ # For the github runner, just to make sure wen don't accidentally succeed
+ if not exit_status.strip():
+ _, __ = machine.execute("echo 99 >/tmp/exit_status")
+ exit_status = "99"
+
+ machine.copy_from_vm("/tmp/exit_status")
+ assert int(exit_status) == 0, f"hyprlock exit code != 0 (exited with {exit_status})"
+
+ # Finally - shutdown
+ machine.shutdown()
+ '';
+ };
+}
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
new file mode 100644
index 00000000..81b562c3
--- /dev/null
+++ b/tests/CMakeLists.txt
@@ -0,0 +1,57 @@
+cmake_minimum_required(VERSION 3.27)
+
+project(hyprlock-test-meta DESCRIPTION "Package files used for hyprlock's integration tests")
+
+include(GNUInstallDirs)
+
+set(CMAKE_CXX_STANDARD 23)
+
+find_package(PkgConfig REQUIRED)
+find_package(hyprwayland-scanner 0.4.4 REQUIRED)
+
+pkg_check_modules(wfldeps REQUIRED IMPORTED_TARGET
+ hyprland-protocols>=0.6.0
+ hyprutils>=0.5.0
+ wayland-client
+ wayland-protocols>=1.35
+)
+
+add_executable(wait-for-lock "waitForLock.cpp")
+
+target_link_libraries(wait-for-lock PRIVATE PkgConfig::wfldeps)
+
+# protocols
+pkg_get_variable(WAYLAND_PROTOCOLS_DIR wayland-protocols pkgdatadir)
+pkg_get_variable(WAYLAND_SCANNER_PKGDATA_DIR wayland-scanner pkgdatadir)
+pkg_get_variable(HYPRLAND_PROTOCOLS hyprland-protocols pkgdatadir)
+message(STATUS "Found hyprland-protocols at ${HYPRLAND_PROTOCOLS}")
+
+make_directory(${CMAKE_SOURCE_DIR}/protocols)
+target_include_directories(wait-for-lock PRIVATE ${CMAKE_SOURCE_DIR}/protocols)
+
+# wayland client
+add_custom_command(
+ OUTPUT ${CMAKE_SOURCE_DIR}/protocols/wayland.cpp
+ ${CMAKE_SOURCE_DIR}/protocols/wayland.hpp
+ COMMAND hyprwayland-scanner --wayland-enums --client
+ ${WAYLAND_SCANNER_PKGDATA_DIR}/wayland.xml ${CMAKE_SOURCE_DIR}/protocols/)
+target_sources(wait-for-lock PRIVATE ${CMAKE_SOURCE_DIR}/protocols/wayland.cpp)
+
+# hyprland-lock-notify-v1
+add_custom_command(
+ OUTPUT ${CMAKE_SOURCE_DIR}/protocols/hyprland-lock-notify-v1.cpp
+ ${CMAKE_SOURCE_DIR}/protocols/hyprland-lock-notify-v1.hpp
+ COMMAND hyprwayland-scanner --client ${HYPRLAND_PROTOCOLS}/protocols/hyprland-lock-notify-v1.xml
+ ${CMAKE_SOURCE_DIR}/protocols/)
+target_sources(wait-for-lock PRIVATE ${CMAKE_SOURCE_DIR}/protocols/hyprland-lock-notify-v1.cpp)
+
+install(TARGETS wait-for-lock)
+
+install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/hyprland.conf
+ DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/hypr)
+
+file(GLOB_RECURSE TESTCONFIGS CONFIGURE_DEPENDS "configs/*.conf")
+install(FILES ${TESTCONFIGS} DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/hypr/configs)
+
+file(GLOB_RECURSE TESTCONFIGS CONFIGURE_DEPENDS "assets/*.png")
+install(FILES ${TESTCONFIGS} DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/hypr/assets)
diff --git a/tests/assets/avatar.png b/tests/assets/avatar.png
new file mode 100644
index 00000000..3fc7d336
Binary files /dev/null and b/tests/assets/avatar.png differ
diff --git a/tests/assets/background.png b/tests/assets/background.png
new file mode 100644
index 00000000..6a03930a
Binary files /dev/null and b/tests/assets/background.png differ
diff --git a/tests/configs/images.conf b/tests/configs/images.conf
new file mode 100644
index 00000000..c88e9b14
--- /dev/null
+++ b/tests/configs/images.conf
@@ -0,0 +1,64 @@
+background {
+ monitor=
+ path=/etc/hyprlock/assets/background.png
+}
+
+image {
+ monitor=
+ path=/etc/hyprlock/assets/avatar.png
+ size=150
+ position=0, 50
+ halign=center
+ valign=center
+ border_size=3
+ shadow_passes=1
+ shadow_size=5
+ shadow_boost=0.5
+}
+
+general {
+ hide_cursor=true
+}
+
+input-field {
+ monitor=
+ size=50, 50
+ capslock_color=rgb(CB7459)
+ dots_rounding=0
+ dots_size=0.35
+ dots_spacing=0.1
+ dots_text_format=*
+ fade_on_empty=true
+ font_color=rgb(8F8F8F)
+ font_family=Noto Sans
+ inner_color=rgba(00000000)
+ outer_color=rgba(FFF7EDa0)
+ outline_thickness=4
+ position=0, -5%
+ rounding=-1
+ halign=center
+ valign=center
+}
+
+label {
+ monitor=
+ color=rgb(FFF7ED)
+ font_family=Noto Sans
+ font_size=40
+ position=0, 10%
+ text=$TIME $FAIL
+ halign=center
+ valign=center
+}
+
+label {
+ monitor=
+ color=rgba(BEBEBEA0)
+ font_family=Noto Sans
+ font_size=24
+ position=0, 15%
+ text=cmd[update:10000] date '+%A %d %B %Y'
+ halign=center
+ valign=center
+}
+
diff --git a/tests/configs/layout_and_shape.conf b/tests/configs/layout_and_shape.conf
new file mode 100644
index 00000000..206b42fb
--- /dev/null
+++ b/tests/configs/layout_and_shape.conf
@@ -0,0 +1,90 @@
+background {
+ color=rgba(255, 255, 255, 0)
+}
+
+shape {
+ size = 90%, 90%
+ position = 0, 0
+ color = rgba(0, 0, 0, 0.5)
+ border_color = rgba(255, 255, 0, 1.0)
+ rounding = 5
+ border_size = 10
+ halign = center
+ valign = center
+}
+
+shape {
+ size = 50%, 50%
+ position = -10, -10
+ color = rgb(0, 0, 255)
+ rounding = 5
+ border_size = 0
+ halign = center
+ valign = center
+}
+
+shape {
+ size = 50%, 50%
+ position = +10, +10
+ color = rgb(00ff00)
+ color = rgba(00ff00ff)
+ rounding = 5
+ border_size = 0
+ halign = center
+ valign = center
+}
+
+# Top left corner
+shape {
+ size = 10%, 10%
+ position = 10, -10
+ color = rgba(0, 255, 0, 1.0)
+ rounding = 5
+ border_size = 0
+ halign = left
+ valign = top
+}
+
+# Top right corner
+shape {
+ size = 10%, 10%
+ position = -10, -10
+ color = rgba(0, 255, 0, 1.0)
+ rounding = 5
+ border_size = 0
+ halign = right
+ valign = top
+}
+
+# Bottom left corner
+shape {
+ size = 10%, 10%
+ position = 10, 10
+ color = rgba(0, 255, 0, 1.0)
+ rounding = 5
+ border_size = 0
+ halign = left
+ valign = bottom
+}
+
+# Bottom right corner
+shape {
+ size = 10%, 10%
+ position = -10, 10
+ color = rgba(0, 255, 0, 1.0)
+ rounding = 5
+ border_size = 0
+ halign = right
+ valign = bottom
+}
+
+# Origin of shape centered
+shape {
+ size = 10%, 10%
+ position = 50%, 50%
+ color = rgb(ff0000)
+ rounding = 5
+ border_size = 0
+ halign = left
+ valign = bottom
+}
diff --git a/tests/configs/lots_of_label_updates.conf b/tests/configs/lots_of_label_updates.conf
new file mode 100644
index 00000000..ad841991
--- /dev/null
+++ b/tests/configs/lots_of_label_updates.conf
@@ -0,0 +1,221 @@
+background {
+ monitor=
+ blur_passes=2
+ blur_size=10
+ path=screenshot
+}
+
+general {
+ hide_cursor=true
+}
+
+input-field {
+ monitor=
+ size=50, 50
+ capslock_color=rgb(CB7459)
+ dots_rounding=0
+ dots_size=0.35
+ dots_spacing=0.1
+ dots_text_format=*
+ fade_on_empty=false
+ font_color=rgb(8F8F8F)
+ font_family=Noto Sans
+ inner_color=rgba(00000000)
+ outer_color=rgba(FFF7EDa0)
+ outline_thickness=4
+ placeholder_text=$PROMPT
+ fail_text=$FAIL ($ATTEMPTS)
+ position=0, -5%
+ rounding=-1
+ halign=center
+ valign=center
+}
+
+label {
+ monitor=
+ color=rgb(FFF7ED)
+ font_family=Noto Sans
+ font_size=40
+ position=0, 10%
+ text=$TIME $FAIL
+ halign=center
+ valign=center
+}
+
+label {
+ monitor=
+ color=rgba(BEBEBEA0)
+ font_family=Noto Sans
+ font_size=24
+ position=0, 15%
+ text=cmd[update:10000] date '+%A %d %B %Y'
+ halign=center
+ valign=center
+}
+
+label {
+ text = cmd[update:200] date +'%H:%M:%S:%N'
+ color = rgba(129, 162, 190, 1.0)
+ font_size = 35
+ font_family = Noto Sans
+
+ position = 150, -200
+ halign = left
+ valign = center
+}
+
+label {
+ text = cmd[update:200] date +'%H:%M:%S:%N'
+ color = rgba(129, 162, 190, 1.0)
+ font_size = 35
+ font_family = Noto Sans
+
+ position = 150, -150
+ halign = left
+ valign = center
+}
+
+label {
+ text = cmd[update:200] date +'%H:%M:%S:%N'
+ color = rgba(129, 162, 190, 1.0)
+ font_size = 35
+ font_family = Noto Sans
+
+ position = 150, -100
+ halign = left
+ valign = center
+}
+
+label {
+ text = cmd[update:200] date +'%H:%M:%S:%N'
+ color = rgba(129, 162, 190, 1.0)
+ font_size = 35
+ font_family = Noto Sans
+
+ position = 150, -50
+ halign = left
+ valign = center
+}
+
+label {
+ text = cmd[update:200] date +'%H:%M:%S'
+ color = rgba(129, 162, 190, 1.0)
+ font_size = 35
+ font_family = Noto Sans
+
+ position = 150, 0
+ halign = left
+ valign = center
+}
+
+label {
+ text = cmd[update:200] date +'%H:%M:%S'
+ color = rgba(129, 162, 190, 1.0)
+ font_size = 35
+ font_family = Noto Sans
+
+ position = 150, 50
+ halign = left
+ valign = center
+}
+
+label {
+ text = cmd[update:200] date +'%H:%M:%S'
+ color = rgba(129, 162, 190, 1.0)
+ font_size = 35
+ font_family = Noto Sans
+
+ position = 150, 100
+ halign = left
+ valign = center
+}
+
+label {
+ text = cmd[update:200] date +'%H:%M:%S'
+ color = rgba(129, 162, 190, 1.0)
+ font_size = 35
+ font_family = Noto Sans
+
+ position = 150, 150
+ halign = left
+ valign = center
+}
+
+label {
+ text = cmd[update:200] date +'%H:%M:%S'
+ color = rgba(129, 162, 190, 1.0)
+ font_size = 35
+ font_family = Noto Sans
+
+ position = 150, 200
+ halign = left
+ valign = center
+}
+
+label {
+ text = cmd[update:200] date +'%H:%M:%S'
+ color = rgba(129, 162, 190, 1.0)
+ font_size = 35
+ font_family = Noto Sans
+
+ position = 150, 250
+ halign = left
+ valign = center
+}
+
+label {
+ text = cmd[update:200] date +'%H:%M:%S'
+ color = rgba(129, 162, 190, 1.0)
+ font_size = 35
+ font_family = Noto Sans
+
+ position = 150, 300
+ halign = left
+ valign = center
+}
+
+label {
+ text = cmd[update:200] date +'%H:%M:%S'
+ color = rgba(129, 162, 190, 1.0)
+ font_size = 35
+ font_family = Noto Sans
+
+ position = 150, 350
+ halign = left
+ valign = center
+}
+
+label {
+ text = cmd[update:200] date +'%H:%M:%S'
+ color = rgba(129, 162, 190, 1.0)
+ font_size = 35
+ font_family = Noto Sans
+
+ position = 150, 400
+ halign = left
+ valign = center
+}
+
+label {
+ text = cmd[update:200] date +'%H:%M:%S'
+ color = rgba(129, 162, 190, 1.0)
+ font_size = 35
+ font_family = Noto Sans
+
+ position = 150, 450
+ halign = left
+ valign = center
+}
+
+label {
+ text = cmd[update:200] date +'%H:%M:%S'
+ color = rgba(129, 162, 190, 1.0)
+ font_size = 35
+ font_family = Noto Sans
+
+ position = 150, 500
+ halign = left
+ valign = center
+}
+
+
diff --git a/tests/hyprland.conf b/tests/hyprland.conf
new file mode 100644
index 00000000..91a68d2c
--- /dev/null
+++ b/tests/hyprland.conf
@@ -0,0 +1,41 @@
+monitor = Virtual-1,1920x1080@60,auto-right,1
+monitor = ,disabled
+
+input {
+ # to type german and polish specific letters via compose keys
+ kb_layout = eu
+}
+
+render {
+ ctm_animation = 0
+ cm_enabled = 0
+ cm_fs_passthrough = 0
+}
+
+animations {
+ enabled = 0
+}
+
+decoration {
+ shadow {
+ enabled = 0
+ }
+}
+
+xwayland {
+ enabled = 0
+}
+
+misc {
+ disable_hyprland_logo = 1
+ disable_splash_rendering = 1
+ force_default_wallpaper = 0
+ key_press_enables_dpms = 1
+}
+
+debug {
+ disable_logs = 0
+}
+
+# we use this in nix/tests/default.nix to be able to wait for hyprland startup
+exec-once = echo "startup" > /tmp/hyprland_exec_once_notification
diff --git a/tests/waitForLock.cpp b/tests/waitForLock.cpp
new file mode 100644
index 00000000..dab433b0
--- /dev/null
+++ b/tests/waitForLock.cpp
@@ -0,0 +1,70 @@
+// This program exits when the wayland session gets locked, or 10 seconds have passed.
+// In case it is already locked, it shall return immediatly.
+// It uses hyprland-lock-notify to accomplish that.
+#include "hyprland-lock-notify-v1.hpp"
+#include "wayland.hpp"
+
+#include
+#include
+#include
+#include
+
+using namespace Hyprutils::Memory;
+
+#define SP CSharedPointer
+
+struct SSessionLockState {
+ SP m_lockNotifier = nullptr;
+ SP m_lockNotification = nullptr;
+ bool m_didLock = false;
+};
+
+int main(int argc, char** argv) {
+ auto wlDisplay = wl_display_connect(nullptr);
+ if (!wlDisplay) {
+ std::println(stderr, "Failed to connect to Wayland display");
+ return -1;
+ }
+
+ auto state = makeShared();
+
+ auto wlRegistry = makeShared((wl_proxy*)wl_display_get_registry(wlDisplay));
+ wlRegistry->setGlobal([state](CCWlRegistry* r, uint32_t name, const char* interface, uint32_t version) {
+ const std::string IFACE = interface;
+
+ if (IFACE == hyprland_lock_notifier_v1_interface.name)
+ state->m_lockNotifier =
+ makeShared((wl_proxy*)wl_registry_bind((wl_registry*)r->resource(), name, &hyprland_lock_notifier_v1_interface, version));
+ });
+
+ wl_display_roundtrip(wlDisplay);
+
+ if (!state->m_lockNotifier) {
+ std::print(stderr, "Failed to bind to lock notifier\n");
+ return -1;
+ }
+
+ state->m_lockNotification = makeShared(state->m_lockNotifier->sendGetLockNotification());
+ state->m_lockNotification->setLocked([state](auto) { state->m_didLock = true; });
+
+ wl_display_flush(wlDisplay);
+
+ const auto STARTTP = std::chrono::system_clock::now();
+ while (!state->m_didLock) {
+ if (wl_display_prepare_read(wlDisplay) == 0) {
+ wl_display_read_events(wlDisplay);
+ wl_display_dispatch_pending(wlDisplay);
+ } else {
+ wl_display_dispatch(wlDisplay);
+ }
+
+ if (std::chrono::system_clock::now() - STARTTP > std::chrono::seconds(10)) {
+ std::print(stderr, "Timeout waiting for the lock event\n");
+ return -1;
+ }
+
+ std::this_thread::sleep_for(std::chrono::milliseconds(10));
+ }
+
+ return 0;
+}