mirror of
https://github.com/obra/superpowers.git
synced 2026-05-17 16:19:29 +08:00
feat: support Codex native plugin hooks
This commit is contained in:
239
tests/hooks/test-session-start.sh
Executable file
239
tests/hooks/test-session-start.sh
Executable file
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
HOOK_UNDER_TEST="$REPO_ROOT/hooks/session-start"
|
||||
WRAPPER_UNDER_TEST="$REPO_ROOT/hooks/run-hook.cmd"
|
||||
|
||||
FAILURES=0
|
||||
TEST_ROOT="$(mktemp -d)"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$TEST_ROOT"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
pass() {
|
||||
echo " [PASS] $1"
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo " [FAIL] $1"
|
||||
FAILURES=$((FAILURES + 1))
|
||||
}
|
||||
|
||||
make_home() {
|
||||
local name="$1"
|
||||
local home="$TEST_ROOT/$name/home"
|
||||
mkdir -p "$home"
|
||||
printf '%s\n' "$home"
|
||||
}
|
||||
|
||||
assert_command_output() {
|
||||
local description="$1"
|
||||
local shape="$2"
|
||||
local contains="$3"
|
||||
local not_contains="$4"
|
||||
local home="$5"
|
||||
shift 5
|
||||
|
||||
local output
|
||||
if ! output="$(env -i PATH="${PATH:-}" HOME="$home" "$@" 2>&1)"; then
|
||||
fail "$description"
|
||||
echo " hook exited non-zero"
|
||||
echo "$output" | sed 's/^/ /'
|
||||
return
|
||||
fi
|
||||
|
||||
if printf '%s' "$output" | \
|
||||
EXPECT_SHAPE="$shape" \
|
||||
EXPECT_CONTAINS="$contains" \
|
||||
EXPECT_NOT_CONTAINS="$not_contains" \
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
|
||||
const input = fs.readFileSync(0, "utf8");
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(input);
|
||||
} catch (error) {
|
||||
console.error(`invalid JSON: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function hasOwn(object, key) {
|
||||
return Object.prototype.hasOwnProperty.call(object, key);
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const shape = process.env.EXPECT_SHAPE;
|
||||
let context;
|
||||
|
||||
if (shape === "nested") {
|
||||
if (!hasOwn(payload, "hookSpecificOutput")) {
|
||||
fail("missing hookSpecificOutput");
|
||||
}
|
||||
if (hasOwn(payload, "additional_context") || hasOwn(payload, "additionalContext")) {
|
||||
fail("nested output also included a top-level context field");
|
||||
}
|
||||
const hookOutput = payload.hookSpecificOutput;
|
||||
if (!hookOutput || typeof hookOutput !== "object" || Array.isArray(hookOutput)) {
|
||||
fail("hookSpecificOutput is not an object");
|
||||
}
|
||||
if (hookOutput.hookEventName !== "SessionStart") {
|
||||
fail(`unexpected hookEventName: ${hookOutput.hookEventName}`);
|
||||
}
|
||||
context = hookOutput.additionalContext;
|
||||
} else if (shape === "cursor") {
|
||||
if (hasOwn(payload, "hookSpecificOutput")) {
|
||||
fail("cursor output included hookSpecificOutput");
|
||||
}
|
||||
if (!hasOwn(payload, "additional_context")) {
|
||||
fail("cursor output missing additional_context");
|
||||
}
|
||||
if (hasOwn(payload, "additionalContext")) {
|
||||
fail("cursor output included additionalContext");
|
||||
}
|
||||
context = payload.additional_context;
|
||||
} else if (shape === "sdk") {
|
||||
if (hasOwn(payload, "hookSpecificOutput")) {
|
||||
fail("sdk output included hookSpecificOutput");
|
||||
}
|
||||
if (!hasOwn(payload, "additionalContext")) {
|
||||
fail("sdk output missing additionalContext");
|
||||
}
|
||||
if (hasOwn(payload, "additional_context")) {
|
||||
fail("sdk output included additional_context");
|
||||
}
|
||||
context = payload.additionalContext;
|
||||
} else {
|
||||
fail(`unknown expected shape: ${shape}`);
|
||||
}
|
||||
|
||||
if (typeof context !== "string" || context.trim() === "") {
|
||||
fail("injected context was empty");
|
||||
}
|
||||
|
||||
const expectedText = process.env.EXPECT_CONTAINS || "";
|
||||
if (expectedText && !context.includes(expectedText)) {
|
||||
fail(`context did not contain expected text: ${expectedText}`);
|
||||
}
|
||||
|
||||
const forbiddenTexts = (process.env.EXPECT_NOT_CONTAINS || "")
|
||||
.split("\u001f")
|
||||
.filter(Boolean);
|
||||
for (const forbiddenText of forbiddenTexts) {
|
||||
if (context.includes(forbiddenText)) {
|
||||
fail(`context contained forbidden text: ${forbiddenText}`);
|
||||
}
|
||||
}
|
||||
'; then
|
||||
pass "$description"
|
||||
else
|
||||
fail "$description"
|
||||
echo " output:"
|
||||
echo "$output" | sed 's/^/ /'
|
||||
fi
|
||||
}
|
||||
|
||||
echo "SessionStart hook output tests"
|
||||
|
||||
claude_home="$(make_home claude-code)"
|
||||
assert_command_output \
|
||||
"Claude Code emits nested SessionStart additionalContext" \
|
||||
"nested" \
|
||||
"" \
|
||||
"" \
|
||||
"$claude_home" \
|
||||
CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
|
||||
bash "$HOOK_UNDER_TEST"
|
||||
|
||||
codex_home="$(make_home codex-plugin-hooks)"
|
||||
codex_data="$TEST_ROOT/codex-plugin-hooks/data"
|
||||
mkdir -p "$codex_data"
|
||||
assert_command_output \
|
||||
"Codex plugin hooks emit nested SessionStart additionalContext" \
|
||||
"nested" \
|
||||
"" \
|
||||
"" \
|
||||
"$codex_home" \
|
||||
PLUGIN_DATA="$codex_data" \
|
||||
CLAUDE_PLUGIN_DATA="$codex_data" \
|
||||
PLUGIN_ROOT="$REPO_ROOT" \
|
||||
CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
|
||||
bash "$HOOK_UNDER_TEST"
|
||||
|
||||
codex_wrapper_home="$(make_home codex-wrapper)"
|
||||
codex_wrapper_data="$TEST_ROOT/codex-wrapper/data"
|
||||
mkdir -p "$codex_wrapper_data"
|
||||
assert_command_output \
|
||||
"Codex wrapper path emits nested SessionStart additionalContext" \
|
||||
"nested" \
|
||||
"" \
|
||||
"" \
|
||||
"$codex_wrapper_home" \
|
||||
PLUGIN_DATA="$codex_wrapper_data" \
|
||||
CLAUDE_PLUGIN_DATA="$codex_wrapper_data" \
|
||||
PLUGIN_ROOT="$REPO_ROOT" \
|
||||
CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
|
||||
bash "$WRAPPER_UNDER_TEST" session-start
|
||||
|
||||
cursor_home="$(make_home cursor)"
|
||||
assert_command_output \
|
||||
"Cursor emits top-level additional_context only" \
|
||||
"cursor" \
|
||||
"" \
|
||||
"" \
|
||||
"$cursor_home" \
|
||||
CURSOR_PLUGIN_ROOT="$REPO_ROOT" \
|
||||
CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
|
||||
bash "$HOOK_UNDER_TEST"
|
||||
|
||||
copilot_home="$(make_home copilot-cli)"
|
||||
assert_command_output \
|
||||
"Copilot CLI emits top-level additionalContext only" \
|
||||
"sdk" \
|
||||
"" \
|
||||
"" \
|
||||
"$copilot_home" \
|
||||
COPILOT_CLI=1 \
|
||||
CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
|
||||
bash "$HOOK_UNDER_TEST"
|
||||
|
||||
claude_legacy_home="$(make_home claude-legacy-warning)"
|
||||
mkdir -p "$claude_legacy_home/.config/superpowers/skills"
|
||||
assert_command_output \
|
||||
"Claude legacy warning points custom skills to ~/.claude/skills" \
|
||||
"nested" \
|
||||
"Superpowers now uses Claude Code's skills system. Custom skills in ~/.config/superpowers/skills will not be read. Move custom skills to ~/.claude/skills instead." \
|
||||
"" \
|
||||
"$claude_legacy_home" \
|
||||
CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
|
||||
bash "$HOOK_UNDER_TEST"
|
||||
|
||||
codex_legacy_home="$(make_home codex-legacy-warning)"
|
||||
codex_legacy_data="$TEST_ROOT/codex-legacy-warning/data"
|
||||
mkdir -p "$codex_legacy_home/.config/superpowers/skills" "$codex_legacy_data"
|
||||
assert_command_output \
|
||||
"Codex legacy warning uses harness-neutral custom-skill wording" \
|
||||
"nested" \
|
||||
"WARNING: Superpowers now uses your coding agent's native skills system. Custom skills in ~/.config/superpowers/skills will not be read. Move custom skills to a skills location supported by your coding agent. To make this message go away, remove ~/.config/superpowers/skills" \
|
||||
"~/.claude/skills"$'\037'"Claude Code's skills system" \
|
||||
"$codex_legacy_home" \
|
||||
PLUGIN_DATA="$codex_legacy_data" \
|
||||
CLAUDE_PLUGIN_DATA="$codex_legacy_data" \
|
||||
PLUGIN_ROOT="$REPO_ROOT" \
|
||||
CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
|
||||
bash "$HOOK_UNDER_TEST"
|
||||
|
||||
if [[ "$FAILURES" -gt 0 ]]; then
|
||||
echo "STATUS: FAILED ($FAILURES failure(s))"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "STATUS: PASSED"
|
||||
Reference in New Issue
Block a user