diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29d39ae --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +tests/coverage/ diff --git a/tests/README.md b/tests/README.md index d96bba2..b0a901e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -37,3 +37,25 @@ under test. Since every script here resolves its config from There's no CI wiring for this suite yet — run it manually before/after touching anything under `skills/` or `vendor/claude-code/plugins/`. + +## Coverage + +```bash +sudo apt install kcov # or a user-local install, as done for bats +kcov --include-pattern=/repo/skills/,/repo/vendor/claude-code/plugins/,force-ask-on-chaining.sh,force-ask-on-raw-git.sh \ + tests/coverage bats -r tests/ +``` + +Opens as `tests/coverage/bats./index.html` (not committed — see +`.gitignore`). `kcov` follows every bash subprocess `bats` forks, so this +covers the actual wrapper/setup/hook scripts, not just `bats` itself. + +The `--include-pattern` needs three things, not just the two source +directories: `git_cmd.sh`/`vault_*.sh`/`setup.sh` run straight from this +checkout, so `/repo/skills/` and `/repo/vendor/claude-code/plugins/` catch +them (the leading `/repo/` matters — a plain `skills/` would also match this +repo's own outer directory name and pull in unrelated files). The two +`PreToolUse` hooks are different: `tests/git-manager/hooks.bats` exercises +the *deployed* copies under `~/.agent-skills/git-manager/bin/` (matching how +Claude Code actually invokes them), which live outside both of those +directories — hence matching them by filename instead. diff --git a/tests/git-manager/git_cmd.bats b/tests/git-manager/git_cmd.bats index 4411aa9..71cf848 100644 --- a/tests/git-manager/git_cmd.bats +++ b/tests/git-manager/git_cmd.bats @@ -123,3 +123,9 @@ setup() { commit_lines=$(printf '%s\n' "$output" | grep -c '^[0-9a-f]\{7,\} ') [ "$commit_lines" -eq 2 ] } + +@test "an argument outside --repo/-- is rejected" { + run "$SCRIPT" status extra + [ "$status" -eq 1 ] + [[ "$output" == *"unexpected argument 'extra'"* ]] +} diff --git a/tests/git-manager/hooks.bats b/tests/git-manager/hooks.bats index 7b43178..0a3de9c 100644 --- a/tests/git-manager/hooks.bats +++ b/tests/git-manager/hooks.bats @@ -4,15 +4,26 @@ # repo source under hooks/ directly, because force-ask-on-raw-git.sh only # gets its companion _lib.sh (for the "already supports" wording) once # deployed alongside it — exactly like real usage. +# +# Deployed once in setup_file() (bats-core: runs once per file, not per +# test) into $BATS_FILE_TMPDIR rather than a fresh $BATS_TEST_TMPDIR/home +# per test: besides being faster (one setup.sh run instead of one per +# test), it gives every test in this file the *same* absolute script path, +# which matters for coverage tooling (kcov) to aggregate hits against one +# file instead of splintering across a differently-pathed copy per test. load '../helpers/common' +setup_file() { + FILE_HOME="$BATS_FILE_TMPDIR/home" + mkdir -p "$FILE_HOME" + HOME="$FILE_HOME" "$REPO_ROOT/vendor/claude-code/plugins/git-manager/scripts/setup.sh" \ + --settings-scope user --install-hook --install-raw-git-hook > /dev/null +} + setup() { - sandbox_home - SETUP="$REPO_ROOT/vendor/claude-code/plugins/git-manager/scripts/setup.sh" - "$SETUP" --settings-scope user --install-hook --install-raw-git-hook > /dev/null - CHAIN_HOOK="$HOME/.agent-skills/git-manager/bin/force-ask-on-chaining.sh" - RAWGIT_HOOK="$HOME/.agent-skills/git-manager/bin/force-ask-on-raw-git.sh" + CHAIN_HOOK="$BATS_FILE_TMPDIR/home/.agent-skills/git-manager/bin/force-ask-on-chaining.sh" + RAWGIT_HOOK="$BATS_FILE_TMPDIR/home/.agent-skills/git-manager/bin/force-ask-on-raw-git.sh" } # --- force-ask-on-chaining.sh --- @@ -38,6 +49,14 @@ setup() { [ -z "$output" ] } +@test "chaining hook: missing jq fails open instead of blocking every Bash call" { + input='{"tool_name":"Bash","tool_input":{"command":"a && b"}}' + hide_command jq + run "$CHAIN_HOOK" <<< "$input" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + # --- force-ask-on-raw-git.sh --- @test "raw-git hook: git_cmd.sh invocations are exempted" { @@ -69,3 +88,18 @@ setup() { [ "$status" -eq 0 ] [ -z "$output" ] } + +@test "raw-git hook: non-Bash tool_name produces no output even for raw git" { + input='{"tool_name":"Read","tool_input":{"command":"git status"}}' + run "$RAWGIT_HOOK" <<< "$input" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "raw-git hook: missing jq fails open instead of blocking every Bash call" { + input='{"tool_name":"Bash","tool_input":{"command":"git status"}}' + hide_command jq + run "$RAWGIT_HOOK" <<< "$input" + [ "$status" -eq 0 ] + [ -z "$output" ] +} diff --git a/tests/git-manager/setup.bats b/tests/git-manager/setup.bats index 51b7e56..9f752c0 100644 --- a/tests/git-manager/setup.bats +++ b/tests/git-manager/setup.bats @@ -104,3 +104,22 @@ print(any('force-ask-on-raw-git.sh' in c for c in cmds)) [ -f "$PROJECT_DIR/.claude/settings.json" ] [ ! -e "$HOME/.claude/settings.json" ] } + +@test "an unknown flag errors" { + run "$SETUP" --settings-scope user --bogus-flag + [ "$status" -eq 1 ] + [[ "$output" == *"unknown argument '--bogus-flag'"* ]] +} + +@test "an invalid --settings-scope value errors" { + run "$SETUP" --settings-scope nonsense + [ "$status" -eq 1 ] + [[ "$output" == *"--settings-scope must be 'project' or 'user'"* ]] +} + +@test "missing python3 fails with a clear error instead of a silent crash" { + hide_command python3 + run "$SETUP" --settings-scope user + [ "$status" -eq 1 ] + [[ "$output" == *"python3 is required"* ]] +} diff --git a/tests/helpers/common.bash b/tests/helpers/common.bash index 7cee19e..ca030db 100644 --- a/tests/helpers/common.bash +++ b/tests/helpers/common.bash @@ -87,6 +87,38 @@ with open(config_file, "w") as f: PYEOF } +# hide_command — makes unresolvable for the rest of the test +# by building a shim directory with symlinks to every executable currently +# reachable via $PATH *except* , then replacing $PATH with just that +# directory. A plain "remove one directory from $PATH" doesn't work here: +# python3/jq/rg all live in the same directory (/usr/bin) as git/sed/grep/ +# realpath/etc that the scripts under test also need, so excluding that one +# directory would break everything, not just the target command. Callers +# must do any setup that itself needs the now-hidden command (e.g. writing +# a config file with write_git_manager_config/write_vault_config, both of +# which shell out to python3) *before* calling hide_command. +hide_command() { + local hidden="$1" + local shim="$BATS_TEST_TMPDIR/shim-no-$hidden" + mkdir -p "$shim" + local dir + local oldIFS="$IFS" + IFS=':' + local dirs=($PATH) + IFS="$oldIFS" + for dir in "${dirs[@]}"; do + [ -d "$dir" ] || continue + # One `ln` call per PATH directory (not per file) — forking `basename` + # and `ln` per file made this take minutes with a few thousand files + # spread across a dozen PATH dirs. `-n` skips a name already placed by + # an earlier (higher-priority) directory, matching normal PATH lookup + # order; unreadable dirs/empty globs are silenced. + ln -sn -t "$shim" "$dir"/* 2>/dev/null || true + done + rm -f "$shim/$hidden" + export PATH="$shim" +} + # json_get — small helper for assertions against JSON # written by setup.sh, without needing jq as a test dependency. json_get() { diff --git a/tests/obsidian-vault-kb/setup.bats b/tests/obsidian-vault-kb/setup.bats index c804a70..753459f 100644 --- a/tests/obsidian-vault-kb/setup.bats +++ b/tests/obsidian-vault-kb/setup.bats @@ -62,3 +62,31 @@ setup() { mode=$(json_get "$CONFIG_FILE" "mode") [ "$mode" = '"maintain"' ] } + +@test "an unknown flag errors" { + run "$SETUP" --settings-scope user --mode append --vault v="$VAULT" --bogus-flag + [ "$status" -eq 1 ] + [[ "$output" == *"unknown argument '--bogus-flag'"* ]] +} + +@test "an invalid --settings-scope value errors" { + run "$SETUP" --settings-scope nonsense --mode append --vault v="$VAULT" + [ "$status" -eq 1 ] + [[ "$output" == *"--settings-scope must be 'project' or 'user'"* ]] +} + +@test "--settings-scope project writes under /.claude/settings.json" { + PROJECT_DIR="$BATS_TEST_TMPDIR/someproject" + mkdir -p "$PROJECT_DIR" + run "$SETUP" --settings-scope project --project-dir "$PROJECT_DIR" --mode append --vault v="$VAULT" + [ "$status" -eq 0 ] + [ -f "$PROJECT_DIR/.claude/settings.json" ] + [ ! -e "$HOME/.claude/settings.json" ] +} + +@test "missing python3 fails with a clear error instead of a silent crash" { + hide_command python3 + run "$SETUP" --settings-scope user --mode append --vault v="$VAULT" + [ "$status" -eq 1 ] + [[ "$output" == *"python3 is required"* ]] +} diff --git a/tests/obsidian-vault-kb/vault_backlinks.bats b/tests/obsidian-vault-kb/vault_backlinks.bats index dda0196..03ce763 100644 --- a/tests/obsidian-vault-kb/vault_backlinks.bats +++ b/tests/obsidian-vault-kb/vault_backlinks.bats @@ -50,3 +50,10 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"no backlinks found"* ]] } + +@test "grep fallback still finds backlinks when rg is unavailable" { + hide_command rg + run "$SCRIPT" homelab-overview + [ "$status" -eq 0 ] + [[ "$output" == *"dns-setup.md"* ]] +} diff --git a/tests/obsidian-vault-kb/vault_index.bats b/tests/obsidian-vault-kb/vault_index.bats index 163d4bd..5bb5ad4 100644 --- a/tests/obsidian-vault-kb/vault_index.bats +++ b/tests/obsidian-vault-kb/vault_index.bats @@ -45,3 +45,54 @@ setup() { [ "$status" -eq 1 ] [[ "$output" == *"no vault named"* ]] } + +# --- resolve_vault_path / canonicalize_and_check_dir edge cases --- +# (exercised via vault_index.sh as a representative of the shared _lib.sh +# logic used identically by vault_search.sh and vault_backlinks.sh) + +@test "no config file at all errors" { + rm -f "$CONFIG_FILE" + run "$SCRIPT" + [ "$status" -eq 1 ] + [[ "$output" == *"no configuration found"* ]] +} + +@test "an empty vaults array errors" { + write_vault_config "$CONFIG_FILE" "append" + run "$SCRIPT" + [ "$status" -eq 1 ] + [[ "$output" == *"no vaults defined"* ]] +} + +@test "multiple vaults registered without a name errors and lists names" { + VAULT2="$BATS_TEST_TMPDIR/vault2" + make_test_vault "$VAULT2" + write_vault_config "$CONFIG_FILE" "append" "v=$VAULT" "v2=$VAULT2" + run "$SCRIPT" + [ "$status" -eq 1 ] + [[ "$output" == *"multiple vaults configured"* ]] + [[ "$output" == *"v2"* ]] +} + +@test "a registered vault path that no longer exists errors" { + write_vault_config "$CONFIG_FILE" "append" "gone=$BATS_TEST_TMPDIR/does-not-exist" + run "$SCRIPT" gone + [ "$status" -eq 1 ] + [[ "$output" == *"does not exist"* ]] +} + +@test "a registered vault path that is a file, not a directory, errors" { + FILE_PATH="$BATS_TEST_TMPDIR/not-a-dir" + echo "just a file" > "$FILE_PATH" + write_vault_config "$CONFIG_FILE" "append" "plain=$FILE_PATH" + run "$SCRIPT" plain + [ "$status" -eq 1 ] + [[ "$output" == *"is not a directory"* ]] +} + +@test "missing python3 fails with a clear error instead of a silent crash" { + hide_command python3 + run "$SCRIPT" v + [ "$status" -eq 1 ] + [[ "$output" == *"python3 is required"* ]] +} diff --git a/tests/obsidian-vault-kb/vault_search.bats b/tests/obsidian-vault-kb/vault_search.bats index 83a565e..fc6ac44 100644 --- a/tests/obsidian-vault-kb/vault_search.bats +++ b/tests/obsidian-vault-kb/vault_search.bats @@ -64,3 +64,16 @@ setup() { [ "$status" -eq 1 ] [[ "$output" == *"outside the vault"* ]] } + +@test "a subfolder that doesn't exist in the vault errors" { + run "$SCRIPT" pihole v NoSuchFolder + [ "$status" -eq 1 ] + [[ "$output" == *"does not exist in the vault"* ]] +} + +@test "grep fallback still finds matches when rg is unavailable" { + hide_command rg + run "$SCRIPT" pihole + [ "$status" -eq 0 ] + [[ "$output" == *"dns-setup.md"* ]] +}