Add bats test suite for git-manager and obsidian-vault-kb
Isolated per-test $HOME sandboxing via tests/helpers/common.bash. Covers both plugins' wrapper scripts, their setup.sh, and git-manager's two PreToolUse hooks (53 tests). Verified the suite catches regressions by temporarily reintroducing the recently-fixed vault_search.sh -g/-- ordering bug and confirming it fails.
This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
# Tests
|
||||||
|
|
||||||
|
A [bats-core](https://bats-core.readthedocs.io/) suite covering both
|
||||||
|
plugins' wrapper scripts, their `setup.sh`, and git-manager's two
|
||||||
|
`PreToolUse` hooks. Plain bats assertions only (`run` + `[ ]`/`[[ ]]`) — no
|
||||||
|
extra helper libraries.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install bats
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bats -r tests/ # everything (-r: recurse into subdirs)
|
||||||
|
bats -r tests/git-manager/ # one plugin
|
||||||
|
bats tests/obsidian-vault-kb/vault_search.bats # one file
|
||||||
|
```
|
||||||
|
|
||||||
|
## How it's isolated
|
||||||
|
|
||||||
|
Every test points `$HOME` at a fresh, bats-managed `$BATS_TEST_TMPDIR/home`
|
||||||
|
(see `tests/helpers/common.bash`, `sandbox_home`) before running any script
|
||||||
|
under test. Since every script here resolves its config from
|
||||||
|
`~/.agent-skills/<name>/config.json` and setup.sh writes to
|
||||||
|
`~/.claude/settings.json`, this means:
|
||||||
|
|
||||||
|
- Tests never read or write your real `~/.agent-skills/` or
|
||||||
|
`~/.claude/settings.json`.
|
||||||
|
- Nothing needs manual cleanup — bats deletes `$BATS_TEST_TMPDIR` after
|
||||||
|
each test automatically.
|
||||||
|
- Tests that exercise a `setup.sh` run the real script end to end (it's
|
||||||
|
what deploys the stable script copies + writes permissions/config), not a
|
||||||
|
mocked version.
|
||||||
|
|
||||||
|
There's no CI wiring for this suite yet — run it manually before/after
|
||||||
|
touching anything under `skills/` or `vendor/claude-code/plugins/`.
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env bats
|
||||||
|
# Tests for skills/git-manager/scripts/git_cmd.sh + _lib.sh.
|
||||||
|
|
||||||
|
load '../helpers/common'
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
sandbox_home
|
||||||
|
SCRIPT="$REPO_ROOT/skills/git-manager/scripts/git_cmd.sh"
|
||||||
|
CONFIG_FILE="$HOME/.agent-skills/git-manager/config.json"
|
||||||
|
REPO="$BATS_TEST_TMPDIR/repo"
|
||||||
|
make_test_repo "$REPO"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- subcommand allowlist ---
|
||||||
|
|
||||||
|
@test "allowed subcommand (status) runs and returns real git output" {
|
||||||
|
write_git_manager_config "$CONFIG_FILE" "myrepo=$REPO"
|
||||||
|
run "$SCRIPT" status --repo myrepo
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"nothing to commit"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "disallowed subcommand (stash) is rejected before git ever runs" {
|
||||||
|
write_git_manager_config "$CONFIG_FILE" "myrepo=$REPO"
|
||||||
|
run "$SCRIPT" stash --repo myrepo
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"is not allowed"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "no subcommand at all prints usage and exits 1" {
|
||||||
|
run "$SCRIPT"
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"Usage:"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- repo resolution ---
|
||||||
|
|
||||||
|
@test "no config file at all errors" {
|
||||||
|
run "$SCRIPT" status
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"no configuration found"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "zero repos registered errors" {
|
||||||
|
write_git_manager_config "$CONFIG_FILE"
|
||||||
|
run "$SCRIPT" status
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"no repos registered"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "exactly one repo registered auto-resolves without --repo" {
|
||||||
|
write_git_manager_config "$CONFIG_FILE" "myrepo=$REPO"
|
||||||
|
run "$SCRIPT" status
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"$REPO"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "multiple repos registered without --repo errors and lists names" {
|
||||||
|
REPO2="$BATS_TEST_TMPDIR/repo2"
|
||||||
|
make_test_repo "$REPO2"
|
||||||
|
write_git_manager_config "$CONFIG_FILE" "myrepo=$REPO" "other=$REPO2"
|
||||||
|
run "$SCRIPT" status
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"multiple repos configured"* ]]
|
||||||
|
[[ "$output" == *"myrepo"* ]]
|
||||||
|
[[ "$output" == *"other"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "--repo selects the named repo among several registered" {
|
||||||
|
REPO2="$BATS_TEST_TMPDIR/repo2"
|
||||||
|
make_test_repo "$REPO2"
|
||||||
|
write_git_manager_config "$CONFIG_FILE" "myrepo=$REPO" "other=$REPO2"
|
||||||
|
run "$SCRIPT" status --repo other
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"$REPO2"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "unknown --repo name errors and lists registered names" {
|
||||||
|
write_git_manager_config "$CONFIG_FILE" "myrepo=$REPO"
|
||||||
|
run "$SCRIPT" status --repo doesnotexist
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"no repo named 'doesnotexist'"* ]]
|
||||||
|
[[ "$output" == *"myrepo"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- canonicalize_and_check_repo ---
|
||||||
|
|
||||||
|
@test "registered path that no longer exists errors" {
|
||||||
|
write_git_manager_config "$CONFIG_FILE" "gone=$BATS_TEST_TMPDIR/does-not-exist"
|
||||||
|
run "$SCRIPT" status --repo gone
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"does not exist"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "registered path that isn't a git work tree errors" {
|
||||||
|
PLAIN_DIR="$BATS_TEST_TMPDIR/not-a-repo"
|
||||||
|
mkdir -p "$PLAIN_DIR"
|
||||||
|
write_git_manager_config "$CONFIG_FILE" "plain=$PLAIN_DIR"
|
||||||
|
run "$SCRIPT" status --repo plain
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"not inside a git work tree"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a registered path nested inside the repo still resolves to the toplevel" {
|
||||||
|
mkdir -p "$REPO/subdir"
|
||||||
|
write_git_manager_config "$CONFIG_FILE" "nested=$REPO/subdir"
|
||||||
|
run "$SCRIPT" status --repo nested
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"== Repo: $REPO =="* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- -- passthrough ---
|
||||||
|
|
||||||
|
@test "args after -- reach git unmodified" {
|
||||||
|
git -C "$REPO" -c user.email=test@test -c user.name=test \
|
||||||
|
commit -q --allow-empty -m "second"
|
||||||
|
git -C "$REPO" -c user.email=test@test -c user.name=test \
|
||||||
|
commit -q --allow-empty -m "third"
|
||||||
|
write_git_manager_config "$CONFIG_FILE" "myrepo=$REPO"
|
||||||
|
run "$SCRIPT" log --repo myrepo -- --oneline -n2
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
# 2 commit lines + the script's own "== Repo ==" / "== Running ==" banner lines
|
||||||
|
commit_lines=$(printf '%s\n' "$output" | grep -c '^[0-9a-f]\{7,\} ')
|
||||||
|
[ "$commit_lines" -eq 2 ]
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env bats
|
||||||
|
# Tests for the two optional PreToolUse hooks. Run against the *deployed*
|
||||||
|
# copies (via a real setup.sh run into a sandboxed $HOME) rather than the
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
load '../helpers/common'
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- force-ask-on-chaining.sh ---
|
||||||
|
|
||||||
|
@test "chaining hook: && in the command forces an ask" {
|
||||||
|
input='{"tool_name":"Bash","tool_input":{"command":"git add -A && git commit -m x"}}'
|
||||||
|
run "$CHAIN_HOOK" <<< "$input"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *'"permissionDecision": "ask"'* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "chaining hook: a clean command produces no output" {
|
||||||
|
input='{"tool_name":"Bash","tool_input":{"command":"git status"}}'
|
||||||
|
run "$CHAIN_HOOK" <<< "$input"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ -z "$output" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "chaining hook: non-Bash tool_name produces no output even with chaining chars" {
|
||||||
|
input='{"tool_name":"Read","tool_input":{"command":"a && b"}}'
|
||||||
|
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" {
|
||||||
|
input='{"tool_name":"Bash","tool_input":{"command":"~/.agent-skills/git-manager/bin/git_cmd.sh status"}}'
|
||||||
|
run "$RAWGIT_HOOK" <<< "$input"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ -z "$output" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "raw-git hook: raw git on a wrapper-supported subcommand asks with 'already supports' wording" {
|
||||||
|
input='{"tool_name":"Bash","tool_input":{"command":"git status"}}'
|
||||||
|
run "$RAWGIT_HOOK" <<< "$input"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *'"permissionDecision": "ask"'* ]]
|
||||||
|
[[ "$output" == *"already supports"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "raw-git hook: raw git on an unsupported subcommand asks with 'accepted fallback' wording" {
|
||||||
|
input='{"tool_name":"Bash","tool_input":{"command":"git stash"}}'
|
||||||
|
run "$RAWGIT_HOOK" <<< "$input"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *'"permissionDecision": "ask"'* ]]
|
||||||
|
[[ "$output" == *"accepted fallback"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "raw-git hook: a non-git command produces no output" {
|
||||||
|
input='{"tool_name":"Bash","tool_input":{"command":"ls -la"}}'
|
||||||
|
run "$RAWGIT_HOOK" <<< "$input"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ -z "$output" ]
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env bats
|
||||||
|
# Tests for vendor/claude-code/plugins/git-manager/scripts/setup.sh.
|
||||||
|
|
||||||
|
load '../helpers/common'
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
sandbox_home
|
||||||
|
SETUP="$REPO_ROOT/vendor/claude-code/plugins/git-manager/scripts/setup.sh"
|
||||||
|
SETTINGS_FILE="$HOME/.claude/settings.json"
|
||||||
|
CONFIG_FILE="$HOME/.agent-skills/git-manager/config.json"
|
||||||
|
REPO="$BATS_TEST_TMPDIR/repo"
|
||||||
|
make_test_repo "$REPO"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "writes the two expected tilde-form permission rules" {
|
||||||
|
run "$SETUP" --settings-scope user
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
allow=$(json_get "$SETTINGS_FILE" "permissions.allow")
|
||||||
|
[[ "$allow" == *'Bash(~/.agent-skills/git-manager/bin/git_cmd.sh:*)'* ]]
|
||||||
|
[[ "$allow" == *'Read(~/.agent-skills/git-manager/config.json)'* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "merges into an existing settings.json without disturbing unrelated keys" {
|
||||||
|
mkdir -p "$(dirname "$SETTINGS_FILE")"
|
||||||
|
cat > "$SETTINGS_FILE" << 'EOF'
|
||||||
|
{"permissions": {"allow": ["Bash(ls:*)"]}, "unrelatedTopLevelKey": "keep-me"}
|
||||||
|
EOF
|
||||||
|
run "$SETUP" --settings-scope user
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
allow=$(json_get "$SETTINGS_FILE" "permissions.allow")
|
||||||
|
[[ "$allow" == *'Bash(ls:*)'* ]]
|
||||||
|
[[ "$allow" == *'Bash(~/.agent-skills/git-manager/bin/git_cmd.sh:*)'* ]]
|
||||||
|
kept=$(json_get "$SETTINGS_FILE" "unrelatedTopLevelKey")
|
||||||
|
[ "$kept" = '"keep-me"' ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "re-running setup is idempotent: no duplicate allow entries" {
|
||||||
|
"$SETUP" --settings-scope user > /dev/null
|
||||||
|
run "$SETUP" --settings-scope user
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
count=$(python3 -c "import json; print(json.load(open('$SETTINGS_FILE'))['permissions']['allow'].count('Bash(~/.agent-skills/git-manager/bin/git_cmd.sh:*)'))")
|
||||||
|
[ "$count" -eq 1 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "--repo registers a new repo in config.json" {
|
||||||
|
run "$SETUP" --settings-scope user --repo myrepo="$REPO"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
path=$(json_get "$CONFIG_FILE" "repos.0.path")
|
||||||
|
[ "$path" = "\"$REPO\"" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "registering the same repo name twice updates the path instead of duplicating" {
|
||||||
|
REPO2="$BATS_TEST_TMPDIR/repo2"
|
||||||
|
make_test_repo "$REPO2"
|
||||||
|
"$SETUP" --settings-scope user --repo myrepo="$REPO" > /dev/null
|
||||||
|
run "$SETUP" --settings-scope user --repo myrepo="$REPO2"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
count=$(python3 -c "import json; print(len(json.load(open('$CONFIG_FILE'))['repos']))")
|
||||||
|
[ "$count" -eq 1 ]
|
||||||
|
path=$(json_get "$CONFIG_FILE" "repos.0.path")
|
||||||
|
[ "$path" = "\"$REPO2\"" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "without --install-hook or --install-raw-git-hook, no PreToolUse hooks are installed" {
|
||||||
|
run "$SETUP" --settings-scope user
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
run python3 -c "import json; print('hooks' in json.load(open('$SETTINGS_FILE')))"
|
||||||
|
[ "$output" = "False" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "--install-hook installs only the anti-chaining hook" {
|
||||||
|
run "$SETUP" --settings-scope user --install-hook
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
run python3 -c "
|
||||||
|
import json
|
||||||
|
h = json.load(open('$SETTINGS_FILE'))['hooks']['PreToolUse']
|
||||||
|
cmds = [c['command'] for e in h for c in e['hooks']]
|
||||||
|
print(len(cmds))
|
||||||
|
print(any('force-ask-on-chaining.sh' in c for c in cmds))
|
||||||
|
print(any('force-ask-on-raw-git.sh' in c for c in cmds))
|
||||||
|
"
|
||||||
|
[[ "$output" == $'1\nTrue\nFalse' ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "--install-raw-git-hook installs only the raw-git nudge hook" {
|
||||||
|
run "$SETUP" --settings-scope user --install-raw-git-hook
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
run python3 -c "
|
||||||
|
import json
|
||||||
|
h = json.load(open('$SETTINGS_FILE'))['hooks']['PreToolUse']
|
||||||
|
cmds = [c['command'] for e in h for c in e['hooks']]
|
||||||
|
print(len(cmds))
|
||||||
|
print(any('force-ask-on-chaining.sh' in c for c in cmds))
|
||||||
|
print(any('force-ask-on-raw-git.sh' in c for c in cmds))
|
||||||
|
"
|
||||||
|
[[ "$output" == $'1\nFalse\nTrue' ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@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"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ -f "$PROJECT_DIR/.claude/settings.json" ]
|
||||||
|
[ ! -e "$HOME/.claude/settings.json" ]
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Shared helpers for this repo's bats suites. Loaded with `load
|
||||||
|
# '../helpers/common'` at the top of each .bats file.
|
||||||
|
#
|
||||||
|
# The core trick used throughout: point $HOME at a fresh
|
||||||
|
# $BATS_TEST_TMPDIR/home before each test, so every script under test that
|
||||||
|
# reads/writes ~/.agent-skills/... or ~/.claude/settings.json is fully
|
||||||
|
# sandboxed and cleaned up automatically by bats after the test — no shared
|
||||||
|
# state between tests, and never touches the real machine's config.
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
|
||||||
|
# Call from a test file's setup() after exporting HOME.
|
||||||
|
sandbox_home() {
|
||||||
|
export HOME="$BATS_TEST_TMPDIR/home"
|
||||||
|
mkdir -p "$HOME"
|
||||||
|
}
|
||||||
|
|
||||||
|
# make_test_repo <dir> — git-init a throwaway repo with one commit so
|
||||||
|
# `git status`/`log`/etc have something real to operate on.
|
||||||
|
make_test_repo() {
|
||||||
|
local dir="$1"
|
||||||
|
mkdir -p "$dir"
|
||||||
|
git -C "$dir" init -q
|
||||||
|
git -C "$dir" -c user.email=test@test -c user.name=test \
|
||||||
|
commit -q --allow-empty -m "init"
|
||||||
|
}
|
||||||
|
|
||||||
|
# write_git_manager_config <config_file> <name>=<path> [<name>=<path> ...]
|
||||||
|
# Writes a git-manager config.json registering the given repos.
|
||||||
|
write_git_manager_config() {
|
||||||
|
local config_file="$1"; shift
|
||||||
|
mkdir -p "$(dirname "$config_file")"
|
||||||
|
python3 - "$config_file" "$@" << 'PYEOF'
|
||||||
|
import json, sys
|
||||||
|
config_file, pairs = sys.argv[1], sys.argv[2:]
|
||||||
|
repos = []
|
||||||
|
for p in pairs:
|
||||||
|
name, path = p.split("=", 1)
|
||||||
|
repos.append({"name": name, "path": path})
|
||||||
|
with open(config_file, "w") as f:
|
||||||
|
json.dump({"repos": repos}, f)
|
||||||
|
PYEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# make_test_vault <dir> — a small Obsidian-shaped vault: two notes with
|
||||||
|
# frontmatter tags that wikilink each other, plus .obsidian/.git noise and
|
||||||
|
# a non-markdown attachment, so exclusion behavior is exercisable.
|
||||||
|
make_test_vault() {
|
||||||
|
local dir="$1"
|
||||||
|
mkdir -p "$dir/Notes" "$dir/.obsidian" "$dir/.git"
|
||||||
|
|
||||||
|
cat > "$dir/Notes/dns-setup.md" << 'EOF'
|
||||||
|
---
|
||||||
|
tags: [networking, dns]
|
||||||
|
---
|
||||||
|
# DNS Setup
|
||||||
|
|
||||||
|
Configured pihole as the primary resolver. See [[homelab-overview]].
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$dir/Notes/homelab-overview.md" << 'EOF'
|
||||||
|
---
|
||||||
|
tags: [homelab, overview]
|
||||||
|
---
|
||||||
|
# Homelab Overview
|
||||||
|
|
||||||
|
Links to [[dns-setup]] and other topics.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "obsidian-internal-marker" > "$dir/.obsidian/config"
|
||||||
|
echo "attachment-only-marker" > "$dir/Notes/attachment.pdf"
|
||||||
|
}
|
||||||
|
|
||||||
|
# write_vault_config <config_file> <mode> <name>=<path> [<name>=<path> ...]
|
||||||
|
write_vault_config() {
|
||||||
|
local config_file="$1"; local mode="$2"; shift 2
|
||||||
|
mkdir -p "$(dirname "$config_file")"
|
||||||
|
python3 - "$config_file" "$mode" "$@" << 'PYEOF'
|
||||||
|
import json, sys
|
||||||
|
config_file, mode, pairs = sys.argv[1], sys.argv[2], sys.argv[3:]
|
||||||
|
vaults = []
|
||||||
|
for p in pairs:
|
||||||
|
name, path = p.split("=", 1)
|
||||||
|
vaults.append({"name": name, "path": path})
|
||||||
|
with open(config_file, "w") as f:
|
||||||
|
json.dump({"vaults": vaults, "mode": mode}, f)
|
||||||
|
PYEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# json_get <file> <dotted.path> — small helper for assertions against JSON
|
||||||
|
# written by setup.sh, without needing jq as a test dependency.
|
||||||
|
json_get() {
|
||||||
|
local file="$1" path="$2"
|
||||||
|
python3 -c '
|
||||||
|
import json, sys
|
||||||
|
with open(sys.argv[1]) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
for key in sys.argv[2].split("."):
|
||||||
|
if key == "":
|
||||||
|
continue
|
||||||
|
data = data[int(key)] if key.isdigit() else data[key]
|
||||||
|
print(json.dumps(data))
|
||||||
|
' "$file" "$path"
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bats
|
||||||
|
# Tests for vendor/claude-code/plugins/obsidian-vault-kb/scripts/setup.sh.
|
||||||
|
|
||||||
|
load '../helpers/common'
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
sandbox_home
|
||||||
|
SETUP="$REPO_ROOT/vendor/claude-code/plugins/obsidian-vault-kb/scripts/setup.sh"
|
||||||
|
SETTINGS_FILE="$HOME/.claude/settings.json"
|
||||||
|
CONFIG_FILE="$HOME/.agent-skills/obsidian-vault-kb/config.json"
|
||||||
|
VAULT="$BATS_TEST_TMPDIR/vault"
|
||||||
|
make_test_vault "$VAULT"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "writes the three Bash rules and one Read rule" {
|
||||||
|
run "$SETUP" --settings-scope user --mode append --vault v="$VAULT"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
allow=$(json_get "$SETTINGS_FILE" "permissions.allow")
|
||||||
|
[[ "$allow" == *'Bash(~/.agent-skills/obsidian-vault-kb/bin/vault_index.sh:*)'* ]]
|
||||||
|
[[ "$allow" == *'Bash(~/.agent-skills/obsidian-vault-kb/bin/vault_search.sh:*)'* ]]
|
||||||
|
[[ "$allow" == *'Bash(~/.agent-skills/obsidian-vault-kb/bin/vault_backlinks.sh:*)'* ]]
|
||||||
|
[[ "$allow" == *'Read(~/.agent-skills/obsidian-vault-kb/config.json)'* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "--mode outside the fixed set errors" {
|
||||||
|
run "$SETUP" --settings-scope user --mode not-a-real-mode --vault v="$VAULT"
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"--mode must be read-only, append, or maintain"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "at least one --vault is required" {
|
||||||
|
run "$SETUP" --settings-scope user --mode append
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"at least one --vault"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a --vault pointing at a nonexistent directory errors before writing config" {
|
||||||
|
run "$SETUP" --settings-scope user --mode append --vault v="$BATS_TEST_TMPDIR/nope"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[ ! -f "$CONFIG_FILE" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "registers the vault path and mode in config.json" {
|
||||||
|
run "$SETUP" --settings-scope user --mode append --vault v="$VAULT"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
mode=$(json_get "$CONFIG_FILE" "mode")
|
||||||
|
[ "$mode" = '"append"' ]
|
||||||
|
path=$(json_get "$CONFIG_FILE" "vaults.0.path")
|
||||||
|
[ "$path" = "\"$VAULT\"" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "registering the same vault name twice updates the path instead of duplicating" {
|
||||||
|
VAULT2="$BATS_TEST_TMPDIR/vault2"
|
||||||
|
make_test_vault "$VAULT2"
|
||||||
|
"$SETUP" --settings-scope user --mode append --vault v="$VAULT" > /dev/null
|
||||||
|
run "$SETUP" --settings-scope user --mode maintain --vault v="$VAULT2"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
count=$(python3 -c "import json; print(len(json.load(open('$CONFIG_FILE'))['vaults']))")
|
||||||
|
[ "$count" -eq 1 ]
|
||||||
|
path=$(json_get "$CONFIG_FILE" "vaults.0.path")
|
||||||
|
[ "$path" = "\"$VAULT2\"" ]
|
||||||
|
mode=$(json_get "$CONFIG_FILE" "mode")
|
||||||
|
[ "$mode" = '"maintain"' ]
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bats
|
||||||
|
# Tests for skills/obsidian-vault-kb/scripts/vault_backlinks.sh.
|
||||||
|
|
||||||
|
load '../helpers/common'
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
sandbox_home
|
||||||
|
SCRIPT="$REPO_ROOT/skills/obsidian-vault-kb/scripts/vault_backlinks.sh"
|
||||||
|
CONFIG_FILE="$HOME/.agent-skills/obsidian-vault-kb/config.json"
|
||||||
|
VAULT="$BATS_TEST_TMPDIR/vault"
|
||||||
|
make_test_vault "$VAULT"
|
||||||
|
write_vault_config "$CONFIG_FILE" "append" "v=$VAULT"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "finds a plain [[note]] wikilink" {
|
||||||
|
run "$SCRIPT" homelab-overview
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"dns-setup.md"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "finds a [[note|alias]] wikilink" {
|
||||||
|
cat > "$VAULT/Notes/router.md" << 'EOF'
|
||||||
|
See [[dns-setup|the DNS note]] for details.
|
||||||
|
EOF
|
||||||
|
run "$SCRIPT" dns-setup
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"router.md"* ]]
|
||||||
|
[[ "$output" == *"homelab-overview.md"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a note name with regex metacharacters is handled safely" {
|
||||||
|
cat > "$VAULT/Notes/weird.md" << 'EOF'
|
||||||
|
Related: [[weird.name[test]]]
|
||||||
|
EOF
|
||||||
|
run "$SCRIPT" 'weird.name[test]'
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"weird.md"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "excludes backlinks that only exist inside a non-markdown file" {
|
||||||
|
echo '[[dns-setup]]' > "$VAULT/Notes/attachment2.pdf"
|
||||||
|
run "$SCRIPT" dns-setup
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"homelab-overview.md"* ]]
|
||||||
|
[[ "$output" != *"attachment2.pdf"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "an unlinked note reports no backlinks found" {
|
||||||
|
run "$SCRIPT" nope-not-a-note
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"no backlinks found"* ]]
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bats
|
||||||
|
# Tests for skills/obsidian-vault-kb/scripts/vault_index.sh.
|
||||||
|
|
||||||
|
load '../helpers/common'
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
sandbox_home
|
||||||
|
SCRIPT="$REPO_ROOT/skills/obsidian-vault-kb/scripts/vault_index.sh"
|
||||||
|
CONFIG_FILE="$HOME/.agent-skills/obsidian-vault-kb/config.json"
|
||||||
|
VAULT="$BATS_TEST_TMPDIR/vault"
|
||||||
|
make_test_vault "$VAULT"
|
||||||
|
write_vault_config "$CONFIG_FILE" "append" "v=$VAULT"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "folder structure excludes .obsidian and .git" {
|
||||||
|
run "$SCRIPT" v
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"/Notes"* ]]
|
||||||
|
[[ "$output" != *".obsidian"* ]]
|
||||||
|
[[ "$output" != *"/.git"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "note count per top-level folder counts only .md files" {
|
||||||
|
run "$SCRIPT" v
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"Notes: 2"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "frontmatter tags are extracted and deduplicated" {
|
||||||
|
run "$SCRIPT" v
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
for tag in dns homelab networking overview; do
|
||||||
|
[[ "$output" == *"$tag"* ]]
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a note matching *overview* is flagged as a possible MOC file" {
|
||||||
|
run "$SCRIPT" v
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"homelab-overview.md"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "unknown vault name errors" {
|
||||||
|
run "$SCRIPT" not-a-real-vault
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"no vault named"* ]]
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env bats
|
||||||
|
# Tests for skills/obsidian-vault-kb/scripts/vault_search.sh.
|
||||||
|
#
|
||||||
|
# The "non-markdown file not matched" test is a direct regression guard for
|
||||||
|
# the bug where `-g` glob filters were placed after `--`, so ripgrep treated
|
||||||
|
# them as literal positional paths instead of options and the *.md filter
|
||||||
|
# silently never applied (confirmed by matching content inside a .pdf).
|
||||||
|
|
||||||
|
load '../helpers/common'
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
sandbox_home
|
||||||
|
SCRIPT="$REPO_ROOT/skills/obsidian-vault-kb/scripts/vault_search.sh"
|
||||||
|
CONFIG_FILE="$HOME/.agent-skills/obsidian-vault-kb/config.json"
|
||||||
|
VAULT="$BATS_TEST_TMPDIR/vault"
|
||||||
|
make_test_vault "$VAULT"
|
||||||
|
write_vault_config "$CONFIG_FILE" "append" "v=$VAULT"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "finds a match inside a markdown note" {
|
||||||
|
run "$SCRIPT" pihole
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"dns-setup.md"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "does not match content that exists only inside a non-markdown file" {
|
||||||
|
run "$SCRIPT" attachment-only-marker
|
||||||
|
[[ "$output" == *"(no matches)"* ]]
|
||||||
|
[[ "$output" != *"attachment.pdf"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "does not match content that exists only inside .obsidian" {
|
||||||
|
run "$SCRIPT" obsidian-internal-marker
|
||||||
|
[[ "$output" == *"(no matches)"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "no spurious ripgrep argument errors on stderr/stdout" {
|
||||||
|
run "$SCRIPT" pihole
|
||||||
|
[[ "$output" != *"No such file or directory"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a subfolder argument narrows the search root" {
|
||||||
|
run "$SCRIPT" pihole v Notes
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"== Search root: $VAULT/Notes =="* ]]
|
||||||
|
[[ "$output" == *"dns-setup.md"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a subfolder containing '..' is rejected" {
|
||||||
|
run "$SCRIPT" pihole v ../../etc
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"no '..' or absolute paths"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "an absolute-path subfolder is rejected" {
|
||||||
|
run "$SCRIPT" pihole v /etc
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"no '..' or absolute paths"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a subfolder symlink escaping the vault is rejected" {
|
||||||
|
ln -s /etc "$VAULT/Notes/escape-link"
|
||||||
|
run "$SCRIPT" pihole v Notes/escape-link
|
||||||
|
[ "$status" -eq 1 ]
|
||||||
|
[[ "$output" == *"outside the vault"* ]]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user