test-session-start.sh 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  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. claude_home="$(make_home claude-code)"
  128. assert_command_output \
  129. "Claude Code emits nested SessionStart additionalContext" \
  130. "nested" \
  131. "" \
  132. "" \
  133. "$claude_home" \
  134. CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
  135. bash "$HOOK_UNDER_TEST"
  136. wrapper_home="$(make_home run-hook-wrapper)"
  137. assert_command_output \
  138. "run-hook.cmd wrapper dispatches to the named session-start script" \
  139. "nested" \
  140. "" \
  141. "" \
  142. "$wrapper_home" \
  143. CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
  144. bash "$WRAPPER_UNDER_TEST" session-start
  145. cursor_home="$(make_home cursor)"
  146. assert_command_output \
  147. "Cursor emits top-level additional_context only" \
  148. "cursor" \
  149. "" \
  150. "" \
  151. "$cursor_home" \
  152. CURSOR_PLUGIN_ROOT="$REPO_ROOT" \
  153. CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
  154. bash "$HOOK_UNDER_TEST"
  155. copilot_home="$(make_home copilot-cli)"
  156. assert_command_output \
  157. "Copilot CLI emits top-level additionalContext only" \
  158. "sdk" \
  159. "" \
  160. "" \
  161. "$copilot_home" \
  162. COPILOT_CLI=1 \
  163. CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
  164. bash "$HOOK_UNDER_TEST"
  165. legacy_home="$(make_home legacy-warning-removed)"
  166. mkdir -p "$legacy_home/.config/superpowers/skills"
  167. assert_command_output \
  168. "SessionStart omits obsolete legacy custom-skill warning" \
  169. "nested" \
  170. "" \
  171. "Superpowers now uses"$'\037'"~/.config/superpowers/skills"$'\037'"~/.claude/skills"$'\037'"legacy" \
  172. "$legacy_home" \
  173. CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
  174. bash "$HOOK_UNDER_TEST"
  175. if [[ "$FAILURES" -gt 0 ]]; then
  176. echo "STATUS: FAILED ($FAILURES failure(s))"
  177. exit 1
  178. fi
  179. echo "STATUS: PASSED"