test-session-start.sh 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  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. cursor_home="$(make_home cursor)"
  166. assert_command_output \
  167. "Cursor emits top-level additional_context only" \
  168. "cursor" \
  169. "" \
  170. "" \
  171. "$cursor_home" \
  172. CURSOR_PLUGIN_ROOT="$REPO_ROOT" \
  173. CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
  174. bash "$HOOK_UNDER_TEST"
  175. copilot_home="$(make_home copilot-cli)"
  176. assert_command_output \
  177. "Copilot CLI emits top-level additionalContext only" \
  178. "sdk" \
  179. "" \
  180. "" \
  181. "$copilot_home" \
  182. COPILOT_CLI=1 \
  183. CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
  184. bash "$HOOK_UNDER_TEST"
  185. legacy_home="$(make_home legacy-warning-removed)"
  186. mkdir -p "$legacy_home/.config/superpowers/skills"
  187. assert_command_output \
  188. "SessionStart omits obsolete legacy custom-skill warning" \
  189. "nested" \
  190. "" \
  191. "Superpowers now uses"$'\037'"~/.config/superpowers/skills"$'\037'"~/.claude/skills"$'\037'"legacy" \
  192. "$legacy_home" \
  193. CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
  194. bash "$HOOK_UNDER_TEST"
  195. if [[ "$FAILURES" -gt 0 ]]; then
  196. echo "STATUS: FAILED ($FAILURES failure(s))"
  197. exit 1
  198. fi
  199. echo "STATUS: PASSED"