test-session-start.sh 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. #!/usr/bin/env bash
  2. set -euo pipefail
  3. SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
  4. REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
  5. HOOK_UNDER_TEST="$REPO_ROOT/hooks/session-start"
  6. WRAPPER_UNDER_TEST="$REPO_ROOT/hooks/run-hook.cmd"
  7. FAILURES=0
  8. TEST_ROOT="$(mktemp -d)"
  9. cleanup() {
  10. rm -rf "$TEST_ROOT"
  11. }
  12. trap cleanup EXIT
  13. pass() {
  14. echo " [PASS] $1"
  15. }
  16. fail() {
  17. echo " [FAIL] $1"
  18. FAILURES=$((FAILURES + 1))
  19. }
  20. make_home() {
  21. local name="$1"
  22. local home="$TEST_ROOT/$name/home"
  23. mkdir -p "$home"
  24. printf '%s\n' "$home"
  25. }
  26. assert_command_output() {
  27. local description="$1"
  28. local shape="$2"
  29. local contains="$3"
  30. local not_contains="$4"
  31. local home="$5"
  32. shift 5
  33. local output
  34. if ! output="$(env -i PATH="${PATH:-}" HOME="$home" "$@" 2>&1)"; then
  35. fail "$description"
  36. echo " hook exited non-zero"
  37. echo "$output" | sed 's/^/ /'
  38. return
  39. fi
  40. if printf '%s' "$output" | \
  41. EXPECT_SHAPE="$shape" \
  42. EXPECT_CONTAINS="$contains" \
  43. EXPECT_NOT_CONTAINS="$not_contains" \
  44. node -e '
  45. const fs = require("fs");
  46. const input = fs.readFileSync(0, "utf8");
  47. let payload;
  48. try {
  49. payload = JSON.parse(input);
  50. } catch (error) {
  51. console.error(`invalid JSON: ${error.message}`);
  52. process.exit(1);
  53. }
  54. function hasOwn(object, key) {
  55. return Object.prototype.hasOwnProperty.call(object, key);
  56. }
  57. function fail(message) {
  58. console.error(message);
  59. process.exit(1);
  60. }
  61. const shape = process.env.EXPECT_SHAPE;
  62. let context;
  63. if (shape === "nested") {
  64. if (!hasOwn(payload, "hookSpecificOutput")) {
  65. fail("missing hookSpecificOutput");
  66. }
  67. if (hasOwn(payload, "additional_context") || hasOwn(payload, "additionalContext")) {
  68. fail("nested output also included a top-level context field");
  69. }
  70. const hookOutput = payload.hookSpecificOutput;
  71. if (!hookOutput || typeof hookOutput !== "object" || Array.isArray(hookOutput)) {
  72. fail("hookSpecificOutput is not an object");
  73. }
  74. if (hookOutput.hookEventName !== "SessionStart") {
  75. fail(`unexpected hookEventName: ${hookOutput.hookEventName}`);
  76. }
  77. context = hookOutput.additionalContext;
  78. } else if (shape === "cursor") {
  79. if (hasOwn(payload, "hookSpecificOutput")) {
  80. fail("cursor output included hookSpecificOutput");
  81. }
  82. if (!hasOwn(payload, "additional_context")) {
  83. fail("cursor output missing additional_context");
  84. }
  85. if (hasOwn(payload, "additionalContext")) {
  86. fail("cursor output included additionalContext");
  87. }
  88. context = payload.additional_context;
  89. } else if (shape === "sdk") {
  90. if (hasOwn(payload, "hookSpecificOutput")) {
  91. fail("sdk output included hookSpecificOutput");
  92. }
  93. if (!hasOwn(payload, "additionalContext")) {
  94. fail("sdk output missing additionalContext");
  95. }
  96. if (hasOwn(payload, "additional_context")) {
  97. fail("sdk output included additional_context");
  98. }
  99. context = payload.additionalContext;
  100. } else {
  101. fail(`unknown expected shape: ${shape}`);
  102. }
  103. if (typeof context !== "string" || context.trim() === "") {
  104. fail("injected context was empty");
  105. }
  106. const expectedText = process.env.EXPECT_CONTAINS || "";
  107. if (expectedText && !context.includes(expectedText)) {
  108. fail(`context did not contain expected text: ${expectedText}`);
  109. }
  110. const forbiddenTexts = (process.env.EXPECT_NOT_CONTAINS || "")
  111. .split("\u001f")
  112. .filter(Boolean);
  113. for (const forbiddenText of forbiddenTexts) {
  114. if (context.includes(forbiddenText)) {
  115. fail(`context contained forbidden text: ${forbiddenText}`);
  116. }
  117. }
  118. '; then
  119. pass "$description"
  120. else
  121. fail "$description"
  122. echo " output:"
  123. echo "$output" | sed 's/^/ /'
  124. fi
  125. }
  126. echo "SessionStart hook output tests"
  127. # Registration shape: the hook must declare shell:"bash" so Claude Code on
  128. # Windows dispatches via Git Bash (or fails with an actionable error) instead
  129. # of PowerShell/cmd.exe, whose parsers break on the quoted command string
  130. # (PowerShell ParserError; cmd.exe quote-stripping on paths with metacharacters).
  131. if node -e '
  132. const hooks = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
  133. const entry = hooks.hooks.SessionStart[0].hooks[0];
  134. if (entry.shell !== "bash") {
  135. console.error(`SessionStart hook shell is ${JSON.stringify(entry.shell)}, expected "bash"`);
  136. process.exit(1);
  137. }
  138. if (!/run-hook\.cmd" session-start$/.test(entry.command)) {
  139. console.error(`unexpected SessionStart command shape: ${entry.command}`);
  140. process.exit(1);
  141. }
  142. ' "$REPO_ROOT/hooks/hooks.json"; then
  143. pass "hooks.json registers SessionStart with shell:bash dispatch"
  144. else
  145. fail "hooks.json registers SessionStart with shell:bash dispatch"
  146. fi
  147. claude_home="$(make_home claude-code)"
  148. assert_command_output \
  149. "Claude Code emits nested SessionStart additionalContext" \
  150. "nested" \
  151. "" \
  152. "" \
  153. "$claude_home" \
  154. CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
  155. bash "$HOOK_UNDER_TEST"
  156. wrapper_home="$(make_home run-hook-wrapper)"
  157. assert_command_output \
  158. "run-hook.cmd wrapper dispatches to the named session-start script" \
  159. "nested" \
  160. "" \
  161. "" \
  162. "$wrapper_home" \
  163. CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
  164. bash "$WRAPPER_UNDER_TEST" session-start
  165. # With no bash available, the wrapper must fail open: silent, exit 0.
  166. if output="$(env -i PATH=/nonexistent /bin/sh "$WRAPPER_UNDER_TEST" session-start 2>&1)" \
  167. && [ -z "$output" ]; then
  168. pass "wrapper with no bash on PATH is silent and exits 0"
  169. else
  170. fail "wrapper with no bash on PATH is silent and exits 0"
  171. printf '%s\n' "$output" | head -3 | sed 's/^/ /'
  172. fi
  173. cursor_home="$(make_home cursor)"
  174. assert_command_output \
  175. "Cursor emits top-level additional_context only" \
  176. "cursor" \
  177. "" \
  178. "" \
  179. "$cursor_home" \
  180. CURSOR_PLUGIN_ROOT="$REPO_ROOT" \
  181. CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
  182. bash "$HOOK_UNDER_TEST"
  183. copilot_home="$(make_home copilot-cli)"
  184. assert_command_output \
  185. "Copilot CLI emits top-level additionalContext only" \
  186. "sdk" \
  187. "" \
  188. "" \
  189. "$copilot_home" \
  190. COPILOT_CLI=1 \
  191. CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
  192. bash "$HOOK_UNDER_TEST"
  193. legacy_home="$(make_home legacy-warning-removed)"
  194. mkdir -p "$legacy_home/.config/superpowers/skills"
  195. assert_command_output \
  196. "SessionStart omits obsolete legacy custom-skill warning" \
  197. "nested" \
  198. "" \
  199. "Superpowers now uses"$'\037'"~/.config/superpowers/skills"$'\037'"~/.claude/skills"$'\037'"legacy" \
  200. "$legacy_home" \
  201. CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
  202. bash "$HOOK_UNDER_TEST"
  203. if [[ "$FAILURES" -gt 0 ]]; then
  204. echo "STATUS: FAILED ($FAILURES failure(s))"
  205. exit 1
  206. fi
  207. echo "STATUS: PASSED"