Files
holocron/tests/test-sync-plugin-content.sh
Defame1297 e79497b3cf fix(tests): guard remaining bash 3.2 hazards from PR #95 review
Review findings #5 and #7 on PR #95 flagged two bash-3.2-incompatible
patterns despite the surrounding scripts claiming 3.2 safety:

- tests/run-bats.sh used `mapfile` (bash 4.0+), which fails immediately
  under macOS's stock bash 3.2 before any batching logic runs. Replaced
  with the `while read` loop already established in tests/run-tests.sh,
  and guarded the two downstream `${TEST_FILES[@]}` expansions with
  `${arr[@]+"${arr[@]}"}` to match that file's convention.

- `trap 'rm -rf "${CLEANUP_DIRS[@]}"' EXIT` was unguarded in
  tests/test-sync-marketplace-mirror.sh and
  tests/test-sync-plugin-content.sh: under `set -u`, if `mktemp -d`
  fails before the array is populated, the trap itself throws an
  unbound-variable error that masks the real test failure. A repo-wide
  grep for the same pattern turned up a third, unreviewed instance in
  tests/test-check-release-needed.sh. Fixed all three with the guarded
  idiom already used elsewhere in the repo.

Extended the existing bash-3.2-hazard static check (test 16 in
tests/test-vale-wrap.sh) to scan all four fixed files going forward,
so a regression of either pattern fails the suite instead of only
surfacing on a real bash 3.2 host.

Refs: PR #95
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
2026-08-13 22:23:45 +00:00

