From 53fa38bfcf20ca661d9395d1fddb59525ac113b9 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 29 Jul 2026 17:44:42 +0700 Subject: [PATCH 1/2] #1867 test: add a regression suite and a CI job to run it --- .claude/worktrees/graceful-crafting-hanrahan | 1 - .github/workflows/test.yml | 38 ++++ .gitignore | 3 + CHANGELOG.md | 3 + Configs/.config/uwsm/env.d/01-gpu.sh | 2 + Configs/.config/zsh/ohmyzsh | 1 - Configs/.local/lib/hyde/shutils/ocr.sh | 2 +- Configs/.local/lib/hyde/wallpaper.kon.sh | 2 +- tests/README.md | 38 ++++ tests/lib/common.sh | 29 +++ tests/lua/bind_harness.lua | 221 +++++++++++++++++++ tests/python/check_dots.py | 107 +++++++++ tests/run.sh | 62 ++++++ tests/test_binds.sh | 13 ++ tests/test_dots.sh | 13 ++ tests/test_git.sh | 37 ++++ tests/test_lua_syntax.sh | 18 ++ tests/test_shell.sh | 40 ++++ 18 files changed, 626 insertions(+), 4 deletions(-) delete mode 160000 .claude/worktrees/graceful-crafting-hanrahan create mode 100644 .github/workflows/test.yml delete mode 160000 Configs/.config/zsh/ohmyzsh create mode 100644 tests/README.md create mode 100644 tests/lib/common.sh create mode 100644 tests/lua/bind_harness.lua create mode 100644 tests/python/check_dots.py create mode 100755 tests/run.sh create mode 100755 tests/test_binds.sh create mode 100755 tests/test_dots.sh create mode 100755 tests/test_git.sh create mode 100755 tests/test_lua_syntax.sh create mode 100755 tests/test_shell.sh diff --git a/.claude/worktrees/graceful-crafting-hanrahan b/.claude/worktrees/graceful-crafting-hanrahan deleted file mode 160000 index a51460a7b1..0000000000 --- a/.claude/worktrees/graceful-crafting-hanrahan +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a51460a7b1a822ee7194318b60a38850f711b923 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000000..9b1921a75c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,38 @@ +name: Tests + +on: + push: + branches: + - dev + - rc + - master + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: Configuration and script checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Install test dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends lua5.4 shellcheck + sudo ln -sf /usr/bin/lua5.4 /usr/local/bin/lua + sudo ln -sf /usr/bin/luac5.4 /usr/local/bin/luac + + - name: Report tool versions + run: | + lua -v + shellcheck --version + python3 --version + + - name: Run the test suite + run: sh tests/run.sh diff --git a/.gitignore b/.gitignore index daaecdeaa7..47904e2c48 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ Scripts/pkg_user.lst Configs/.config/uwsm/* ./.* build + +# Local agent worktrees, never part of the tree +.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 375eb238c5..5025e539a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), Group navigation stays on `Super + Alt + arrows` - Hyprland: the right Control key no longer hides Waybar on its own. Hiding moved to `Super + Ctrl + B` - Hyprland: workspaces 11-20 on the numpad respond again +- OCR: the language list in the result notification is no longer split across arguments +- Wallpaper: the duplicate check in the kon backend compares against the whole hash list again +- Repo: dropped two stray gitlinks that made `git submodule` fail on a fresh clone ## v26.7.4 | 4th Week of July 2026 Release diff --git a/Configs/.config/uwsm/env.d/01-gpu.sh b/Configs/.config/uwsm/env.d/01-gpu.sh index 458e9d6df4..04e7d8dab4 100644 --- a/Configs/.config/uwsm/env.d/01-gpu.sh +++ b/Configs/.config/uwsm/env.d/01-gpu.sh @@ -1,3 +1,5 @@ +#!/usr/bin/env sh + # 00-gpu.sh - GPU detection # This env script will detect the presence of various GPUs on the system # and set environment variables accordingly. diff --git a/Configs/.config/zsh/ohmyzsh b/Configs/.config/zsh/ohmyzsh deleted file mode 160000 index 9e23925b85..0000000000 --- a/Configs/.config/zsh/ohmyzsh +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 9e23925b8581d22033f07f1983587412d3761494 diff --git a/Configs/.local/lib/hyde/shutils/ocr.sh b/Configs/.local/lib/hyde/shutils/ocr.sh index 8914b877a1..73435c492f 100644 --- a/Configs/.local/lib/hyde/shutils/ocr.sh +++ b/Configs/.local/lib/hyde/shutils/ocr.sh @@ -35,6 +35,6 @@ ocr_extract() { ) printf "%s" "$tesseract_output" | wl-copy - notify-send -a "HyDE Alert" "$(echo -e "OCR: ${#tesseract_output} symbols recognized\n\nLanguages used ${tesseract_languages[@]/#/'\n '}")" -i "$image_path" -e -r 9 + notify-send -a "HyDE Alert" "$(echo -e "OCR: ${#tesseract_output} symbols recognized\n\nLanguages used ${tesseract_languages[*]/#/'\n '}")" -i "$image_path" -e -r 9 } diff --git a/Configs/.local/lib/hyde/wallpaper.kon.sh b/Configs/.local/lib/hyde/wallpaper.kon.sh index 9bbd70296c..d605234412 100755 --- a/Configs/.local/lib/hyde/wallpaper.kon.sh +++ b/Configs/.local/lib/hyde/wallpaper.kon.sh @@ -36,7 +36,7 @@ done if [ ! -z "$setTheme" ] && [ ! -z "$setWall" ]; then inwallHash="$(set_hash "$setWall")" get_hashmap "$tgtPath/$setTheme" - if [[ ${wallHash[@]} == *"$inwallHash"* ]]; then + if [[ ${wallHash[*]} == *"$inwallHash"* ]]; then notify-send -a "HyDE Notify" -i "$thmbDir/$inwallHash.sqre" "Error" "Hash matched in $setTheme" exit 0 fi diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000000..409c1efbdb --- /dev/null +++ b/tests/README.md @@ -0,0 +1,38 @@ +# Tests + +Structural checks over the shipped configuration and scripts. They read the +tree, never the machine: no Hyprland session, no installed HyDE, nothing +outside the repository is touched. + +## Running + +```sh +sh tests/run.sh # every case +sh tests/run.sh binds # only cases whose name contains "binds" +``` + +Each case is an executable `tests/test_*.sh` that prints its own diagnostics +and exits non-zero on failure. `tests/run.sh` discovers them, so adding a case +is adding a file. + +## Cases + +| Case | Checks | +| --- | --- | +| `test_binds.sh` | Loads the Lua keybinds against a stubbed Hyprland API: no two binds share a combination once modifiers are folded to what Hyprland matches on, no keysym sits in a modifier position, no bind uses the `code:NN` form the Lua parser rejects, every bind has a description, every `hyde-shell` command it runs exists | +| `test_git.sh` | The tree holds no gitlink without a matching `.gitmodules` entry, which would break `git submodule` and anything walking submodules | +| `test_dots.sh` | Every installer metafile under `Scripts/dots` parses, declares the keys the installer needs, uses a known action, and points at a source directory that exists | +| `test_lua_syntax.sh` | Every shipped Lua file parses | +| `test_shell.sh` | Every shipped shell script parses, and shellcheck finds no error-severity problem | + +## Dependencies + +| Tool | Used by | Missing | +| --- | --- | --- | +| `lua`, `luac` | bind and Lua syntax checks | case is skipped | +| `python3` 3.11+ | metafile check | case is skipped | +| `shellcheck` | shell check | only the parse half runs | + +A skipped case is reported as such and does not fail the run, so the suite +stays usable on a machine without the full toolchain. CI installs everything, +so nothing is skipped there. diff --git a/tests/lib/common.sh b/tests/lib/common.sh new file mode 100644 index 0000000000..8e8c0d0e21 --- /dev/null +++ b/tests/lib/common.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env sh +# Shared helpers for the test cases. + +TESTS_DIR=${TESTS_DIR:-$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)} +export TESTS_DIR + +# The tree under test. Defaults to the checkout the suite lives in, and can +# be pointed at another one to check it with this suite. +REPO_ROOT=${REPO_ROOT:-$(CDPATH='' cd -- "$TESTS_DIR/.." && pwd)} +export REPO_ROOT + +_failures=0 + +fail() { + _failures=$((_failures + 1)) + printf ' fail: %s\n' "$1" +} + +skip() { + printf ' skip: %s\n' "$1" +} + +finish() { + if [ "$_failures" -ne 0 ]; then + printf ' %d failure(s)\n' "$_failures" + exit 1 + fi + exit 0 +} diff --git a/tests/lua/bind_harness.lua b/tests/lua/bind_harness.lua new file mode 100644 index 0000000000..ad4cc1f44e --- /dev/null +++ b/tests/lua/bind_harness.lua @@ -0,0 +1,221 @@ +-- Loads the shipped Lua keybinds against a stubbed Hyprland API and checks +-- them for defects that Hyprland itself accepts silently. +-- +-- Everything Hyprland exposes as `hl` is replaced with a recorder, so no +-- compositor is needed and nothing is dispatched. `REPO_ROOT` selects the +-- tree to check, which lets the same harness run against any checkout. + +local repo_root = assert(os.getenv("REPO_ROOT"), "REPO_ROOT is not set") +local lua_root = repo_root .. "/Configs/.local/share/hypr/lua" +local lib_root = repo_root .. "/Configs/.local/lib/hyde" + +-- Modifiers Hyprland accepts in front of the key. Anything else in that +-- position is silently dropped, which turns the combo into a different bind. +local MODIFIERS = { + SUPER = true, + CTRL = true, + CONTROL = true, + SHIFT = true, + ALT = true, + CAPS = true, + WIN = true, + LOGO = true, + MOD1 = true, + MOD2 = true, + MOD3 = true, + MOD4 = true, + MOD5 = true +} + +local ALIASES = {CONTROL = "CTRL", WIN = "SUPER", LOGO = "SUPER", MOD1 = "ALT", MOD4 = "SUPER"} + +local function trim(str) + return (str:match("^%s*(.-)%s*$")) +end + +local function split(combo) + local tokens = {} + for token in combo:gmatch("[^+]+") do + tokens[#tokens + 1] = trim(token) + end + return tokens +end + +-- Reduces a combo to what Hyprland matches on. Modifiers are a set, so +-- spelling, order and case carry no meaning: "SUPER + CTRL + Left" and +-- "SUPER + CONTROL + LEFT" are the same bind. +local function canonical(combo) + local tokens = split(combo) + local key = table.remove(tokens) or "" + + for i, token in ipairs(tokens) do + local upper = token:upper() + tokens[i] = ALIASES[upper] or upper + end + table.sort(tokens) + + tokens[#tokens + 1] = key:upper() + return table.concat(tokens, " + ") +end + +local function file_exists(path) + local handle = io.open(path, "r") + if handle then + handle:close() + return true + end + return false +end + +-- Subcommands hyde-shell implements itself, declared as "# @cmd: name" in its +-- header. They resolve without a matching file in the script directories. +local function builtin_commands() + local builtins = {} + local handle = io.open(repo_root .. "/Configs/.local/bin/hyde-shell", "r") + if not handle then + return builtins + end + + for line in handle:lines() do + local name = line:match("^#%s*@cmd:%s*([%w%._-]+)") + if name then + builtins[name] = true + end + end + handle:close() + + return builtins +end + +local BUILTINS = builtin_commands() + +-- hyde-shell resolves a command name against these extensions, in this order. +local function command_resolves(name) + if BUILTINS[name] then + return true + end + if file_exists(lib_root .. "/" .. name) then + return true + end + for _, extension in ipairs({".sh", ".lua", ".py"}) do + if file_exists(lib_root .. "/" .. name .. extension) then + return true + end + end + return false +end + +local binds = {} + +local function dsp_proxy(prefix) + return setmetatable( + {}, + { + __index = function(_, key) + return dsp_proxy(prefix == "" and key or prefix .. "." .. key) + end, + __call = function(_, ...) + return {dispatcher = prefix, args = {...}} + end + } + ) +end + +_G.hl = { + dsp = dsp_proxy(""), + dispatch = function() + end, + get_active_window = function() + return {fullscreen = 0, floating = false} + end, + notification = { + create = function() + end + } +} + +_G.hl.bind = function(combo, action, opts) + binds[#binds + 1] = {combo = combo, action = action, opts = opts} +end + +_G.hl.unbind = function(combo) + for index = #binds, 1, -1 do + if binds[index].combo == combo then + table.remove(binds, index) + return + end + end +end + +_G.hyde = { + config = { + app = { + terminal = "kitty", + explorer = "dolphin", + browser = "firefox", + editor = "code" + }, + modifiers = {main = "SUPER"} + } +} + +dofile(lua_root .. "/hyde/dispatcher.lua") +dofile(lua_root .. "/hyde/binds.lua") +dofile(lua_root .. "/key_binds.lua") + +local failures = 0 + +local function check(condition, message) + if not condition then + failures = failures + 1 + print(" fail: " .. message) + end +end + +local seen = {} +for _, bind in ipairs(binds) do + local id = canonical(bind.combo) + local previous = seen[id] + + check( + previous == nil, + string.format("combo %s is bound twice, as %q and %q", id, tostring(previous), bind.combo) + ) + seen[id] = bind.combo + + local tokens = split(bind.combo) + local key = table.remove(tokens) + + check( + not key:match("^[Cc][Oo][Dd][Ee]:"), + string.format("%q uses a keycode, which the Lua bind parser does not accept", bind.combo) + ) + + for _, token in ipairs(tokens) do + check( + MODIFIERS[token:upper()] ~= nil, + string.format("%q holds %q where a modifier is expected", bind.combo, token) + ) + end + + check( + type(bind.opts) == "table" and type(bind.opts.description) == "string" and bind.opts.description ~= "", + string.format("%q has no description", bind.combo) + ) +end + +-- Every hyde-shell command reachable from a bind has to exist in the tree. +for _, bind in ipairs(binds) do + local args = type(bind.action) == "table" and bind.action.args or nil + local command = args and args[1] + + if type(command) == "string" then + local name = command:match("^hyde%-shell%s+([%w%._-]+)") + if name then + check(command_resolves(name), string.format("%q runs %q, which is not in the tree", bind.combo, name)) + end + end +end + +print(string.format(" %d bind(s) checked", #binds)) +os.exit(failures == 0 and 0 or 1) diff --git a/tests/python/check_dots.py b/tests/python/check_dots.py new file mode 100644 index 0000000000..99815d39b6 --- /dev/null +++ b/tests/python/check_dots.py @@ -0,0 +1,107 @@ +"""Structural validation of the dot metafiles under Scripts/dots. + +Each metafile describes what the installer copies and which packages a +component needs. A malformed entry is not reported by the installer, it is +simply skipped, so the component silently ships without part of its files. +""" + +from __future__ import annotations + +import os +import pathlib +import sys +import tomllib + +ACTIONS = {"sync", "preserve", "tarball"} +PACKAGE_MANAGERS = {"pacman", "yay", "paru", "dnf", "flatpak", "apt", "zypper"} + +REPO_ROOT = pathlib.Path(os.environ.get("REPO_ROOT", ".")) +DOTS_DIR = REPO_ROOT / "Scripts" / "dots" + + +def inside_repo(relative: str) -> bool: + """A source path has to stay in the checkout, absolute or `..` included.""" + root = REPO_ROOT.resolve() + try: + return (root / relative).resolve().is_relative_to(root) + except (OSError, ValueError): + return False + + +def entries(document: dict) -> list[tuple[str, dict]]: + """Yields (component, table) for every files table in a metafile.""" + found = [] + for component, body in document.items(): + if isinstance(body, dict): + for table in body.get("files", []) or []: + found.append((component, table)) + return found + + +def dependencies(document: dict) -> list[tuple[str, dict]]: + found = [] + for component, body in document.items(): + if isinstance(body, dict): + for table in body.get("dependency", []) or []: + found.append((component, table)) + return found + + +def main() -> int: + metafiles = sorted(DOTS_DIR.glob("*.toml")) + if not metafiles: + print(f" fail: no metafiles found under {DOTS_DIR}") + return 1 + + failures = 0 + + def fail(message: str) -> None: + nonlocal failures + failures += 1 + print(f" fail: {message}") + + for metafile in metafiles: + name = metafile.name + + try: + document = tomllib.loads(metafile.read_text()) + except tomllib.TOMLDecodeError as error: + fail(f"{name} is not valid TOML: {error}") + continue + + for component, table in entries(document): + where = f"{name} [{component}.files]" + + if "paths" not in table: + fail(f"{where} has no paths") + if "target_root" not in table: + fail(f"{where} has no target_root") + + action = table.get("action") + if action is not None and action not in ACTIONS: + fail(f"{where} has an unknown action {action!r}") + + source_root = table.get("source_root") + if source_root is not None: + if not isinstance(source_root, str): + fail(f"{where} declares source_root as {type(source_root).__name__}, expected a string") + elif not inside_repo(source_root): + fail(f"{where} points outside the repository with source_root {source_root!r}") + elif not (REPO_ROOT / source_root).is_dir(): + fail(f"{where} points at a missing source_root {source_root!r}") + + for component, table in dependencies(document): + where = f"{name} [{component}.dependency]" + unknown = set(table) - PACKAGE_MANAGERS + if unknown: + fail(f"{where} lists unknown package managers {sorted(unknown)}") + for manager, packages in table.items(): + if not isinstance(packages, list): + fail(f"{where} declares {manager} as {type(packages).__name__}, expected a list") + + print(f" {len(metafiles)} metafile(s) checked") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000000..13aee1ede4 --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env sh +# Runs every test case in this directory and reports the result. +# +# Usage: +# tests/run.sh run every case +# tests/run.sh binds run the cases whose name contains "binds" +# +# A case is any executable tests/test_*.sh. It prints its own diagnostics and +# exits non-zero on failure. + +set -u + +TESTS_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +export TESTS_DIR +tests_dir=$TESTS_DIR +# Honour a pre-set REPO_ROOT so the suite can be pointed at another tree. +REPO_ROOT=${REPO_ROOT:-$(CDPATH='' cd -- "$tests_dir/.." && pwd)} +export REPO_ROOT + +filter=${1:-} +total=0 +failed=0 + +for case_path in "$tests_dir"/test_*.sh; do + [ -f "$case_path" ] || continue + + case_name=$(basename "$case_path" .sh) + if [ -n "$filter" ]; then + case "$case_name" in + *"$filter"*) ;; + *) continue ;; + esac + fi + + total=$((total + 1)) + printf '%s\n' "$case_name" + + # An executable case runs under its own shebang; a plain file still runs, + # so a forgotten chmod does not silently drop coverage. + if [ -x "$case_path" ]; then + "$case_path" + status=$? + else + sh "$case_path" + status=$? + fi + + if [ "$status" -eq 0 ]; then + printf ' ok\n' + else + printf ' FAILED\n' + failed=$((failed + 1)) + fi +done + +if [ "$total" -eq 0 ]; then + printf 'no test cases matched\n' >&2 + exit 1 +fi + +printf '\n%d case(s), %d failed\n' "$total" "$failed" +[ "$failed" -eq 0 ] diff --git a/tests/test_binds.sh b/tests/test_binds.sh new file mode 100755 index 0000000000..58b5106243 --- /dev/null +++ b/tests/test_binds.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env sh +# Checks the shipped Lua keybinds for collisions and unreachable binds. + +. "$(dirname -- "$0")/lib/common.sh" + +if ! command -v lua >/dev/null 2>&1; then + skip "lua is not installed" + finish +fi + +lua "$TESTS_DIR/lua/bind_harness.lua" || fail "bind_harness reported defects" + +finish diff --git a/tests/test_dots.sh b/tests/test_dots.sh new file mode 100755 index 0000000000..980934ab38 --- /dev/null +++ b/tests/test_dots.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env sh +# The installer's dot metafiles have to be well formed. + +. "$(dirname -- "$0")/lib/common.sh" + +if ! command -v python3 >/dev/null 2>&1; then + skip "python3 is not installed" + finish +fi + +python3 "$TESTS_DIR/python/check_dots.py" || fail "check_dots reported defects" + +finish diff --git a/tests/test_git.sh b/tests/test_git.sh new file mode 100755 index 0000000000..959618cc64 --- /dev/null +++ b/tests/test_git.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env sh +# The tree must not hold a gitlink without a matching submodule declaration. +# +# A commit entry with no `.gitmodules` counterpart makes `git submodule` fail +# for everyone who clones, and takes down any tooling that walks submodules, +# `actions/checkout` among them. + +. "$(dirname -- "$0")/lib/common.sh" + +if ! command -v git >/dev/null 2>&1; then + skip "git is not installed" + finish +fi + +if ! git -C "$REPO_ROOT" rev-parse --git-dir >/dev/null 2>&1; then + skip "not a git checkout" + finish +fi + +gitlinks=$(git -C "$REPO_ROOT" ls-tree -r HEAD | awk '$2 == "commit" { print $4 }') + +count=0 +for path in $gitlinks; do + count=$((count + 1)) + + if [ ! -f "$REPO_ROOT/.gitmodules" ]; then + fail "$path is a gitlink but the tree has no .gitmodules" + continue + fi + + if ! grep -q "path *= *$path\$" "$REPO_ROOT/.gitmodules"; then + fail "$path is a gitlink with no entry in .gitmodules" + fi +done + +printf ' %d gitlink(s) checked\n' "$count" +finish diff --git a/tests/test_lua_syntax.sh b/tests/test_lua_syntax.sh new file mode 100755 index 0000000000..1ee8f8b3ff --- /dev/null +++ b/tests/test_lua_syntax.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env sh +# Every shipped Lua file has to parse. + +. "$(dirname -- "$0")/lib/common.sh" + +if ! command -v luac >/dev/null 2>&1; then + skip "luac is not installed" + finish +fi + +count=0 +for file in $(find "$REPO_ROOT/Configs" -name '*.lua' -type f | sort); do + count=$((count + 1)) + luac -p "$file" >/dev/null 2>&1 || fail "${file#"$REPO_ROOT"/} does not parse" +done + +printf ' %d file(s) checked\n' "$count" +finish diff --git a/tests/test_shell.sh b/tests/test_shell.sh new file mode 100755 index 0000000000..2859c016b2 --- /dev/null +++ b/tests/test_shell.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env sh +# Every shipped shell script has to parse, and shellcheck has to find no +# error-severity problem in it. +# +# Parsing is done with bash rather than the interpreter each script declares. +# Several scripts carry an sh shebang while using arrays and `local`, and +# hyde-shell runs every .sh with bash regardless, so checking them as POSIX sh +# would report a mismatch this suite is not the place to resolve. + +. "$(dirname -- "$0")/lib/common.sh" + +scripts=$(find "$REPO_ROOT/Configs" "$REPO_ROOT/Scripts" "$REPO_ROOT/tests" -name '*.sh' -type f | sort) + +count=0 +for file in $scripts; do + count=$((count + 1)) + + bash -n "$file" 2>/dev/null || fail "${file#"$REPO_ROOT"/} does not parse" +done + +printf ' %d file(s) parsed\n' "$count" + +if ! command -v shellcheck >/dev/null 2>&1; then + skip "shellcheck is not installed" + finish +fi + +report=$(printf '%s\n' "$scripts" | xargs shellcheck --severity=error --format=gcc) +status=$? + +if [ -n "$report" ]; then + printf '%s\n' "$report" | while IFS= read -r line; do + printf ' %s\n' "$line" + done + fail "shellcheck reported error-severity findings" +elif [ "$status" -ne 0 ]; then + fail "shellcheck exited with $status without reporting anything" +fi + +finish From 4a8e3edadc8c7420e876551782c5800009b23c2b Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Wed, 29 Jul 2026 18:21:55 +0700 Subject: [PATCH 2/2] #1867 test: check touchpad gestures alongside keybinds --- tests/README.md | 2 +- tests/lua/bind_harness.lua | 66 +++++++++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/tests/README.md b/tests/README.md index 409c1efbdb..6be9f57cb6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -19,7 +19,7 @@ is adding a file. | Case | Checks | | --- | --- | -| `test_binds.sh` | Loads the Lua keybinds against a stubbed Hyprland API: no two binds share a combination once modifiers are folded to what Hyprland matches on, no keysym sits in a modifier position, no bind uses the `code:NN` form the Lua parser rejects, every bind has a description, every `hyde-shell` command it runs exists | +| `test_binds.sh` | Loads the Lua keybinds against a stubbed Hyprland API: no two binds share a combination once modifiers are folded to what Hyprland matches on, no keysym sits in a modifier position, no bind uses the `code:NN` form the Lua parser rejects, every bind has a description, every `hyde-shell` command it runs exists. Touchpad gestures are checked in the same pass: a valid finger count, a direction and action Hyprland accepts, and no two gestures on the same finger count and direction | | `test_git.sh` | The tree holds no gitlink without a matching `.gitmodules` entry, which would break `git submodule` and anything walking submodules | | `test_dots.sh` | Every installer metafile under `Scripts/dots` parses, declares the keys the installer needs, uses a known action, and points at a source directory that exists | | `test_lua_syntax.sh` | Every shipped Lua file parses | diff --git a/tests/lua/bind_harness.lua b/tests/lua/bind_harness.lua index ad4cc1f44e..f2bd48837a 100644 --- a/tests/lua/bind_harness.lua +++ b/tests/lua/bind_harness.lua @@ -106,6 +106,32 @@ local function command_resolves(name) end local binds = {} +local gestures = {} + +-- Values Hyprland accepts, taken from what it rejects at config load: +-- an unknown direction or action is reported as a config error, but a +-- duplicate pair is not, and that is the one that silently wins or loses. +local GESTURE_DIRECTIONS = { + horizontal = true, + vertical = true, + left = true, + right = true, + up = true, + down = true, + pinchin = true, + pinchout = true, + swipe = true +} + +local GESTURE_ACTIONS = { + workspace = true, + move = true, + close = true, + fullscreen = true, + float = true, + special = true, + resize = true +} local function dsp_proxy(prefix) return setmetatable( @@ -138,6 +164,10 @@ _G.hl.bind = function(combo, action, opts) binds[#binds + 1] = {combo = combo, action = action, opts = opts} end +_G.hl.gesture = function(spec) + gestures[#gestures + 1] = spec +end + _G.hl.unbind = function(combo) for index = #binds, 1, -1 do if binds[index].combo == combo then @@ -217,5 +247,39 @@ for _, bind in ipairs(binds) do end end -print(string.format(" %d bind(s) checked", #binds)) +local gesture_seen = {} +for index, gesture in ipairs(gestures) do + local where = string.format("gesture %d", index) + + if type(gesture) ~= "table" then + check(false, where .. " is not a table") + else + local fingers = tonumber(gesture.fingers) + check(fingers ~= nil and fingers >= 3, where .. " needs a finger count of 3 or more") + + local direction = tostring(gesture.direction or "") + check( + GESTURE_DIRECTIONS[direction] ~= nil, + string.format("%s has direction %q, which Hyprland does not accept", where, direction) + ) + + local action = tostring(gesture.action or "") + check( + GESTURE_ACTIONS[action] ~= nil, + string.format("%s has action %q, which Hyprland does not accept", where, action) + ) + + -- One swipe cannot carry two meanings, and Hyprland says nothing when + -- it does: the second declaration simply shadows the first. + local id = tostring(fingers) .. "|" .. direction + check( + gesture_seen[id] == nil, + string.format("%d fingers %s is declared twice, as %q and %q", fingers or 0, direction, + tostring(gesture_seen[id]), action) + ) + gesture_seen[id] = action + end +end + +print(string.format(" %d bind(s) and %d gesture(s) checked", #binds, #gestures)) os.exit(failures == 0 and 0 or 1)