diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..d96bba2 --- /dev/null +++ b/tests/README.md @@ -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//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/`. diff --git a/tests/git-manager/git_cmd.bats b/tests/git-manager/git_cmd.bats new file mode 100644 index 0000000..4411aa9 --- /dev/null +++ b/tests/git-manager/git_cmd.bats @@ -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 ] +} diff --git a/tests/git-manager/hooks.bats b/tests/git-manager/hooks.bats new file mode 100644 index 0000000..7b43178 --- /dev/null +++ b/tests/git-manager/hooks.bats @@ -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" ] +} diff --git a/tests/git-manager/setup.bats b/tests/git-manager/setup.bats new file mode 100644 index 0000000..51b7e56 --- /dev/null +++ b/tests/git-manager/setup.bats @@ -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 /.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" ] +} diff --git a/tests/helpers/common.bash b/tests/helpers/common.bash new file mode 100644 index 0000000..7cee19e --- /dev/null +++ b/tests/helpers/common.bash @@ -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 — 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 = [= ...] +# 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 — 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 = [= ...] +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 — 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" +} diff --git a/tests/obsidian-vault-kb/setup.bats b/tests/obsidian-vault-kb/setup.bats new file mode 100644 index 0000000..c804a70 --- /dev/null +++ b/tests/obsidian-vault-kb/setup.bats @@ -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"' ] +} diff --git a/tests/obsidian-vault-kb/vault_backlinks.bats b/tests/obsidian-vault-kb/vault_backlinks.bats new file mode 100644 index 0000000..dda0196 --- /dev/null +++ b/tests/obsidian-vault-kb/vault_backlinks.bats @@ -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"* ]] +} diff --git a/tests/obsidian-vault-kb/vault_index.bats b/tests/obsidian-vault-kb/vault_index.bats new file mode 100644 index 0000000..163d4bd --- /dev/null +++ b/tests/obsidian-vault-kb/vault_index.bats @@ -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"* ]] +} diff --git a/tests/obsidian-vault-kb/vault_search.bats b/tests/obsidian-vault-kb/vault_search.bats new file mode 100644 index 0000000..83a565e --- /dev/null +++ b/tests/obsidian-vault-kb/vault_search.bats @@ -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"* ]] +}