309 lines
9.9 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SCRIPT="$REPO_ROOT/scripts/sync-plugin-content.sh"
PASS=0
FAIL=0
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
if ! command -v apm &>/dev/null; then
echo "apm not installed -- skipping (see kyberforge:apm-install)" >&2
exit 77
fi
# Minimal fixture: one skill (with a tests/ fixture that must NOT be mirrored), one
# agent, one hooks.json -- enough to exercise every mirrored category
# (scripts/sync-plugin-content.sh's MIRROR_DIRS plus hooks.json) without needing
# network access (no apm.yml dependencies).
make_fixture() {
local dir
dir="$(mktemp -d)"
mkdir -p "$dir/.apm/skills/hello/tests" "$dir/.apm/agents" "$dir/.apm/hooks"
cat > "$dir/apm.yml" <<'YAML'
name: fixture
version: 0.0.1
description: fixture
license: MIT
type: hybrid
targets:
- claude
dependencies:
apm: []
mcp: []
includes: auto
devDependencies:
apm: []
scripts: {}
YAML
cat > "$dir/.apm/skills/hello/SKILL.md" <<'EOF'
---
name: hello
description: hello
---
Hello.
EOF
cat > "$dir/.apm/skills/hello/tests/sample.bats" <<'EOF'
@test "dummy" { true; }
EOF
cat > "$dir/.apm/agents/foo.agent.md" <<'EOF'
---
name: foo
description: foo
---
Foo.
EOF
cat > "$dir/.apm/hooks/hooks.json" <<'EOF'
{"hooks": {"PreToolUse": []}}
EOF
echo "$dir"
}
# Same base fixture, but with a copilot target (so apm pack produces
# .github/plugin/plugin.json) and a caller-supplied .mcp.json -- for exercising
# reinject_mcp_servers().
make_fixture_with_mcp() {
local mcp_json="$1" dir
dir="$(mktemp -d)"
mkdir -p "$dir/.apm/skills/hello" "$dir/.apm/agents"
cat > "$dir/apm.yml" <<'YAML'
name: fixture
version: 0.0.1
description: fixture
license: MIT
type: hybrid
targets:
- claude
- copilot
dependencies:
apm: []
mcp: []
includes: auto
devDependencies:
apm: []
scripts: {}
YAML
cat > "$dir/.apm/skills/hello/SKILL.md" <<'EOF'
---
name: hello
description: hello
---
Hello.
EOF
cat > "$dir/.apm/agents/foo.agent.md" <<'EOF'
---
name: foo
description: foo
---
Foo.
EOF
printf '%s' "$mcp_json" > "$dir/.mcp.json"
echo "$dir"
}
CLEANUP_DIRS=()
trap 'rm -rf ${CLEANUP_DIRS[@]+"${CLEANUP_DIRS[@]}"}' EXIT
track() { CLEANUP_DIRS+=("$1"); }
# --- 1. --check reports drift before any sync has run ---
echo ""
echo "--- --check reports drift on an unsynced fixture ---"
FIXTURE="$(make_fixture)"; track "$FIXTURE"
if bash "$SCRIPT" --check "$FIXTURE" > /dev/null 2>&1; then
fail "exited 0 on an unsynced fixture — expected drift (exit 1)"
else
pass "exits non-zero (drift) before syncing"
fi
# --- 2. A real sync creates the flat mirror and exits 0 ---
echo ""
echo "--- real sync creates skills/, agents/, hooks.json ---"
if bash "$SCRIPT" "$FIXTURE" > /dev/null 2>&1 \
&& [[ -f "$FIXTURE/skills/hello/SKILL.md" ]] \
&& [[ -f "$FIXTURE/agents/foo.agent.md" ]] \
&& [[ -f "$FIXTURE/hooks.json" ]]; then
pass "sync creates the expected flat mirror"
else
fail "sync did not create the expected flat mirror"
fi
# --- 3. tests/ fixtures are excluded from the mirror ---
echo ""
echo "--- tests/ subdirectories are not mirrored ---"
if [[ ! -e "$FIXTURE/skills/hello/tests" ]]; then
pass "skills/hello/tests/ was not copied into the mirror"
else
fail "skills/hello/tests/ was copied into the mirror — should be excluded"
fi
# --- 4. --check is clean immediately after a real sync ---
echo ""
echo "--- --check is clean right after syncing ---"
if bash "$SCRIPT" --check "$FIXTURE" > /dev/null 2>&1; then
pass "no drift reported immediately after syncing"
else
fail "drift reported right after syncing — sync and check disagree"
fi
# --- 5. New .apm/ content is detected as drift, and a re-sync clears it ---
echo ""
echo "--- new .apm/ content is detected as drift and cleared by re-sync ---"
mkdir -p "$FIXTURE/.apm/skills/second"
cat > "$FIXTURE/.apm/skills/second/SKILL.md" <<'EOF'
---
name: second
description: second
---
Second.
EOF
if bash "$SCRIPT" --check "$FIXTURE" > /dev/null 2>&1; then
fail "no drift reported after adding a new skill under .apm/ — expected drift"
else
pass "new .apm/ content is detected as drift"
bash "$SCRIPT" "$FIXTURE" > /dev/null 2>&1
if bash "$SCRIPT" --check "$FIXTURE" > /dev/null 2>&1; then
pass "re-sync clears the drift"
else
fail "re-sync did not clear the drift"
fi
fi
# --- 6. A plugin dir with no .apm/ is skipped cleanly, not treated as an error ---
echo ""
echo "--- a plugin dir with no .apm/ is skipped, not failed ---"
NO_APM="$(mktemp -d)"; track "$NO_APM"
if bash "$SCRIPT" "$NO_APM" > /dev/null 2>&1 && bash "$SCRIPT" --check "$NO_APM" > /dev/null 2>&1; then
pass "a plugin dir with no .apm/ exits 0 in both real and --check mode"
else
fail "a plugin dir with no .apm/ should exit 0 (skip), not fail"
fi
# --- 7. A plugin dir that doesn't exist at all is a hard failure, not a skip ---
echo ""
echo "--- a plugin dir that does not exist fails, distinct from an existing-but-empty one ---"
MISSING_ROOT="$(mktemp -d)"; track "$MISSING_ROOT"
MISSING="$MISSING_ROOT/does-not-exist"
if bash "$SCRIPT" "$MISSING" > /dev/null 2>&1; then
fail "exited 0 for a plugin dir that does not exist — expected a hard failure"
else
pass "a nonexistent plugin dir fails instead of silently skipping"
fi
# --- 8. --check never mutates the real plugin root, even on first-time manifest creation ---
echo ""
echo "--- --check does not create .claude-plugin/plugin.json or .github/plugin/plugin.json ---"
FIXTURE8="$(make_fixture)"; track "$FIXTURE8"
bash "$SCRIPT" --check "$FIXTURE8" > /dev/null 2>&1 || true
if [[ ! -e "$FIXTURE8/.claude-plugin/plugin.json" ]] && [[ ! -e "$FIXTURE8/.github/plugin/plugin.json" ]]; then
pass "--check leaves the real plugin root without a first-write plugin.json"
else
fail "--check created plugin.json in the real plugin root — it must never mutate it"
fi
# --- 9. A duplicate plugin-dir basename among arguments fails fast, not silently ---
echo ""
echo "--- duplicate plugin dir basenames among arguments are rejected ---"
DUP_PARENT_A="$(mktemp -d)"; track "$DUP_PARENT_A"
DUP_PARENT_B="$(mktemp -d)"; track "$DUP_PARENT_B"
mkdir -p "$DUP_PARENT_A/dup" "$DUP_PARENT_B/dup"
if bash "$SCRIPT" "$DUP_PARENT_A/dup" "$DUP_PARENT_B/dup" > /dev/null 2>&1; then
fail "exited 0 with two plugin-dir arguments sharing a basename — expected a collision error"
else
pass "rejects two plugin-dir arguments that share a basename"
fi
# --- 10. Real sync re-injects mcpServers that apm's Copilot builder strips ---
echo ""
echo "--- real sync re-injects mcpServers into .github/plugin/plugin.json ---"
FIXTURE10="$(make_fixture_with_mcp '{"mcpServers":{"demo":{"command":"demo-server","type":"stdio"}}}')"; track "$FIXTURE10"
bash "$SCRIPT" "$FIXTURE10" > /dev/null 2>&1
if jq -e '.mcpServers.demo.command == "demo-server"' "$FIXTURE10/.github/plugin/plugin.json" > /dev/null 2>&1; then
pass "mcpServers from .mcp.json is present in .github/plugin/plugin.json after a real sync"
else
fail "mcpServers was not re-injected into .github/plugin/plugin.json"
fi
# --- 11. An empty .mcp.json does not add a redundant mcpServers: {} ---
echo ""
echo "--- an empty .mcp.json does not add mcpServers: {} ---"
FIXTURE11="$(make_fixture_with_mcp '{"mcpServers":{}}')"; track "$FIXTURE11"
bash "$SCRIPT" "$FIXTURE11" > /dev/null 2>&1
if jq -e 'has("mcpServers") | not' "$FIXTURE11/.github/plugin/plugin.json" > /dev/null 2>&1; then
pass "an empty .mcp.json does not add mcpServers to .github/plugin/plugin.json"
else
fail "an empty .mcp.json still added mcpServers -- should match apm's own omit-when-empty convention"
fi
# --- 12. --check detects drift in the compiled plugin.json (apm.yml content changed) ---
echo ""
echo "--- --check detects plugin.json content drift from apm.yml after a version bump ---"
FIXTURE12="$(make_fixture)"; track "$FIXTURE12"
bash "$SCRIPT" "$FIXTURE12" > /dev/null 2>&1
if bash "$SCRIPT" --check "$FIXTURE12" > /dev/null 2>&1; then
pass "check is clean right after the initial sync (baseline for this test)"
else
fail "check reported drift right after the initial sync -- can't test the version-bump case"
fi
# Bump the version in apm.yml without re-syncing -- the compiled
# .claude-plugin/plugin.json is now stale relative to what apm pack would
# currently produce.
cat > "$FIXTURE12/apm.yml" <<'YAML'
name: fixture
version: 0.0.2
description: fixture
license: MIT
type: hybrid
targets:
- claude
dependencies:
apm: []
mcp: []
includes: auto
devDependencies:
apm: []
scripts: {}
YAML
if bash "$SCRIPT" --check "$FIXTURE12" > /dev/null 2>&1; then
fail "no drift reported after bumping apm.yml's version -- plugin.json should be stale"
else
pass "plugin.json content drift (version bump) is detected"
bash "$SCRIPT" "$FIXTURE12" > /dev/null 2>&1
if bash "$SCRIPT" --check "$FIXTURE12" > /dev/null 2>&1; then
pass "re-sync clears the plugin.json drift"
else
fail "re-sync did not clear the plugin.json drift"
fi
fi
# --- 13. --check detects an orphaned hooks.json after .apm/hooks/ is removed ---
echo ""
echo "--- --check detects an orphaned hooks.json when .apm/hooks/ is removed ---"
FIXTURE13="$(make_fixture)"; track "$FIXTURE13"
bash "$SCRIPT" "$FIXTURE13" > /dev/null 2>&1
if [[ ! -f "$FIXTURE13/hooks.json" ]]; then
fail "initial sync did not create hooks.json -- can't test the orphan case"
fi
rm -rf "$FIXTURE13/.apm/hooks"
if bash "$SCRIPT" --check "$FIXTURE13" > /dev/null 2>&1; then
fail "no drift reported for an orphaned hooks.json after .apm/hooks/ removal"
else
pass "orphaned hooks.json is detected as drift"
bash "$SCRIPT" "$FIXTURE13" > /dev/null 2>&1
if [[ ! -e "$FIXTURE13/hooks.json" ]]; then
pass "re-sync removes the orphaned hooks.json"
else
fail "re-sync left the orphaned hooks.json in place"
fi
if bash "$SCRIPT" --check "$FIXTURE13" > /dev/null 2>&1; then
pass "re-sync clears the orphaned-hooks.json drift"
else
fail "re-sync did not clear the orphaned-hooks.json drift"
fi
fi
echo ""
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]]