test-session-start.sh 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  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. CODEX_HOOK_UNDER_TEST="$REPO_ROOT/hooks/session-start-codex"
  7. WRAPPER_UNDER_TEST="$REPO_ROOT/hooks/run-hook.cmd"
  8. FAILURES=0
  9. TEST_ROOT="$(mktemp -d)"
  10. cleanup() {
  11. rm -rf "$TEST_ROOT"
  12. }
  13. trap cleanup EXIT
  14. pass() {
  15. echo " [PASS] $1"
  16. }
  17. fail() {
  18. echo " [FAIL] $1"
  19. FAILURES=$((FAILURES + 1))
  20. }
  21. make_home() {
  22. local name="$1"
  23. local home="$TEST_ROOT/$name/home"
  24. mkdir -p "$home"
  25. printf '%s\n' "$home"
  26. }
  27. assert_command_output() {
  28. local description="$1"
  29. local shape="$2"
  30. local contains="$3"
  31. local not_contains="$4"
  32. local home="$5"
  33. shift 5
  34. local output
  35. if ! output="$(env -i PATH="${PATH:-}" HOME="$home" "$@" 2>&1)"; then
  36. fail "$description"
  37. echo " hook exited non-zero"
  38. echo "$output" | sed 's/^/ /'
  39. return
  40. fi
  41. if printf '%s' "$output" | \
  42. EXPECT_SHAPE="$shape" \
  43. EXPECT_CONTAINS="$contains" \
  44. EXPECT_NOT_CONTAINS="$not_contains" \
  45. node -e '
  46. const fs = require("fs");
  47. const input = fs.readFileSync(0, "utf8");
  48. let payload;
  49. try {
  50. payload = JSON.parse(input);
  51. } catch (error) {
  52. console.error(`invalid JSON: ${error.message}`);
  53. process.exit(1);
  54. }
  55. function hasOwn(object, key) {
  56. return Object.prototype.hasOwnProperty.call(object, key);
  57. }
  58. function fail(message) {
  59. console.error(message);
  60. process.exit(1);
  61. }
  62. const shape = process.env.EXPECT_SHAPE;
  63. let context;
  64. if (shape === "nested") {
  65. if (!hasOwn(payload, "hookSpecificOutput")) {
  66. fail("missing hookSpecificOutput");
  67. }
  68. if (hasOwn(payload, "additional_context") || hasOwn(payload, "additionalContext")) {
  69. fail("nested output also included a top-level context field");
  70. }
  71. const hookOutput = payload.hookSpecificOutput;
  72. if (!hookOutput || typeof hookOutput !== "object" || Array.isArray(hookOutput)) {
  73. fail("hookSpecificOutput is not an object");
  74. }
  75. if (hookOutput.hookEventName !== "SessionStart") {
  76. fail(`unexpected hookEventName: ${hookOutput.hookEventName}`);
  77. }
  78. context = hookOutput.additionalContext;
  79. } else if (shape === "cursor") {
  80. if (hasOwn(payload, "hookSpecificOutput")) {
  81. fail("cursor output included hookSpecificOutput");
  82. }
  83. if (!hasOwn(payload, "additional_context")) {
  84. fail("cursor output missing additional_context");
  85. }
  86. if (hasOwn(payload, "additionalContext")) {
  87. fail("cursor output included additionalContext");
  88. }
  89. context = payload.additional_context;
  90. } else if (shape === "sdk") {
  91. if (hasOwn(payload, "hookSpecificOutput")) {
  92. fail("sdk output included hookSpecificOutput");
  93. }
  94. if (!hasOwn(payload, "additionalContext")) {
  95. fail("sdk output missing additionalContext");
  96. }
  97. if (hasOwn(payload, "additional_context")) {
  98. fail("sdk output included additional_context");
  99. }
  100. context = payload.additionalContext;
  101. } else {
  102. fail(`unknown expected shape: ${shape}`);
  103. }
  104. if (typeof context !== "string" || context.trim() === "") {
  105. fail("injected context was empty");
  106. }
  107. const expectedText = process.env.EXPECT_CONTAINS || "";
  108. if (expectedText && !context.includes(expectedText)) {
  109. fail(`context did not contain expected text: ${expectedText}`);
  110. }
  111. const forbiddenTexts = (process.env.EXPECT_NOT_CONTAINS || "")
  112. .split("\u001f")
  113. .filter(Boolean);
  114. for (const forbiddenText of forbiddenTexts) {
  115. if (context.includes(forbiddenText)) {
  116. fail(`context contained forbidden text: ${forbiddenText}`);
  117. }
  118. }
  119. '; then
  120. pass "$description"
  121. else
  122. fail "$description"
  123. echo " output:"
  124. echo "$output" | sed 's/^/ /'
  125. fi
  126. }
  127. echo "SessionStart hook output tests"
  128. claude_home="$(make_home claude-code)"
  129. assert_command_output \
  130. "Claude Code emits nested SessionStart additionalContext" \
  131. "nested" \
  132. "" \
  133. "" \
  134. "$claude_home" \
  135. CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
  136. bash "$HOOK_UNDER_TEST"
  137. codex_home="$(make_home codex-plugin-hooks)"
  138. codex_data="$TEST_ROOT/codex-plugin-hooks/data"
  139. mkdir -p "$codex_data"
  140. assert_command_output \
  141. "Codex plugin hooks use dedicated script and emit nested SessionStart additionalContext" \
  142. "nested" \
  143. "" \
  144. "" \
  145. "$codex_home" \
  146. PLUGIN_DATA="$codex_data" \
  147. CLAUDE_PLUGIN_DATA="$codex_data" \
  148. PLUGIN_ROOT="$REPO_ROOT" \
  149. CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
  150. bash "$CODEX_HOOK_UNDER_TEST"
  151. codex_wrapper_home="$(make_home codex-wrapper)"
  152. codex_wrapper_data="$TEST_ROOT/codex-wrapper/data"
  153. mkdir -p "$codex_wrapper_data"
  154. assert_command_output \
  155. "Codex wrapper path dispatches to dedicated script" \
  156. "nested" \
  157. "" \
  158. "" \
  159. "$codex_wrapper_home" \
  160. PLUGIN_DATA="$codex_wrapper_data" \
  161. CLAUDE_PLUGIN_DATA="$codex_wrapper_data" \
  162. PLUGIN_ROOT="$REPO_ROOT" \
  163. CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
  164. bash "$WRAPPER_UNDER_TEST" session-start-codex
  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. codex_legacy_home="$(make_home codex-legacy-warning-removed)"
  196. codex_legacy_data="$TEST_ROOT/codex-legacy-warning-removed/data"
  197. mkdir -p "$codex_legacy_home/.config/superpowers/skills" "$codex_legacy_data"
  198. assert_command_output \
  199. "Codex SessionStart omits obsolete legacy custom-skill warning" \
  200. "nested" \
  201. "" \
  202. "Superpowers now uses"$'\037'"~/.config/superpowers/skills"$'\037'"~/.claude/skills"$'\037'"legacy" \
  203. "$codex_legacy_home" \
  204. PLUGIN_DATA="$codex_legacy_data" \
  205. CLAUDE_PLUGIN_DATA="$codex_legacy_data" \
  206. PLUGIN_ROOT="$REPO_ROOT" \
  207. CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
  208. bash "$CODEX_HOOK_UNDER_TEST"
  209. if [[ "$FAILURES" -gt 0 ]]; then
  210. echo "STATUS: FAILED ($FAILURES failure(s))"
  211. exit 1
  212. fi
  213. echo "STATUS: PASSED"