|
|
@@ -0,0 +1,240 @@
|
|
|
+#!/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"
|
|
|
+CODEX_HOOK_UNDER_TEST="$REPO_ROOT/hooks/session-start-codex"
|
|
|
+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 use dedicated script and 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 "$CODEX_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 dispatches to dedicated script" \
|
|
|
+ "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-codex
|
|
|
+
|
|
|
+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"
|
|
|
+
|
|
|
+legacy_home="$(make_home legacy-warning-removed)"
|
|
|
+mkdir -p "$legacy_home/.config/superpowers/skills"
|
|
|
+assert_command_output \
|
|
|
+ "SessionStart omits obsolete legacy custom-skill warning" \
|
|
|
+ "nested" \
|
|
|
+ "" \
|
|
|
+ "Superpowers now uses"$'\037'"~/.config/superpowers/skills"$'\037'"~/.claude/skills"$'\037'"legacy" \
|
|
|
+ "$legacy_home" \
|
|
|
+ CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
|
|
|
+ bash "$HOOK_UNDER_TEST"
|
|
|
+
|
|
|
+codex_legacy_home="$(make_home codex-legacy-warning-removed)"
|
|
|
+codex_legacy_data="$TEST_ROOT/codex-legacy-warning-removed/data"
|
|
|
+mkdir -p "$codex_legacy_home/.config/superpowers/skills" "$codex_legacy_data"
|
|
|
+assert_command_output \
|
|
|
+ "Codex SessionStart omits obsolete legacy custom-skill warning" \
|
|
|
+ "nested" \
|
|
|
+ "" \
|
|
|
+ "Superpowers now uses"$'\037'"~/.config/superpowers/skills"$'\037'"~/.claude/skills"$'\037'"legacy" \
|
|
|
+ "$codex_legacy_home" \
|
|
|
+ PLUGIN_DATA="$codex_legacy_data" \
|
|
|
+ CLAUDE_PLUGIN_DATA="$codex_legacy_data" \
|
|
|
+ PLUGIN_ROOT="$REPO_ROOT" \
|
|
|
+ CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
|
|
|
+ bash "$CODEX_HOOK_UNDER_TEST"
|
|
|
+
|
|
|
+if [[ "$FAILURES" -gt 0 ]]; then
|
|
|
+ echo "STATUS: FAILED ($FAILURES failure(s))"
|
|
|
+ exit 1
|
|
|
+fi
|
|
|
+
|
|
|
+echo "STATUS: PASSED"
|