diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec06ad2..c83beb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,20 +27,19 @@ jobs: run: | shellcheck --severity=error \ scripts/cheer.sh \ + scripts/lib/*.sh \ scripts/animations/*.sh \ scripts/voices/*.sh \ bin/cheer \ + tests/*.sh \ scripts/check-secrets.sh \ scripts/install-hooks.sh - name: Scan for secrets run: bash scripts/check-secrets.sh - - name: Run smoke test - run: | - CHEERER_ENABLED=false bash scripts/cheer.sh - CHEERER_LANG=en CHEERER_DUMB=true bash scripts/cheer.sh - CHEERER_LANG=ja CHEERER_DUMB=true bash scripts/cheer.sh + - name: Run test suite + run: bash tests/run.sh all release: if: startsWith(github.ref, 'refs/tags/v') diff --git a/bin/cheer b/bin/cheer index 6b0db05..75d24b9 100755 --- a/bin/cheer +++ b/bin/cheer @@ -1,6 +1,8 @@ #!/bin/bash SCRIPT_DIR="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +. "$SCRIPT_DIR/scripts/lib/config.sh" + if [[ "${1:-}" == "--epic" ]]; then export CHEERER_ANIM=epic shift @@ -12,16 +14,14 @@ if [[ "${1:-}" == "--version" ]]; then fi if [[ "${1:-}" == "--disable" ]]; then - CHEERER_DATA_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.config/cheerer}" - mkdir -p "$CHEERER_DATA_DIR" - printf 'CHEERER_ENABLED=false\n' > "$CHEERER_DATA_DIR/config.sh" + config_ensure_data_dir + printf 'CHEERER_ENABLED=false\n' > "$(config_file_path)" echo "cheerer disabled. Run 'cheer --enable' to re-enable." exit 0 fi if [[ "${1:-}" == "--enable" ]]; then - CHEERER_DATA_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.config/cheerer}" - rm -f "$CHEERER_DATA_DIR/config.sh" + rm -f "$(config_file_path)" echo "cheerer enabled." exit 0 fi @@ -66,39 +66,21 @@ if [[ "${1:-}" == "--help" ]] || [[ "${1:-}" == "-h" ]]; then fi _cheerer_config() { - CHEERER_ENABLED="${CHEERER_ENABLED:-true}" - CHEERER_LANG="${CHEERER_LANG:-${CLAUDE_PLUGIN_OPTION_LANG:-zh}}" - CHEERER_ANIM="${CHEERER_ANIM:-${CLAUDE_PLUGIN_OPTION_ANIM:-random}}" - CHEERER_VOICE="${CHEERER_VOICE:-${CLAUDE_PLUGIN_OPTION_VOICE:-on}}" - CHEERER_STYLE="${CHEERER_STYLE:-${CLAUDE_PLUGIN_OPTION_STYLE:-adaptive}}" - CHEERER_INTENSITY="${CHEERER_INTENSITY:-${CLAUDE_PLUGIN_OPTION_INTENSITY:-normal}}" - CHEERER_MODE="${CHEERER_MODE:-auto}" - CHEERER_COOLDOWN="${CHEERER_COOLDOWN:-3}" - CHEERER_ANIM_DURATION="${CHEERER_ANIM_DURATION:-30}" - CHEERER_EPIC="${CHEERER_EPIC:-false}" - CHEERER_EPIC_THRESHOLD="${CHEERER_EPIC_THRESHOLD:-60}" + config_load_file "$(config_file_path)" + config_apply_defaults echo "" echo " cheerer — Current Configuration" echo "" - printf " CHEERER_ENABLED=%s\n" "$CHEERER_ENABLED" - printf " CHEERER_LANG=%s\n" "$CHEERER_LANG" - printf " CHEERER_ANIM=%s\n" "$CHEERER_ANIM" - printf " CHEERER_VOICE=%s\n" "$CHEERER_VOICE" - printf " CHEERER_STYLE=%s\n" "$CHEERER_STYLE" - printf " CHEERER_INTENSITY=%s\n" "$CHEERER_INTENSITY" - printf " CHEERER_MODE=%s\n" "$CHEERER_MODE" - printf " CHEERER_COOLDOWN=%s\n" "$CHEERER_COOLDOWN" - printf " CHEERER_ANIM_DURATION=%s\n" "$CHEERER_ANIM_DURATION" - printf " CHEERER_EPIC=%s\n" "$CHEERER_EPIC" - printf " CHEERER_EPIC_THRESHOLD=%s\n" "$CHEERER_EPIC_THRESHOLD" + config_print_current echo "" - local config_file="${CLAUDE_PLUGIN_DATA:-$HOME/.config/cheerer}/config.sh" - if [[ -f "$config_file" ]]; then - printf " Config file: %s (active)\n" "$config_file" + local _cfg_file + _cfg_file="$(config_file_path)" + if [[ -f "$_cfg_file" ]]; then + printf " Config file: %s (active)\n" "$_cfg_file" else - printf " Config file: %s (not found)\n" "$config_file" + printf " Config file: %s (not found)\n" "$_cfg_file" fi echo "" exit 0 @@ -109,7 +91,7 @@ if [[ "${1:-}" == "--config" ]]; then fi _cheerer_stats() { - CHEERER_DATA_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.config/cheerer}" + config_ensure_data_dir STATS_FILE="$CHEERER_DATA_DIR/stats.json" HISTORY_FILE="$CHEERER_DATA_DIR/history.log" diff --git a/scripts/animations/basketball.sh b/scripts/animations/basketball.sh index 0351d46..ded5515 100755 --- a/scripts/animations/basketball.sh +++ b/scripts/animations/basketball.sh @@ -4,7 +4,7 @@ ANIM_LIB="$(dirname "${BASH_SOURCE[0]}")/../lib/animation.sh" if [[ ! -f "$ANIM_LIB" ]]; then - printf '🎉 %s\n' "${CHEERER_MESSAGE:-Great work!}" + printf '🎉 %s\n' "$(printf '%s' "${CHEERER_MESSAGE:-Great work!}" | sed $'s/\033\[[0-9;]*[A-Za-z]//g' | tr -d '\001-\037')" exit 0 fi . "$ANIM_LIB" @@ -20,4 +20,9 @@ DANMAKU_COLOR=($'\033[38;5;208m' $'\033[33m' $'\033[1;32m' $'\033[38;5;208 DANMAKU_SPEED=(3 4 2 3 5 4 ) DANMAKU_DELAY=(0 5 2 8 12 3 ) +if ! anim_validate_theme; then + anim_fallback_plain + exit 0 +fi + anim_danmaku_run diff --git a/scripts/animations/dance.sh b/scripts/animations/dance.sh index ed66ad2..f60d2b0 100755 --- a/scripts/animations/dance.sh +++ b/scripts/animations/dance.sh @@ -4,7 +4,7 @@ ANIM_LIB="$(dirname "${BASH_SOURCE[0]}")/../lib/animation.sh" if [[ ! -f "$ANIM_LIB" ]]; then - printf '🎉 %s\n' "${CHEERER_MESSAGE:-Great work!}" + printf '🎉 %s\n' "$(printf '%s' "${CHEERER_MESSAGE:-Great work!}" | sed $'s/\033\[[0-9;]*[A-Za-z]//g' | tr -d '\001-\037')" exit 0 fi . "$ANIM_LIB" @@ -20,4 +20,9 @@ DANMAKU_COLOR=($'\033[96m' $'\033[33m' $'\033[1;32m' $'\033[35m' DANMAKU_SPEED=(3 2 2 4 3 5 ) DANMAKU_DELAY=(0 5 2 10 3 15 ) +if ! anim_validate_theme; then + anim_fallback_plain + exit 0 +fi + anim_danmaku_run diff --git a/scripts/animations/fireworks.sh b/scripts/animations/fireworks.sh index 60351bd..ab12368 100755 --- a/scripts/animations/fireworks.sh +++ b/scripts/animations/fireworks.sh @@ -4,7 +4,7 @@ ANIM_LIB="$(dirname "${BASH_SOURCE[0]}")/../lib/animation.sh" if [[ ! -f "$ANIM_LIB" ]]; then - printf '🎉 %s\n' "${CHEERER_MESSAGE:-Great work!}" + printf '🎉 %s\n' "$(printf '%s' "${CHEERER_MESSAGE:-Great work!}" | sed $'s/\033\[[0-9;]*[A-Za-z]//g' | tr -d '\001-\037')" exit 0 fi . "$ANIM_LIB" @@ -20,4 +20,9 @@ DANMAKU_COLOR=($'\033[33m' $'\033[31m' $'\033[1;33m' $'\033[35m' DANMAKU_SPEED=(3 4 2 3 2 5 ) DANMAKU_DELAY=(0 5 2 8 12 3 ) +if ! anim_validate_theme; then + anim_fallback_plain + exit 0 +fi + anim_danmaku_run diff --git a/scripts/animations/rocket.sh b/scripts/animations/rocket.sh index 64fa0c6..527804b 100755 --- a/scripts/animations/rocket.sh +++ b/scripts/animations/rocket.sh @@ -4,7 +4,7 @@ ANIM_LIB="$(dirname "${BASH_SOURCE[0]}")/../lib/animation.sh" if [[ ! -f "$ANIM_LIB" ]]; then - printf '🎉 %s\n' "${CHEERER_MESSAGE:-Great work!}" + printf '🎉 %s\n' "$(printf '%s' "${CHEERER_MESSAGE:-Great work!}" | sed $'s/\033\[[0-9;]*[A-Za-z]//g' | tr -d '\001-\037')" exit 0 fi . "$ANIM_LIB" @@ -20,4 +20,9 @@ DANMAKU_COLOR=($'\033[31m' $'\033[38;5;208m' $'\033[1;96m' $'\033[33 DANMAKU_SPEED=(2 4 2 3 3 5 ) DANMAKU_DELAY=(0 5 2 8 12 3 ) +if ! anim_validate_theme; then + anim_fallback_plain + exit 0 +fi + anim_danmaku_run diff --git a/scripts/animations/trophy.sh b/scripts/animations/trophy.sh index b57ffea..f615e64 100755 --- a/scripts/animations/trophy.sh +++ b/scripts/animations/trophy.sh @@ -4,7 +4,7 @@ ANIM_LIB="$(dirname "${BASH_SOURCE[0]}")/../lib/animation.sh" if [[ ! -f "$ANIM_LIB" ]]; then - printf '🎉 %s\n' "${CHEERER_MESSAGE:-Great work!}" + printf '🎉 %s\n' "$(printf '%s' "${CHEERER_MESSAGE:-Great work!}" | sed $'s/\033\[[0-9;]*[A-Za-z]//g' | tr -d '\001-\037')" exit 0 fi . "$ANIM_LIB" @@ -20,4 +20,9 @@ DANMAKU_COLOR=($'\033[33m' $'\033[97m' $'\033[1;33m' $'\033[35m' DANMAKU_SPEED=(3 2 2 3 4 3 ) DANMAKU_DELAY=(0 5 2 8 12 3 ) +if ! anim_validate_theme; then + anim_fallback_plain + exit 0 +fi + anim_danmaku_run diff --git a/scripts/animations/wave.sh b/scripts/animations/wave.sh index 31c9a65..8e1fd0f 100755 --- a/scripts/animations/wave.sh +++ b/scripts/animations/wave.sh @@ -4,7 +4,7 @@ ANIM_LIB="$(dirname "${BASH_SOURCE[0]}")/../lib/animation.sh" if [[ ! -f "$ANIM_LIB" ]]; then - printf '🎉 %s\n' "${CHEERER_MESSAGE:-Great work!}" + printf '🎉 %s\n' "$(printf '%s' "${CHEERER_MESSAGE:-Great work!}" | sed $'s/\033\[[0-9;]*[A-Za-z]//g' | tr -d '\001-\037')" exit 0 fi . "$ANIM_LIB" @@ -20,4 +20,9 @@ DANMAKU_COLOR=($'\033[34m' $'\033[96m' $'\033[1;32m' $'\033[34m' DANMAKU_SPEED=(3 4 2 3 5 3 ) DANMAKU_DELAY=(0 5 2 8 12 3 ) +if ! anim_validate_theme; then + anim_fallback_plain + exit 0 +fi + anim_danmaku_run diff --git a/scripts/cheer.sh b/scripts/cheer.sh index f35bca2..5884fef 100755 --- a/scripts/cheer.sh +++ b/scripts/cheer.sh @@ -1,15 +1,13 @@ #!/bin/bash set +e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +. "$SCRIPT_DIR/lib/config.sh" + _cheer_load_config() { - CHEERER_DATA_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.config/cheerer}" - if [[ -f "$CHEERER_DATA_DIR/config.sh" ]]; then - if grep -qE '^[[:space:]]*CHEERER_[A-Z_]+=' "$CHEERER_DATA_DIR/config.sh" 2>/dev/null; then - if ! grep -qvE '^[[:space:]]*(CHEERER_[A-Z_]+=.*|#.*|)[[:space:]]*$' "$CHEERER_DATA_DIR/config.sh" 2>/dev/null; then - . "$CHEERER_DATA_DIR/config.sh" - fi - fi - fi + config_ensure_data_dir + config_load_file "$(config_file_path)" } _cheer_check_enabled() { @@ -25,15 +23,15 @@ _cheer_setup_tty() { fi } -_cheer_parse_hook_event() { +_cheer_read_hook_payload() { + HOOK_PAYLOAD="" local _raw - if read -r -t 1 _raw 2>/dev/null; then :; fi - HOOK_EVENT=$(printf '%s' "$_raw" | grep -o '"hook_event_name"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4) - TASK_DURATION=$(printf '%s' "$_raw" | grep -o '"duration_seconds"[[:space:]]*:[[:space:]]*[0-9]*' | grep -o '[0-9]*$') + if read -r -t 1 _raw 2>/dev/null; then + HOOK_PAYLOAD="$_raw" + fi } _cheer_setup_dirs() { - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CHEERER_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" ANIM_DIR="$SCRIPT_DIR/animations" VOICE_DIR="$SCRIPT_DIR/voices" @@ -42,27 +40,8 @@ _cheer_setup_dirs() { } _cheer_validate_config() { - CHEERER_LANG="${CHEERER_LANG:-${CLAUDE_PLUGIN_OPTION_LANG:-zh}}" - CHEERER_ANIM="${CHEERER_ANIM:-${CLAUDE_PLUGIN_OPTION_ANIM:-random}}" - CHEERER_VOICE="${CHEERER_VOICE:-${CLAUDE_PLUGIN_OPTION_VOICE:-on}}" - CHEERER_STYLE="${CHEERER_STYLE:-${CLAUDE_PLUGIN_OPTION_STYLE:-adaptive}}" - CHEERER_INTENSITY="${CHEERER_INTENSITY:-${CLAUDE_PLUGIN_OPTION_INTENSITY:-normal}}" - CHEERER_DUMB="${CHEERER_DUMB:-auto}" - CHEERER_MODE="${CHEERER_MODE:-auto}" - CHEERER_COOLDOWN="${CHEERER_COOLDOWN:-3}" - CHEERER_EPIC_THRESHOLD="${CHEERER_EPIC_THRESHOLD:-60}" - CHEERER_EPIC="${CHEERER_EPIC:-false}" - - case "$CHEERER_LANG" in zh|en|ja|ko|es) ;; *) CHEERER_LANG="zh" ;; esac - case "$CHEERER_STYLE" in adaptive|balanced|hype|cozy) ;; *) CHEERER_STYLE="adaptive" ;; esac - case "$CHEERER_INTENSITY" in soft|normal|high) ;; *) CHEERER_INTENSITY="normal" ;; esac - case "$CHEERER_MODE" in auto|full|text) ;; *) CHEERER_MODE="auto" ;; esac - case "$CHEERER_DUMB" in auto|true|false) ;; *) CHEERER_DUMB="auto" ;; esac - - if [[ "$CHEERER_DUMB" == "auto" ]]; then - CHEERER_DUMB=false - [[ "${TERM:-}" == "dumb" ]] || [[ -z "${TERM:-}" ]] && CHEERER_DUMB=true - fi + config_apply_defaults + config_resolve_runtime_flags } _cheer_check_cooldown() { @@ -109,25 +88,21 @@ _cheer_apply_anim_override() { _cheer_load_config _cheer_check_enabled _cheer_setup_tty -_cheer_parse_hook_event +_cheer_read_hook_payload _cheer_setup_dirs _cheer_validate_config . "$SCRIPT_DIR/lib/state.sh" +. "$SCRIPT_DIR/lib/context.sh" . "$SCRIPT_DIR/lib/policy.sh" . "$SCRIPT_DIR/lib/render.sh" state_init CHEERER_FIRST_RUN="false" [[ "${STATS_TOTAL_TRIGGERS:-0}" -eq 0 ]] && CHEERER_FIRST_RUN="true" -CURRENT_TS=$(date +%s 2>/dev/null || echo 0) -CURRENT_ISO=$(date -Iseconds 2>/dev/null || date) -export CHEERER_HOUR="${CHEERER_HOUR:-$(date +%H 2>/dev/null || echo 12)}" -export CHEERER_ANIM_DURATION="${CHEERER_ANIM_DURATION:-}" -RECENT_TASKCOMPLETED_COUNT=$(state_recent_count $((CURRENT_TS - 300)) "TaskCompleted") -SESSION_STREAK=$(state_recent_count $((CURRENT_TS - 1800)) "TaskCompleted") -RECENT_ANIMATIONS="$(state_recent_values_csv 6 3)" -RECENT_MESSAGE_IDS="$(state_recent_values_csv 7 3)" +export CHEERER_ANIM_DURATION +context_build_runtime "$HOOK_PAYLOAD" +context_publish _cheer_check_cooldown _cheer_check_epic diff --git a/scripts/lib/animation.sh b/scripts/lib/animation.sh index ce9f68b..1f206d1 100644 --- a/scripts/lib/animation.sh +++ b/scripts/lib/animation.sh @@ -41,6 +41,32 @@ anim_sanitize_msg() { printf '%s' "$raw" | sed $'s/\033\[[0-9;]*[A-Za-z]//g' | tr -d '\001-\037' } +anim_validate_theme() { + local expected="${DANMAKU_ROWS:-0}" + local i row + + [[ "$expected" =~ ^[0-9]+$ ]] || return 1 + [[ "$expected" -ge 1 ]] || return 1 + [[ "${#DANMAKU_ROW[@]}" -eq "$expected" ]] || return 1 + [[ "${#DANMAKU_TEXT[@]}" -eq "$expected" ]] || return 1 + [[ "${#DANMAKU_COLOR[@]}" -eq "$expected" ]] || return 1 + [[ "${#DANMAKU_SPEED[@]}" -eq "$expected" ]] || return 1 + [[ "${#DANMAKU_DELAY[@]}" -eq "$expected" ]] || return 1 + + for ((i=0; i/dev/null; then + return 0 + fi + + # Safe to source + . "$_file" +} + +# --------------------------------------------------------------------------- +# config_apply_defaults() — set CHEERER_* variables from plugin options / +# environment, then normalize to known-good values. +# --------------------------------------------------------------------------- +config_apply_defaults() { + CHEERER_ENABLED="${CHEERER_ENABLED:-true}" + CHEERER_LANG="${CHEERER_LANG:-${CLAUDE_PLUGIN_OPTION_LANG:-zh}}" + CHEERER_ANIM="${CHEERER_ANIM:-${CLAUDE_PLUGIN_OPTION_ANIM:-random}}" + CHEERER_VOICE="${CHEERER_VOICE:-${CLAUDE_PLUGIN_OPTION_VOICE:-on}}" + CHEERER_STYLE="${CHEERER_STYLE:-${CLAUDE_PLUGIN_OPTION_STYLE:-adaptive}}" + CHEERER_INTENSITY="${CHEERER_INTENSITY:-${CLAUDE_PLUGIN_OPTION_INTENSITY:-normal}}" + CHEERER_DUMB="${CHEERER_DUMB:-auto}" + CHEERER_MODE="${CHEERER_MODE:-auto}" + CHEERER_COOLDOWN="${CHEERER_COOLDOWN:-3}" + CHEERER_ANIM_DURATION="${CHEERER_ANIM_DURATION:-30}" + CHEERER_EPIC="${CHEERER_EPIC:-false}" + CHEERER_EPIC_THRESHOLD="${CHEERER_EPIC_THRESHOLD:-60}" + + # Normalize to valid enumerations + case "$CHEERER_LANG" in zh|en|ja|ko|es) ;; *) CHEERER_LANG="zh" ;; esac + case "$CHEERER_STYLE" in adaptive|balanced|hype|cozy) ;; *) CHEERER_STYLE="adaptive" ;; esac + case "$CHEERER_INTENSITY" in soft|normal|high) ;; *) CHEERER_INTENSITY="normal" ;; esac + case "$CHEERER_DUMB" in auto|true|false) ;; *) CHEERER_DUMB="auto" ;; esac + case "$CHEERER_MODE" in auto|full|text) ;; *) CHEERER_MODE="auto" ;; esac +} + +# --------------------------------------------------------------------------- +# config_resolve_runtime_flags() — convert CHEERER_DUMB=auto to true/false +# based on the current TERM value. +# --------------------------------------------------------------------------- +config_resolve_runtime_flags() { + if [[ "${CHEERER_DUMB:-auto}" == "auto" ]]; then + if [[ "${TERM:-}" == "dumb" ]] || [[ -z "${TERM:-}" ]]; then + CHEERER_DUMB=true + else + CHEERER_DUMB=false + fi + fi +} + +# --------------------------------------------------------------------------- +# config_ensure_data_dir() — create the data directory and export +# CHEERER_DATA_DIR pointing to it. +# --------------------------------------------------------------------------- +config_ensure_data_dir() { + CHEERER_DATA_DIR="$(config_data_dir)" + mkdir -p "$CHEERER_DATA_DIR" 2>/dev/null || true +} + +# --------------------------------------------------------------------------- +# config_print_current() — print effective CHEERER_* values, one per line, +# using " KEY=value" format (two leading spaces, matching bin/cheer output). +# --------------------------------------------------------------------------- +config_print_current() { + printf " CHEERER_ENABLED=%s\n" "${CHEERER_ENABLED:-}" + printf " CHEERER_LANG=%s\n" "${CHEERER_LANG:-}" + printf " CHEERER_ANIM=%s\n" "${CHEERER_ANIM:-}" + printf " CHEERER_VOICE=%s\n" "${CHEERER_VOICE:-}" + printf " CHEERER_STYLE=%s\n" "${CHEERER_STYLE:-}" + printf " CHEERER_INTENSITY=%s\n" "${CHEERER_INTENSITY:-}" + printf " CHEERER_MODE=%s\n" "${CHEERER_MODE:-}" + printf " CHEERER_DUMB=%s\n" "${CHEERER_DUMB:-}" + printf " CHEERER_COOLDOWN=%s\n" "${CHEERER_COOLDOWN:-}" + printf " CHEERER_ANIM_DURATION=%s\n" "${CHEERER_ANIM_DURATION:-}" + printf " CHEERER_EPIC=%s\n" "${CHEERER_EPIC:-}" + printf " CHEERER_EPIC_THRESHOLD=%s\n" "${CHEERER_EPIC_THRESHOLD:-}" +} diff --git a/scripts/lib/context.sh b/scripts/lib/context.sh new file mode 100644 index 0000000..1e1b8af --- /dev/null +++ b/scripts/lib/context.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +# context.sh — runtime context module for cheerer +# Parses the hook payload, builds runtime context from history, and +# publishes backwards-compatible globals consumed by policy/render. +# Requires: state.sh must be sourced before calling context_build_runtime. + +# Internal CTX_* vars — reset before each parse/build cycle. +context_reset() { + CTX_HOOK_EVENT="" + CTX_TASK_DURATION=0 + CTX_CURRENT_TS=$(date +%s 2>/dev/null || echo 0) + CTX_CURRENT_ISO=$(date -Iseconds 2>/dev/null || date) + CTX_HOUR="${CHEERER_HOUR:-$(date +%H 2>/dev/null || echo 12)}" + CTX_RECENT_TASKCOMPLETED_COUNT=0 + CTX_SESSION_STREAK=0 + CTX_RECENT_ANIMATIONS="" + CTX_RECENT_MESSAGE_IDS="" +} + +# context_parse_hook_payload +# Extracts hook_event_name and duration_seconds from a single-line JSON payload. +# Non-numeric or missing duration defaults to 0. +context_parse_hook_payload() { + local payload="$1" + + CTX_HOOK_EVENT=$(printf '%s' "$payload" | \ + grep -o '"hook_event_name"[[:space:]]*:[[:space:]]*"[^"]*"' | \ + cut -d'"' -f4) + + local raw_dur + raw_dur=$(printf '%s' "$payload" | \ + grep -o '"duration_seconds"[[:space:]]*:[[:space:]]*[0-9]*' | \ + grep -o '[0-9]*$') + + if [[ "$raw_dur" =~ ^[0-9]+$ ]]; then + CTX_TASK_DURATION="$raw_dur" + else + CTX_TASK_DURATION=0 + fi +} + +# context_build_runtime +# Parses the payload, takes a timestamp, and reads recent history +# via state.sh APIs to populate all CTX_* vars. +context_build_runtime() { + local payload="${1:-}" + + context_reset + context_parse_hook_payload "$payload" + CTX_RECENT_TASKCOMPLETED_COUNT="$(state_recent_count $((CTX_CURRENT_TS - 300)) "TaskCompleted")" + CTX_SESSION_STREAK="$(state_recent_count $((CTX_CURRENT_TS - 1800)) "TaskCompleted")" + CTX_RECENT_ANIMATIONS="$(state_recent_values_csv 6 3)" + CTX_RECENT_MESSAGE_IDS="$(state_recent_values_csv 7 3)" +} + +# context_publish +# Exports all backwards-compatible globals consumed by policy, render, +# and state_append_history. Must be called after context_build_runtime. +context_publish() { + export HOOK_EVENT="$CTX_HOOK_EVENT" + export TASK_DURATION="$CTX_TASK_DURATION" + export CURRENT_TS="$CTX_CURRENT_TS" + export CURRENT_ISO="$CTX_CURRENT_ISO" + export CHEERER_HOUR="$CTX_HOUR" + export RECENT_TASKCOMPLETED_COUNT="$CTX_RECENT_TASKCOMPLETED_COUNT" + export SESSION_STREAK="$CTX_SESSION_STREAK" + export RECENT_ANIMATIONS="$CTX_RECENT_ANIMATIONS" + export RECENT_MESSAGE_IDS="$CTX_RECENT_MESSAGE_IDS" +} diff --git a/tests/ci_test.sh b/tests/ci_test.sh new file mode 100644 index 0000000..87921f8 --- /dev/null +++ b/tests/ci_test.sh @@ -0,0 +1,48 @@ +#!/bin/bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +. "$ROOT_DIR/tests/test_lib.sh" + +test_ci_workflow_runs_full_suite() { + local workflow + workflow="$(< "$ROOT_DIR/.github/workflows/ci.yml")" + assert_contains "$workflow" "bash tests/run.sh all" +} + +test_ci_workflow_shellchecks_shared_libs_and_tests() { + local workflow + workflow="$(< "$ROOT_DIR/.github/workflows/ci.yml")" + assert_contains "$workflow" "scripts/lib/*.sh" + assert_contains "$workflow" "tests/*.sh" +} + +test_run_sh_all_includes_hardening_suites() { + local runner + runner="$(< "$ROOT_DIR/tests/run.sh")" + assert_contains "$runner" "bash tests/config_test.sh" + assert_contains "$runner" "bash tests/context_test.sh" + assert_contains "$runner" "bash tests/ci_test.sh" +} + +test_run_sh_supports_named_hardening_suites() { + local runner + runner="$(< "$ROOT_DIR/tests/run.sh")" + assert_contains "$runner" "config)" + assert_contains "$runner" "context)" + assert_contains "$runner" "ci)" +} + +test_cheer_script_does_not_redefault_anim_duration_after_config() { + local cheer_script + cheer_script="$(< "$ROOT_DIR/scripts/cheer.sh")" + assert_contains "$cheer_script" 'export CHEERER_ANIM_DURATION' + assert_not_contains "$cheer_script" 'export CHEERER_ANIM_DURATION="${CHEERER_ANIM_DURATION:-}"' +} + +run_test "ci_workflow_runs_full_suite" test_ci_workflow_runs_full_suite +run_test "ci_workflow_shellchecks_shared_libs_and_tests" test_ci_workflow_shellchecks_shared_libs_and_tests +run_test "run_sh_all_includes_hardening_suites" test_run_sh_all_includes_hardening_suites +run_test "run_sh_supports_named_hardening_suites" test_run_sh_supports_named_hardening_suites +run_test "cheer_script_does_not_redefault_anim_duration_after_config" test_cheer_script_does_not_redefault_anim_duration_after_config +finish_tests diff --git a/tests/config_test.sh b/tests/config_test.sh new file mode 100644 index 0000000..fd0f768 --- /dev/null +++ b/tests/config_test.sh @@ -0,0 +1,198 @@ +#!/bin/bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +. "$ROOT_DIR/tests/test_lib.sh" +. "$ROOT_DIR/scripts/lib/config.sh" + +# --------------------------------------------------------------------------- +# test_config_apply_defaults_uses_plugin_options +# --------------------------------------------------------------------------- +test_config_apply_defaults_uses_plugin_options() { + unset CHEERER_LANG CHEERER_ANIM CHEERER_VOICE CHEERER_STYLE CHEERER_INTENSITY \ + CHEERER_DUMB CHEERER_MODE CHEERER_COOLDOWN CHEERER_ANIM_DURATION \ + CHEERER_EPIC CHEERER_EPIC_THRESHOLD CHEERER_ENABLED 2>/dev/null || true + + CLAUDE_PLUGIN_OPTION_LANG=en + CLAUDE_PLUGIN_OPTION_ANIM=fireworks + CLAUDE_PLUGIN_OPTION_VOICE=off + CLAUDE_PLUGIN_OPTION_STYLE=hype + CLAUDE_PLUGIN_OPTION_INTENSITY=high + + config_apply_defaults + + assert_eq "en" "$CHEERER_LANG" || return 1 + assert_eq "fireworks" "$CHEERER_ANIM" || return 1 + assert_eq "off" "$CHEERER_VOICE" || return 1 + assert_eq "hype" "$CHEERER_STYLE" || return 1 + assert_eq "high" "$CHEERER_INTENSITY" || return 1 + assert_eq "true" "$CHEERER_ENABLED" || return 1 + assert_eq "auto" "$CHEERER_DUMB" || return 1 + assert_eq "auto" "$CHEERER_MODE" || return 1 + assert_eq "3" "$CHEERER_COOLDOWN" || return 1 + assert_eq "30" "$CHEERER_ANIM_DURATION" || return 1 + assert_eq "false" "$CHEERER_EPIC" || return 1 + assert_eq "60" "$CHEERER_EPIC_THRESHOLD" || return 1 +} + +# --------------------------------------------------------------------------- +# test_config_apply_defaults_normalizes_invalid_values +# --------------------------------------------------------------------------- +test_config_apply_defaults_normalizes_invalid_values() { + unset CLAUDE_PLUGIN_OPTION_LANG CLAUDE_PLUGIN_OPTION_ANIM CLAUDE_PLUGIN_OPTION_VOICE \ + CLAUDE_PLUGIN_OPTION_STYLE CLAUDE_PLUGIN_OPTION_INTENSITY 2>/dev/null || true + + CHEERER_LANG="klingon" + CHEERER_STYLE="loud" + CHEERER_INTENSITY="ultra" + CHEERER_DUMB="yes" + CHEERER_MODE="fancy" + + config_apply_defaults + + assert_eq "zh" "$CHEERER_LANG" || return 1 + assert_eq "adaptive" "$CHEERER_STYLE" || return 1 + assert_eq "normal" "$CHEERER_INTENSITY" || return 1 + assert_eq "auto" "$CHEERER_DUMB" || return 1 + assert_eq "auto" "$CHEERER_MODE" || return 1 +} + +# --------------------------------------------------------------------------- +# test_config_load_file_rejects_non_cheerer_lines +# --------------------------------------------------------------------------- +test_config_load_file_rejects_non_cheerer_lines() { + local tmp_dir + tmp_dir="$(make_tmp_dir)" + local cfg="$tmp_dir/config.sh" + + # File with a non-CHEERER line — must be rejected + printf 'CHEERER_LANG=en\nrm -rf /\n' > "$cfg" + + CHEERER_LANG="zh" + config_load_file "$cfg" + assert_eq "zh" "$CHEERER_LANG" || return 1 + + # A clean file must be accepted + printf 'CHEERER_LANG=ja\n' > "$cfg" + config_load_file "$cfg" + assert_eq "ja" "$CHEERER_LANG" || return 1 +} + +# --------------------------------------------------------------------------- +# test_config_load_file_rejects_inline_command_injection +# Regression: CHEERER_LANG=en; rm -rf / must not execute and must not +# change CHEERER_LANG — the entire line fails the safe-value filter. +# --------------------------------------------------------------------------- +test_config_load_file_rejects_inline_command_injection() { + local tmp_dir + tmp_dir="$(make_tmp_dir)" + local cfg="$tmp_dir/config.sh" + local sentinel="$tmp_dir/sentinel" + + # Payload: semicolon followed by a command that would create a sentinel file + printf 'CHEERER_LANG=en; touch %s\n' "$sentinel" > "$cfg" + + CHEERER_LANG="zh" + config_load_file "$cfg" + + # Variable must not have changed (file was rejected) + assert_eq "zh" "$CHEERER_LANG" || return 1 + + # Sentinel must not exist (command was not executed) + if [[ -f "$sentinel" ]]; then + printf 'FAIL: sentinel file was created — injection was not blocked\n' + return 1 + fi +} + +# --------------------------------------------------------------------------- +# test_config_resolve_runtime_flags_marks_dumb_term_text_only +# --------------------------------------------------------------------------- +test_config_resolve_runtime_flags_marks_dumb_term_text_only() { + CHEERER_DUMB="auto" + TERM="dumb" + config_resolve_runtime_flags + assert_eq "true" "$CHEERER_DUMB" || return 1 + + CHEERER_DUMB="auto" + TERM="" + config_resolve_runtime_flags + assert_eq "true" "$CHEERER_DUMB" || return 1 + + CHEERER_DUMB="auto" + TERM="xterm-256color" + config_resolve_runtime_flags + assert_eq "false" "$CHEERER_DUMB" || return 1 + + # When already true/false, resolve_runtime_flags must leave it alone + CHEERER_DUMB="true" + TERM="xterm-256color" + config_resolve_runtime_flags + assert_eq "true" "$CHEERER_DUMB" || return 1 +} + +# --------------------------------------------------------------------------- +# test_config_ensure_data_dir_creates_override_dir +# --------------------------------------------------------------------------- +test_config_ensure_data_dir_creates_override_dir() { + local tmp_dir + tmp_dir="$(make_tmp_dir)" + local override_dir="$tmp_dir/my_data" + + CLAUDE_PLUGIN_DATA="$override_dir" + unset CHEERER_DATA_DIR 2>/dev/null || true + + config_ensure_data_dir + + [[ -d "$override_dir" ]] || return 1 + assert_eq "$override_dir" "$CHEERER_DATA_DIR" || return 1 + + unset CLAUDE_PLUGIN_DATA 2>/dev/null || true +} + +# --------------------------------------------------------------------------- +# test_config_print_current_lists_effective_values +# --------------------------------------------------------------------------- +test_config_print_current_lists_effective_values() { + CHEERER_ENABLED=true + CHEERER_LANG=en + CHEERER_ANIM=random + CHEERER_VOICE=on + CHEERER_STYLE=adaptive + CHEERER_INTENSITY=normal + CHEERER_MODE=auto + CHEERER_DUMB=false + CHEERER_COOLDOWN=3 + CHEERER_ANIM_DURATION=30 + CHEERER_EPIC=false + CHEERER_EPIC_THRESHOLD=60 + + local out + out="$(config_print_current)" + + assert_contains "$out" "CHEERER_ENABLED=true" || return 1 + assert_contains "$out" "CHEERER_LANG=en" || return 1 + assert_contains "$out" "CHEERER_ANIM=random" || return 1 + assert_contains "$out" "CHEERER_VOICE=on" || return 1 + assert_contains "$out" "CHEERER_STYLE=adaptive" || return 1 + assert_contains "$out" "CHEERER_INTENSITY=normal" || return 1 + assert_contains "$out" "CHEERER_MODE=auto" || return 1 + assert_contains "$out" "CHEERER_DUMB=false" || return 1 + assert_contains "$out" "CHEERER_COOLDOWN=3" || return 1 + assert_contains "$out" "CHEERER_ANIM_DURATION=30" || return 1 + assert_contains "$out" "CHEERER_EPIC=false" || return 1 + assert_contains "$out" "CHEERER_EPIC_THRESHOLD=60" || return 1 +} + +# --------------------------------------------------------------------------- +# Run +# --------------------------------------------------------------------------- +run_test "config_apply_defaults_uses_plugin_options" test_config_apply_defaults_uses_plugin_options +run_test "config_apply_defaults_normalizes_invalid_values" test_config_apply_defaults_normalizes_invalid_values +run_test "config_load_file_rejects_non_cheerer_lines" test_config_load_file_rejects_non_cheerer_lines +run_test "config_load_file_rejects_inline_command_injection" test_config_load_file_rejects_inline_command_injection +run_test "config_resolve_runtime_flags_marks_dumb_term_text_only" test_config_resolve_runtime_flags_marks_dumb_term_text_only +run_test "config_ensure_data_dir_creates_override_dir" test_config_ensure_data_dir_creates_override_dir +run_test "config_print_current_lists_effective_values" test_config_print_current_lists_effective_values + +finish_tests diff --git a/tests/context_test.sh b/tests/context_test.sh new file mode 100644 index 0000000..2d0aa2b --- /dev/null +++ b/tests/context_test.sh @@ -0,0 +1,79 @@ +#!/bin/bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +. "$ROOT_DIR/tests/test_lib.sh" +. "$ROOT_DIR/scripts/lib/state.sh" +. "$ROOT_DIR/scripts/lib/context.sh" + +make_history() { + local dir="$1" + CHEERER_DATA_DIR="$dir/data" + mkdir -p "$CHEERER_DATA_DIR" + STATS_FILE="$CHEERER_DATA_DIR/stats.json" + HISTORY_FILE="$CHEERER_DATA_DIR/history.log" + state_init +} + +test_context_parse_hook_payload_extracts_event_and_duration() { + context_reset + context_parse_hook_payload '{"hook_event_name":"TaskCompleted","duration_seconds":95}' + + assert_eq "TaskCompleted" "$CTX_HOOK_EVENT" || return 1 + assert_eq "95" "$CTX_TASK_DURATION" || return 1 +} + +test_context_parse_hook_payload_defaults_bad_duration_to_zero() { + context_reset + context_parse_hook_payload '{"hook_event_name":"Stop","duration_seconds":"fast"}' + + assert_eq "Stop" "$CTX_HOOK_EVENT" || return 1 + assert_eq "0" "$CTX_TASK_DURATION" || return 1 +} + +test_context_build_runtime_reads_recent_history() { + local tmp_dir now + tmp_dir="$(make_tmp_dir)" + make_history "$tmp_dir" + + now="$(date +%s)" + state_append_history "$((now - 60))" "TaskCompleted" "12" "solid" "steady" "dance" "en_solid_steady_1" + state_append_history "$((now - 30))" "TaskCompleted" "12" "solid" "steady" "rocket" "en_solid_steady_2" + state_append_history "$((now - 10))" "Stop" "1" "quick" "gentle" "" "en_quick_gentle_1" + + context_build_runtime '{"hook_event_name":"TaskCompleted","duration_seconds":12}' + + assert_eq "2" "$CTX_RECENT_TASKCOMPLETED_COUNT" || return 1 + assert_eq "dance,rocket," "$CTX_RECENT_ANIMATIONS" || return 1 + assert_eq "en_solid_steady_1,en_solid_steady_2,en_quick_gentle_1" "$CTX_RECENT_MESSAGE_IDS" || return 1 +} + +test_context_publish_exposes_backwards_compatible_globals() { + CTX_HOOK_EVENT="TaskCompleted" + CTX_TASK_DURATION="33" + CTX_CURRENT_TS="123" + CTX_CURRENT_ISO="2026-04-13T10:00:00+00:00" + CTX_HOUR="10" + CTX_RECENT_TASKCOMPLETED_COUNT="4" + CTX_SESSION_STREAK="4" + CTX_RECENT_ANIMATIONS="dance,rocket" + CTX_RECENT_MESSAGE_IDS="m1,m2" + + context_publish + + assert_eq "TaskCompleted" "$HOOK_EVENT" || return 1 + assert_eq "33" "$TASK_DURATION" || return 1 + assert_eq "123" "$CURRENT_TS" || return 1 + assert_eq "2026-04-13T10:00:00+00:00" "$CURRENT_ISO" || return 1 + assert_eq "10" "$CHEERER_HOUR" || return 1 + assert_eq "4" "$RECENT_TASKCOMPLETED_COUNT" || return 1 + assert_eq "4" "$SESSION_STREAK" || return 1 + assert_eq "dance,rocket" "$RECENT_ANIMATIONS" || return 1 + assert_eq "m1,m2" "$RECENT_MESSAGE_IDS" || return 1 +} + +run_test "context_parse_hook_payload_extracts_event_and_duration" test_context_parse_hook_payload_extracts_event_and_duration +run_test "context_parse_hook_payload_defaults_bad_duration_to_zero" test_context_parse_hook_payload_defaults_bad_duration_to_zero +run_test "context_build_runtime_reads_recent_history" test_context_build_runtime_reads_recent_history +run_test "context_publish_exposes_backwards_compatible_globals" test_context_publish_exposes_backwards_compatible_globals +finish_tests diff --git a/tests/integration_test.sh b/tests/integration_test.sh index e20d248..dcbdd8d 100644 --- a/tests/integration_test.sh +++ b/tests/integration_test.sh @@ -31,7 +31,9 @@ run_epic_probe() { mkdir -p "$app_root/scripts/lib" "$app_root/scripts/animations" "$app_root/scripts/voices" "$app_root/scripts/messages" "$bin_dir" cp scripts/cheer.sh "$app_root/scripts/cheer.sh" + cp scripts/lib/config.sh "$app_root/scripts/lib/config.sh" cp scripts/lib/state.sh "$app_root/scripts/lib/state.sh" + cp scripts/lib/context.sh "$app_root/scripts/lib/context.sh" cp scripts/lib/policy.sh "$app_root/scripts/lib/policy.sh" cp scripts/lib/render.sh "$app_root/scripts/lib/render.sh" cp scripts/messages/catalog_en.tsv "$app_root/scripts/messages/catalog_en.tsv" @@ -264,6 +266,31 @@ test_danmaku_library_graceful_fallback() { rm -rf "$tmp_dir" } +test_danmaku_library_graceful_fallback_sanitizes_message() { + local tmp_dir output + tmp_dir="$(make_tmp_dir)" + cp scripts/animations/dance.sh "$tmp_dir/dance.sh" + output="$(CHEERER_MESSAGE=$'Fallback\033[2JCheck' bash "$tmp_dir/dance.sh" 2>&1)" + assert_contains "$output" "Fallback" || return 1 + assert_contains "$output" "Check" || return 1 + assert_not_contains "$output" $'\033' || return 1 + assert_not_contains "$output" '[2J' || return 1 + rm -rf "$tmp_dir" +} + +test_ci_suite_runs_outside_repo_root() { + local tmp_dir root_dir output + tmp_dir="$(make_tmp_dir)" + root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + + if ! output="$(cd "$tmp_dir" && bash "$root_dir/tests/ci_test.sh" 2>&1)"; then + printf '%s\n' "$output" + return 1 + fi + + assert_contains "$output" "0 failed" || return 1 +} + run_test "stop_fixture_uses_quick_message" test_stop_fixture_uses_quick_message run_test "long_task_fixture_uses_big_message" test_long_task_fixture_uses_big_message run_test "corrupt_stats_still_exits_zero" test_corrupt_stats_still_exits_zero @@ -282,6 +309,8 @@ run_test "danmaku_animation_contains_message" test_danmaku_animation_contains_me run_test "danmaku_message_sanitizes_control_chars" test_danmaku_message_sanitizes_control_chars run_test "danmaku_narrow_terminal_exits_cleanly" test_danmaku_narrow_terminal_exits_cleanly run_test "danmaku_library_graceful_fallback" test_danmaku_library_graceful_fallback +run_test "danmaku_library_graceful_fallback_sanitizes_message" test_danmaku_library_graceful_fallback_sanitizes_message +run_test "ci_suite_runs_outside_repo_root" test_ci_suite_runs_outside_repo_root test_cooldown_does_not_reset_timer() { local tmp_dir output1 output2 @@ -576,4 +605,58 @@ test_milestone_message_truncated_at_sixty() { run_test "invalid_anim_falls_back_to_random" test_invalid_anim_falls_back_to_random run_test "milestone_message_truncated_at_sixty" test_milestone_message_truncated_at_sixty + +test_anim_validate_theme_rejects_mismatched_arrays() { + . scripts/lib/animation.sh + + declare -F anim_validate_theme >/dev/null || { + printf 'anim_validate_theme missing\n' + return 1 + } + + DANMAKU_ROWS=2 + DANMAKU_ROW=(1 2) + DANMAKU_TEXT=("only-one") + DANMAKU_COLOR=($'\033[32m' $'\033[33m') + DANMAKU_SPEED=(1 1) + DANMAKU_DELAY=(0 0) + + if anim_validate_theme; then + printf 'expected invalid theme to be rejected\n' + return 1 + fi +} + +test_invalid_theme_falls_back_to_plain_output() { + local tmp_dir theme_file output + tmp_dir="$(make_tmp_dir)" + theme_file="$tmp_dir/bad-theme.sh" + + cat > "$theme_file" <&1)" + + assert_contains "$output" "PlanFallback123" + assert_not_contains "$output" "command not found" +} + +run_test "anim_validate_theme_rejects_mismatched_arrays" test_anim_validate_theme_rejects_mismatched_arrays +run_test "invalid_theme_falls_back_to_plain_output" test_invalid_theme_falls_back_to_plain_output finish_tests diff --git a/tests/run.sh b/tests/run.sh index 8607381..5cda747 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -17,14 +17,26 @@ case "${1:-all}" in integration) bash tests/integration_test.sh ;; + config) + bash tests/config_test.sh + ;; + context) + bash tests/context_test.sh + ;; + ci) + bash tests/ci_test.sh + ;; all) bash tests/state_test.sh bash tests/policy_test.sh bash tests/render_test.sh bash tests/integration_test.sh + bash tests/config_test.sh + bash tests/context_test.sh + bash tests/ci_test.sh ;; *) - echo "usage: bash tests/run.sh [state|policy|render|integration|all]" + echo "usage: bash tests/run.sh [state|policy|render|integration|config|context|ci|all]" exit 1 ;; esac