Close bats coverage gaps, add hide_command helper, wire up kcov
- tests/helpers/common.bash: hide_command hides python3/jq/rg via a per-directory-batched PATH shim (not per-file — that took minutes), making the fail-open/grep-fallback branches testable. - 20 new tests across all 7 .bats files: unknown flags, invalid --settings-scope, missing python3/jq, grep fallback when rg is unavailable, empty/multi-vault config edge cases, a vault path that stops existing or isn't a directory, and the previously-untested tool_name != Bash branch of force-ask-on-raw-git.sh. 53 -> 73 tests. - hooks.bats: deploy the hooks once in setup_file() (BATS_FILE_TMPDIR) instead of per-test (BATS_TEST_TMPDIR) -- faster, and gives kcov one stable path per hook to aggregate coverage against instead of a fragmented copy per test. - .gitignore (first in this repo) + tests/README.md Coverage section documenting the kcov invocation and why --include-pattern needs three entries, not two. Real, tool-measured coverage via kcov: 86.15% (255/296 lines) across all 10 wrapper/setup/hook scripts in both plugins, replacing an earlier manual ~68% branch-coverage estimate.
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
tests/coverage/
|
||||||
@@ -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
|
There's no CI wiring for this suite yet — run it manually before/after
|
||||||
touching anything under `skills/` or `vendor/claude-code/plugins/`.
|
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.<hash>/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.
|
||||||
|
|||||||
@@ -123,3 +123,9 @@ setup() {
|
|||||||
commit_lines=$(printf '%s\n' "$output" | grep -c '^[0-9a-f]\{7,\} ')
|
commit_lines=$(printf '%s\n' "$output" | grep -c '^[0-9a-f]\{7,\} ')
|
||||||
[ "$commit_lines" -eq 2 ]
|
[ "$commit_lines" -eq 2 ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "an argument outside --repo/-- is rejected" {
|
||||||
|
run "$SCRIPT" status extra
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"unexpected argument 'extra'"* ]]
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,15 +4,26 @@
|
|||||||
# repo source under hooks/ directly, because force-ask-on-raw-git.sh only
|
# repo source under hooks/ directly, because force-ask-on-raw-git.sh only
|
||||||
# gets its companion _lib.sh (for the "already supports" wording) once
|
# gets its companion _lib.sh (for the "already supports" wording) once
|
||||||
# deployed alongside it — exactly like real usage.
|
# 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'
|
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() {
|
setup() {
|
||||||
sandbox_home
|
CHAIN_HOOK="$BATS_FILE_TMPDIR/home/.agent-skills/git-manager/bin/force-ask-on-chaining.sh"
|
||||||
SETUP="$REPO_ROOT/vendor/claude-code/plugins/git-manager/scripts/setup.sh"
|
RAWGIT_HOOK="$BATS_FILE_TMPDIR/home/.agent-skills/git-manager/bin/force-ask-on-raw-git.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"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- force-ask-on-chaining.sh ---
|
# --- force-ask-on-chaining.sh ---
|
||||||
@@ -38,6 +49,14 @@ setup() {
|
|||||||
[ -z "$output" ]
|
[ -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 ---
|
# --- force-ask-on-raw-git.sh ---
|
||||||
|
|
||||||
@test "raw-git hook: git_cmd.sh invocations are exempted" {
|
@test "raw-git hook: git_cmd.sh invocations are exempted" {
|
||||||
@@ -69,3 +88,18 @@ setup() {
|
|||||||
[ "$status" -eq 0 ]
|
[ "$status" -eq 0 ]
|
||||||
[ -z "$output" ]
|
[ -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" ]
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,3 +104,22 @@ print(any('force-ask-on-raw-git.sh' in c for c in cmds))
|
|||||||
[ -f "$PROJECT_DIR/.claude/settings.json" ]
|
[ -f "$PROJECT_DIR/.claude/settings.json" ]
|
||||||
[ ! -e "$HOME/.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"* ]]
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,6 +87,38 @@ with open(config_file, "w") as f:
|
|||||||
PYEOF
|
PYEOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# hide_command <name> — makes <name> unresolvable for the rest of the test
|
||||||
|
# by building a shim directory with symlinks to every executable currently
|
||||||
|
# reachable via $PATH *except* <name>, 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 <file> <dotted.path> — small helper for assertions against JSON
|
# json_get <file> <dotted.path> — small helper for assertions against JSON
|
||||||
# written by setup.sh, without needing jq as a test dependency.
|
# written by setup.sh, without needing jq as a test dependency.
|
||||||
json_get() {
|
json_get() {
|
||||||
|
|||||||
@@ -62,3 +62,31 @@ setup() {
|
|||||||
mode=$(json_get "$CONFIG_FILE" "mode")
|
mode=$(json_get "$CONFIG_FILE" "mode")
|
||||||
[ "$mode" = '"maintain"' ]
|
[ "$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 <project-dir>/.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"* ]]
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,3 +50,10 @@ EOF
|
|||||||
[ "$status" -eq 0 ]
|
[ "$status" -eq 0 ]
|
||||||
[[ "$output" == *"no backlinks found"* ]]
|
[[ "$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"* ]]
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,3 +45,54 @@ setup() {
|
|||||||
[ "$status" -eq 1 ]
|
[ "$status" -eq 1 ]
|
||||||
[[ "$output" == *"no vault named"* ]]
|
[[ "$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"* ]]
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,3 +64,16 @@ setup() {
|
|||||||
[ "$status" -eq 1 ]
|
[ "$status" -eq 1 ]
|
||||||
[[ "$output" == *"outside the vault"* ]]
|
[[ "$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"* ]]
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user