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 9409146..199fa7b 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 0000000..c07ae5a
--- /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 0000000..df1e74f
--- /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 1ee63ca..d759f16 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -163,3 +163,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 75ba39c..35bf22a 100644
--- a/flake.lock
+++ b/flake.lock
@@ -62,11 +62,11 @@
]
},
"locked": {
- "lastModified": 1772459870,
- "narHash": "sha256-xxkK2Cvqxpf/4UGcJ/TyCwrvmiNWsKsJfFzHMp2bxis=",
+ "lastModified": 1774534854,
+ "narHash": "sha256-grFWyjph17nSuIGE+eh9oPEW5prXRzyWsQ4xCvXTzWc=",
"owner": "hyprwm",
"repo": "hyprutils",
- "rev": "e63f3a79334dec49f8eb1691f66f18115df04085",
+ "rev": "762166b516432ce4b02bfbae365f1daa6f88f76d",
"type": "github"
},
"original": {
@@ -100,11 +100,11 @@
},
"nixpkgs": {
"locked": {
- "lastModified": 1772433332,
- "narHash": "sha256-izhTDFKsg6KeVBxJS9EblGeQ8y+O8eCa6RcW874vxEc=",
+ "lastModified": 1774386573,
+ "narHash": "sha256-4hAV26quOxdC6iyG7kYaZcM3VOskcPUrdCQd/nx8obc=",
"owner": "NixOS",
"repo": "nixpkgs",
- "rev": "cf59864ef8aa2e178cccedbe2c178185b0365705",
+ "rev": "46db2e09e1d3f113a13c0d7b81e2f221c63b8ce9",
"type": "github"
},
"original": {
diff --git a/flake.nix b/flake.nix
index 1c8dbcc..5b4876c 100644
--- a/flake.nix
+++ b/flake.nix
@@ -32,39 +32,42 @@
};
};
- outputs =
- {
- self,
- nixpkgs,
- systems,
- ...
- }@inputs:
- let
- inherit (nixpkgs) lib;
- eachSystem = lib.genAttrs (import systems);
- pkgsFor = eachSystem (
- system:
+ outputs = {
+ self,
+ nixpkgs,
+ systems,
+ ...
+ } @ inputs: let
+ inherit (nixpkgs) lib;
+ eachSystem = lib.genAttrs (import systems);
+ pkgsFor = eachSystem (
+ system:
import nixpkgs {
localSystem.system = system;
- overlays = with self.overlays; [ hyprlock-with-deps ];
+ overlays = with self.overlays; [hyprlock-with-deps];
}
- );
- in
- {
- overlays = import ./nix/overlays.nix { inherit inputs lib self; };
-
- packages = eachSystem (system: {
- default = self.packages.${system}.hyprlock;
- inherit (pkgsFor.${system}) hyprlock;
+ );
+ pkgsDebugFor = eachSystem (system:
+ import nixpkgs {
+ localSystem = system;
+ overlays = with self.overlays; [hyprlock-debug hyprlock-with-deps];
});
+ in {
+ overlays = import ./nix/overlays.nix {inherit inputs lib self;};
- homeManagerModules = {
- default = self.homeManagerModules.hyprlock;
- hyprlock = builtins.throw "hyprlock: the flake HM module has been removed. Use the module from Home Manager upstream.";
- };
+ packages = eachSystem (system: {
+ default = self.packages.${system}.hyprlock;
+ inherit (pkgsFor.${system}) hyprlock;
+ inherit (pkgsDebugFor.${system}) hyprlock-debug hyprlock-test-meta;
+ });
- checks = eachSystem (system: self.packages.${system});
-
- formatter = eachSystem (system: pkgsFor.${system}.nixfmt-tree);
+ homeManagerModules = {
+ default = self.homeManagerModules.hyprlock;
+ hyprlock = builtins.throw "hyprlock: the flake HM module has been removed. Use the module from Home Manager upstream.";
};
+
+ checks = eachSystem (system: self.packages.${system} // (import ./nix/tests/default.nix inputs pkgsFor.${system}));
+
+ formatter = eachSystem (system: pkgsFor.${system}.nixfmt-tree);
+ };
}
diff --git a/nix/default.nix b/nix/default.nix
index 58bc834..8a70b90 100644
--- a/nix/default.nix
+++ b/nix/default.nix
@@ -1,6 +1,7 @@
{
lib,
stdenv,
+ stdenvAdapters,
cmake,
pkg-config,
cairo,
@@ -19,49 +20,73 @@
wayland,
wayland-protocols,
wayland-scanner,
+ debug ? false,
version ? "git",
shortRev ? "",
-}:
-stdenv.mkDerivation {
- pname = "hyprlock";
- inherit version;
+}: let
+ inherit (builtins) foldl';
+ inherit (lib.lists) flatten;
+ inherit (lib.sources) cleanSourceWith cleanSource;
+ inherit (lib.strings) hasSuffix optionalString;
- src = ../.;
-
- nativeBuildInputs = [
- cmake
- pkg-config
- hyprwayland-scanner
- wayland-scanner
+ adapters = flatten [
+ stdenvAdapters.useMoldLinker
+ (lib.optional debug stdenvAdapters.keepDebugInfo)
];
- buildInputs = [
- cairo
- libdrm
- libGL
- libxkbcommon
- libgbm
- hyprgraphics
- hyprlang
- hyprutils
- pam
- pango
- sdbus-cpp_2
- systemdLibs
- wayland
- wayland-protocols
- ];
+ customStdenv = foldl' (acc: adapter: adapter acc) stdenv adapters;
+in
+ customStdenv.mkDerivation {
+ pname = "hyprlock${optionalString debug "-debug"}";
+ inherit version;
- cmakeFlags = lib.mapAttrsToList lib.cmakeFeature {
- HYPRLOCK_COMMIT = shortRev;
- HYPRLOCK_VERSION_COMMIT = ""; # Intentionally left empty (hyprlock --version will always print the commit)
- };
+ src = cleanSourceWith {
+ filter = name: _type: let
+ baseName = baseNameOf (toString name);
+ in
+ ! (hasSuffix ".nix" baseName);
+ src = cleanSource ../.;
+ };
- meta = {
- homepage = "https://github.com/hyprwm/hyprlock";
- description = "A gpu-accelerated screen lock for Hyprland";
- license = lib.licenses.bsd3;
- platforms = lib.platforms.linux;
- mainProgram = "hyprlock";
- };
-}
+ nativeBuildInputs = [
+ cmake
+ pkg-config
+ hyprwayland-scanner
+ wayland-scanner
+ ];
+
+ buildInputs = [
+ cairo
+ libdrm
+ libGL
+ libxkbcommon
+ libgbm
+ hyprgraphics
+ hyprlang
+ hyprutils
+ pam
+ pango
+ sdbus-cpp_2
+ systemdLibs
+ wayland
+ wayland-protocols
+ ];
+
+ cmakeFlags = lib.mapAttrsToList lib.cmakeFeature {
+ HYPRLOCK_COMMIT = shortRev;
+ 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";
+ license = lib.licenses.bsd3;
+ platforms = lib.platforms.linux;
+ mainProgram = "hyprlock";
+ };
+ }
diff --git a/nix/overlays.nix b/nix/overlays.nix
index 6f6f7d4..c238177 100644
--- a/nix/overlays.nix
+++ b/nix/overlays.nix
@@ -2,19 +2,15 @@
lib,
inputs,
self,
-}:
-let
- mkDate =
- longDate:
- (lib.concatStringsSep "-" [
- (builtins.substring 0 4 longDate)
- (builtins.substring 4 2 longDate)
- (builtins.substring 6 2 longDate)
- ]);
+}: let
+ mkDate = longDate: (lib.concatStringsSep "-" [
+ (builtins.substring 0 4 longDate)
+ (builtins.substring 4 2 longDate)
+ (builtins.substring 6 2 longDate)
+ ]);
version = lib.removeSuffix "\n" (builtins.readFile ../VERSION);
-in
-{
+in {
default = inputs.self.overlays.hyprlock;
hyprlock-with-deps = lib.composeManyExtensions [
@@ -37,4 +33,18 @@ in
shortRev = self.sourceInfo.shortRev or "dirty";
};
};
+
+ 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.gcc15Stdenv;
+ version = version + "+date=" + (mkDate (inputs.self.lastModifiedDate or "19700101")) + "_" + (inputs.self.shortRev or "dirty");
+ };
+ })
+ ];
}
diff --git a/nix/test-meta.nix b/nix/test-meta.nix
new file mode 100644
index 0000000..c217aac
--- /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 0000000..9a9306f
--- /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 0000000..db6abe0
--- /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.11.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 0000000..3fc7d33
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 0000000..6a03930
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 0000000..c88e9b1
--- /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 0000000..206b42f
--- /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 0000000..ad84199
--- /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 0000000..91a68d2
--- /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 0000000..dab433b
--- /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;
+}