diff --git a/common/.local/bin/dotfiles-validate b/common/.local/bin/dotfiles-validate index 57a7505..d5e6695 100755 --- a/common/.local/bin/dotfiles-validate +++ b/common/.local/bin/dotfiles-validate @@ -3,6 +3,7 @@ # # Walks the tree and extracts the commands that configs promise to launch: # - hypr conf files: exec-once = CMD / exec = CMD / bind* = ..., exec, CMD +# - hypr lua configs: at_start/at_shutdown/at_reload("CMD"), exec_cmd("CMD") # - waybar config: "exec(-if)", "on-click*", "on-scroll-*", # "on-double-click" values # - systemd user units: Exec*= lines (leading -/@ modifiers stripped) @@ -45,6 +46,30 @@ find "$root" -path '*/.config/hypr/*.conf' -type f 2>/dev/null | while read -r f ' "$f" done >> "$refs_file" +# --- hypr lua configs: autostart collectors and exec_cmd dispatchers --- +# The Lua config manager (Hyprland 0.55+) spells the same two things as function +# calls rather than assignments, so the .conf walk above sees none of them. Only +# the first word is taken, as everywhere else here. The two matches are written +# out rather than folded into a helper: awk cannot take a regex literal as a +# function parameter -- it collapses to a boolean match against $0, which +# silently turns every line into a bogus reference. +find "$root" -path '*/.config/hypr/*.lua' -type f 2>/dev/null | while read -r f; do + awk -v file="$f" ' + match($0, /at_(start|shutdown|reload)\("/) { + rest = substr($0, RSTART + RLENGTH) + sub(/".*$/, "", rest) + n = split(rest, w, /[ \t]+/) + if (n > 0 && w[1] != "") print file ":" FNR ":" w[1] + } + match($0, /hl\.dsp\.exec_cmd\("/) { + rest = substr($0, RSTART + RLENGTH) + sub(/".*$/, "", rest) + n = split(rest, w, /[ \t]+/) + if (n > 0 && w[1] != "") print file ":" FNR ":" w[1] + } + ' "$f" +done >> "$refs_file" + # --- waybar configs: command-bearing JSON values --- find "$root" -path '*/.config/waybar/*' -type f \( -name config -o -name '*.json' -o -name '*.jsonc' \) 2>/dev/null | while read -r f; do awk -v file="$f" ' diff --git a/tests/layout-cycle/test_layout_cycle.py b/tests/layout-cycle/test_layout_cycle.py index 1454817..3740024 100644 --- a/tests/layout-cycle/test_layout_cycle.py +++ b/tests/layout-cycle/test_layout_cycle.py @@ -18,6 +18,7 @@ Run from repo root: import json import os +import re import subprocess import tempfile import unittest @@ -25,7 +26,7 @@ import unittest REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) SCRIPT = os.path.join(REPO_ROOT, "hyprland/.local/bin/layout-cycle") FAKE_HYPRCTL = os.path.join(os.path.dirname(__file__), "fake-hyprctl") -HYPRLAND_CONF = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.conf") +HYPRLAND_CFG = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.lua") FLASH = "rgba(ffd24aff)" ACTIVE = "rgba(daa520ff)" @@ -326,7 +327,7 @@ class TestFlashAllBorders(LayoutCycleHarness): class TestScrollLayoutDisabledInConfig(unittest.TestCase): - """Pin the hyprland.conf half of the scrolling disable. + """Pin the hyprland.lua half of the scrolling disable. The script tests above prove the ring skips scrolling; these prove no keybinding reaches it either, and that the freed Super+Shift+S chord @@ -335,10 +336,20 @@ class TestScrollLayoutDisabledInConfig(unittest.TestCase): @classmethod def setUpClass(cls): - with open(HYPRLAND_CONF) as f: + with open(HYPRLAND_CFG) as f: cls.conf = f.read() + # Every bind in the Lua config is a top-level hl.bind() call; the + # locked/mouse/repeat variants that hyprlang spelled bindl/bindm/binde + # are the same call with an options table, so one prefix covers them. cls.binds = [l for l in cls.conf.splitlines() - if l.strip().startswith(("bind", "bindl", "bindm"))] + if l.strip().startswith("hl.bind")] + + def test_the_binds_were_actually_found(self): + """Guard the guard: a renamed call would empty the list and make both + assertions below pass against nothing.""" + self.assertGreater(len(self.binds), 50, + "found almost no hl.bind lines -- the two assertions " + "below would pass vacuously") def test_no_bind_selects_scrolling_layout(self): offenders = [l for l in self.binds if "general:layout scrolling" in l] @@ -346,7 +357,7 @@ class TestScrollLayoutDisabledInConfig(unittest.TestCase): def test_super_shift_s_is_fullscreen_screenshot(self): shift_s = [l for l in self.binds - if "$mod SHIFT, S," in l] + if re.search(r'\bmod\s*\.\.\s*"\s*\+\s*SHIFT\s*\+\s*S"', l)] self.assertEqual(len(shift_s), 1, msg=f"binds found: {shift_s}") self.assertIn("screenshot fullscreen", shift_s[0]) diff --git a/tests/settings/test_session_restore.py b/tests/settings/test_session_restore.py index a989d52..b2a2721 100644 --- a/tests/settings/test_session_restore.py +++ b/tests/settings/test_session_restore.py @@ -585,12 +585,32 @@ class TestCompositorWiring(unittest.TestCase): script and passing tests while never being placed in the bar. """ - CONF = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.conf") + CONF = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.lua") def _lines(self): + """The autostart commands, in launch order. + + The Lua config collects startup commands with at_start("CMD") and one + hl.on("hyprland.start") handler at the bottom replays the list in order, + so position in this file is still position at launch -- which is what + the ordering assertion below reads. Returning the command strings rather + than raw lines means every entry here is by construction an autostart + command, so the old startswith("exec-once") filter has nothing left to do. + """ + out = [] with open(self.CONF) as f: - return [ln.strip() for ln in f - if ln.strip() and not ln.strip().startswith("#")] + for ln in f: + m = re.search(r'at_start\("(.*)"\)', ln.strip()) + if m: + out.append(m.group(1)) + return out + + def test_the_autostart_commands_were_actually_found(self): + """Guard the guard: a renamed collector would empty the list and make + every assertion below pass against nothing.""" + self.assertGreater(len(self._lines()), 10, + "found almost no at_start commands -- the assertions " + "below would pass vacuously") @staticmethod def _is_toggle_restore(line): @@ -607,22 +627,19 @@ class TestCompositorWiring(unittest.TestCase): def test_restore_runs_at_session_start(self): self.assertTrue( - any(ln.startswith("exec-once") and self._is_toggle_restore(ln) - for ln in self._lines()), - "hyprland.conf has no exec-once running `settings restore` — " + any(self._is_toggle_restore(ln) for ln in self._lines()), + "hyprland.lua has no at_start running `settings restore` — " "remembered toggles would never be replayed") def test_wallpaper_restore_runs_at_session_start(self): self.assertTrue( - any(ln.startswith("exec-once") and "settings restore-wallpaper" in ln - for ln in self._lines()), - "hyprland.conf has no exec-once running `settings restore-wallpaper` " + any("settings restore-wallpaper" in ln for ln in self._lines()), + "hyprland.lua has no at_start running `settings restore-wallpaper` " "— the stored wallpaper would never be put back") def test_the_toggle_restore_runs_only_once(self): """Twice means the whole re-assert budget is spent twice per login.""" - lines = [ln for ln in self._lines() - if ln.startswith("exec-once") and self._is_toggle_restore(ln)] + lines = [ln for ln in self._lines() if self._is_toggle_restore(ln)] self.assertEqual(len(lines), 1, lines) def test_restore_is_ordered_after_the_backings_it_corrects(self): @@ -631,17 +648,15 @@ class TestCompositorWiring(unittest.TestCase): # burn attempts on backings that aren't up yet. lines = self._lines() restore = next(i for i, ln in enumerate(lines) - if ln.startswith("exec-once") - and self._is_toggle_restore(ln)) + if self._is_toggle_restore(ln)) for backing in ("hypridle", "dunst"): - launch = next(i for i, ln in enumerate(lines) - if ln.startswith("exec-once") and backing in ln) + launch = next(i for i, ln in enumerate(lines) if backing in ln) self.assertLess(launch, restore, f"`settings restore` is ordered before {backing}") class DimEnv(TempEnv): - """Adds the hyprctl fake, whose dim default is 1 -- as hyprland.conf's is.""" + """Adds the hyprctl fake, whose dim default is 1 -- as hyprland.lua's is.""" def setUp(self): super().setUp() diff --git a/tests/waybar-reserve/test_reserve_pairing.py b/tests/waybar-reserve/test_reserve_pairing.py index 7421692..fbeb95d 100644 --- a/tests/waybar-reserve/test_reserve_pairing.py +++ b/tests/waybar-reserve/test_reserve_pairing.py @@ -26,7 +26,7 @@ import unittest REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) WAYBAR = os.path.join(REPO_ROOT, "hyprland/.config/waybar/config") -HYPR = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.conf") +HYPR = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.lua") def waybar_config(): @@ -35,13 +35,19 @@ def waybar_config(): def reserve_exec_wired(): - """True when hyprland.conf runs waybar-reserve via exec (not exec-once). + """True when the config re-runs waybar-reserve on every config reload. - Matches waybar-reserve invoked anywhere in an ``exec =`` line, so the - reload-race-safe retry-loop form (``exec = for i in 1 2 3; do sleep 0.2; - waybar-reserve; done``) counts the same as a bare ``exec = waybar-reserve``.""" + Matches waybar-reserve invoked anywhere in an ``at_reload(...)`` line, so the + reload-race-safe retry-loop form (``at_reload("for i in 1 2 3; do sleep 0.2; + waybar-reserve; done")``) counts the same as a bare ``at_reload("waybar-reserve")``. + + at_reload and not at_start, because hyprlang's ``exec`` ran at startup AND on + every reload, and the Lua port splits those two jobs. at_reload's handler is + ``hl.on("config.reloaded")``, which fires on the initial load as well, so it + alone covers both. Matching at_start instead would pass while reservations + died on the next reload -- which is exactly the defect this guards.""" with open(HYPR) as f: - return any(re.match(r"\s*exec\s*=.*\bwaybar-reserve\b", ln) for ln in f) + return any(re.match(r'\s*at_reload\(".*\bwaybar-reserve\b', ln) for ln in f) def reserve_target(): @@ -61,7 +67,7 @@ class ReservePairingHarness(unittest.TestCase): def test_hyprland_wires_the_reserve_script(self): self.assertTrue(reserve_exec_wired(), - "hyprland.conf lacks `exec = waybar-reserve`: reservations " + "hyprland.lua lacks `at_reload(... waybar-reserve ...)`: reservations " "die on the next config reload") def test_reserve_target_covers_the_bar(self):