From 08b66f1ead9ce078ce843d5c85555cc258b1d065 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Fri, 6 Feb 2026 22:14:05 +0100 Subject: [PATCH] Replace claude -p music refresh with zero-token OS-level detection (#49) The daemon's light-cycle music refresh was spawning `claude -p` every ~90s just to run JavaScript via Chrome MCP, costing ~5K tokens per call (~135K tokens/hour for active music users). This replaces it with a pure bash script using macOS native APIs (osascript for Spotify/Apple Music, nowplaying-cli for browser-based players), reducing daemon token usage by ~50% for music users. --- .claude-plugin/marketplace.json | 2 +- .../.claude-plugin/plugin.json | 2 +- .../claude-status-hub/bin/refresh-daemon.sh | 31 +- .../claude-status-hub/bin/refresh-music.sh | 82 ++++ plugins/claude-status-hub/skills/hub-music.md | 3 +- .../tests/test-music-refresh.sh | 411 ++++++++++++++++++ 6 files changed, 514 insertions(+), 17 deletions(-) create mode 100755 plugins/claude-status-hub/bin/refresh-music.sh create mode 100755 plugins/claude-status-hub/tests/test-music-refresh.sh diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 744e15e..ab61d6e 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -18,7 +18,7 @@ { "name": "claude-status-hub", "description": "Monitor what matters - PRs, music, custom alerts - right in your Claude Code statusline", - "version": "1.5.2", + "version": "1.5.4", "author": { "name": "Pavel Fadeev", "email": "pavel.fadeev@gmail.com" diff --git a/plugins/claude-status-hub/.claude-plugin/plugin.json b/plugins/claude-status-hub/.claude-plugin/plugin.json index b2c67c9..948b1c7 100644 --- a/plugins/claude-status-hub/.claude-plugin/plugin.json +++ b/plugins/claude-status-hub/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "claude-status-hub", - "version": "1.5.2", + "version": "1.5.4", "description": "Monitor and act - PRs, calendar, music, custom alerts with contextual actions via /hub-ack", "author": { "name": "Pavel Fadeev", diff --git a/plugins/claude-status-hub/bin/refresh-daemon.sh b/plugins/claude-status-hub/bin/refresh-daemon.sh index c3b0163..ef0432e 100755 --- a/plugins/claude-status-hub/bin/refresh-daemon.sh +++ b/plugins/claude-status-hub/bin/refresh-daemon.sh @@ -38,6 +38,7 @@ acquire_lock() { get_intervals() { local now_ms=$(($(date +%s) * 1000)) local last_activity=$(jq -r '.lastActivity // 0' "$BRIDGE" 2>/dev/null || echo 0) + [[ "$last_activity" =~ ^[0-9]+$ ]] || last_activity=0 local idle_ms=$((now_ms - last_activity)) local idle_min=$((idle_ms / 60000)) @@ -62,6 +63,9 @@ check_focus_break() { local start_time=$(jq -r '.focus.startTime // 0' "$CONFIG" 2>/dev/null) local break_after=$(jq -r '.focus.breakAfterMinutes // 75' "$CONFIG" 2>/dev/null) local last_reminder=$(jq -r '.focus.lastBreakReminder // 0' "$CONFIG" 2>/dev/null) + [[ "$start_time" =~ ^[0-9]+$ ]] || start_time=0 + [[ "$break_after" =~ ^[0-9]+$ ]] || break_after=75 + [[ "$last_reminder" =~ ^[0-9]+$ ]] || last_reminder=0 local now_ms=$(($(date +%s) * 1000)) local duration_min=$(( (now_ms - start_time) / 60000 )) @@ -76,6 +80,7 @@ check_focus_break() { # Check for gap before next meeting (need at least 15 min) local next_meeting_start=$(jq -r '.calendar.lastSeen[0].startTime // 0' "$CONFIG" 2>/dev/null) + [[ "$next_meeting_start" =~ ^[0-9]+$ ]] || next_meeting_start=0 local next_suffix="" if [ "$next_meeting_start" -gt "$now_ms" ]; then @@ -139,7 +144,7 @@ fi acquire_lock || exit 0 # Cleanup on exit -trap "rm -rf '$LOCKDIR'" EXIT INT TERM +trap "rm -rf '$LOCKDIR'" EXIT INT TERM HUP PIPE # Main loop LAST_FULL_REFRESH=0 @@ -156,18 +161,17 @@ while true; do exit 0 fi - # Self-check: exit if another daemon took over the lockfile + # Self-check: exit if another daemon took over the lock # This handles the startup race condition: multiple sessions starting simultaneously - # all pass the initial lockfile check before any writes its PID. After one sleep cycle, - # only the last writer survives because all others see a mismatched lockfile. - # See docs/data-safety-guidelines.md for race condition prevention patterns. - if [ -f "$LOCKFILE" ]; then - CURRENT_LOCK=$(cat "$LOCKFILE" 2>/dev/null) - if [ "$CURRENT_LOCK" != "${PLUGIN_VERSION}:$$" ]; then + # all pass the initial lock check before any writes its PID. After one sleep cycle, + # only the last writer survives because all others see a mismatched PID. + if [ -d "$LOCKDIR" ]; then + CURRENT_PID=$(cat "$LOCKDIR/pid" 2>/dev/null) + if [ "$CURRENT_PID" != "$$" ]; then exit 0 # Another daemon owns the lock, gracefully exit fi else - exit 0 # Lockfile gone, exit + exit 0 # Lock gone, exit fi [ -f "$CONFIG" ] || continue @@ -176,6 +180,7 @@ while true; do NOW_SEC=$(date +%s) if [ -f "$BRIDGE" ]; then BRIDGE_TS=$(jq -r '.timestamp // 0' "$BRIDGE" 2>/dev/null) + [[ "$BRIDGE_TS" =~ ^[0-9]+$ ]] || BRIDGE_TS=0 NOW_MS=$((NOW_SEC * 1000)) AGE_MS=$((NOW_MS - BRIDGE_TS)) # Skip if bridge was updated recently (< 60s) @@ -194,11 +199,9 @@ while true; do "$PR_SCRIPT" 2>/dev/null || true fi - # Light refresh: music if configured - SERVICE=$(jq -r '.background.service // "off"' "$CONFIG" 2>/dev/null) - if [ "$SERVICE" != "off" ]; then - timeout 30 claude -p --chrome --allowedTools "Read,Write,Bash,mcp__claude-in-chrome__*" -- \ - "Read and follow the hub-refresh-music skill at $MUSIC_SKILL" 2>/dev/null || true + # Light refresh: music if configured (zero-token, uses OS-level APIs) + if [ -x "$PLUGIN_ROOT/bin/refresh-music.sh" ]; then + "$PLUGIN_ROOT/bin/refresh-music.sh" 2>/dev/null || true fi # Light refresh: check if focus mode needs break reminder diff --git a/plugins/claude-status-hub/bin/refresh-music.sh b/plugins/claude-status-hub/bin/refresh-music.sh new file mode 100755 index 0000000..e268d36 --- /dev/null +++ b/plugins/claude-status-hub/bin/refresh-music.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# Zero-token music status refresh - replaces claude -p call in light cycle +# Uses macOS native APIs (osascript, nowplaying-cli) instead of Chrome MCP +# Falls back silently - full refresh cycle handles detection via Chrome MCP + +CONFIG="$HOME/.claude/status-config.json" +BRIDGE="/tmp/status-hub.json" +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(dirname "$(dirname "$0")")}" + +# Read music service config +[ -f "$CONFIG" ] || exit 0 +SERVICE=$(jq -r '.background.service // "off"' "$CONFIG" 2>/dev/null) +[ "$SERVICE" = "off" ] && exit 0 + +TITLE="" +ARTIST="" +IS_PLAYING=false + +case "$SERVICE" in + spotify) + # Native Spotify.app AppleScript (no browser needed) + if pgrep -xq "Spotify"; then + TITLE=$(osascript -e 'tell application "Spotify" to name of current track' 2>/dev/null || echo "") + ARTIST=$(osascript -e 'tell application "Spotify" to artist of current track' 2>/dev/null || echo "") + STATE=$(osascript -e 'tell application "Spotify" to player state as string' 2>/dev/null || echo "") + [ "$STATE" = "playing" ] && IS_PLAYING=true + fi + ;; + apple-music) + # Native Music.app AppleScript + if pgrep -xq "Music"; then + TITLE=$(osascript -e 'tell application "Music" to name of current track' 2>/dev/null || echo "") + ARTIST=$(osascript -e 'tell application "Music" to artist of current track' 2>/dev/null || echo "") + STATE=$(osascript -e 'tell application "Music" to player state as string' 2>/dev/null || echo "") + [ "$STATE" = "playing" ] && IS_PLAYING=true + fi + ;; + youtube-music) + # Browser-based - use nowplaying-cli if available + if command -v nowplaying-cli &>/dev/null; then + TITLE=$(nowplaying-cli get title 2>/dev/null || echo "") + ARTIST=$(nowplaying-cli get artist 2>/dev/null || echo "") + RATE=$(nowplaying-cli get playbackRate 2>/dev/null || echo "0") + [ "$RATE" != "0" ] && [ "$RATE" != "" ] && IS_PLAYING=true + fi + # If nowplaying-cli not available, exit silently - full refresh handles it + ;; + *) + # Unknown service - try nowplaying-cli as generic fallback + if command -v nowplaying-cli &>/dev/null; then + TITLE=$(nowplaying-cli get title 2>/dev/null || echo "") + ARTIST=$(nowplaying-cli get artist 2>/dev/null || echo "") + RATE=$(nowplaying-cli get playbackRate 2>/dev/null || echo "0") + [ "$RATE" != "0" ] && [ "$RATE" != "" ] && IS_PLAYING=true + fi + ;; +esac + +# Nothing detected - exit silently (full refresh will handle it) +[ -z "$TITLE" ] && exit 0 + +# Determine icon +ICON="⏸" +[ "$IS_PLAYING" = true ] && ICON="▶" + +# Truncate for statusline display +TITLE="${TITLE:0:30}" +ARTIST="${ARTIST:0:25}" + +# Skip-if-unchanged: compare with current bridge data +if [ -f "$BRIDGE" ]; then + CURRENT_TITLE=$(jq -r '.background.title // ""' "$BRIDGE" 2>/dev/null) + CURRENT_DETAIL=$(jq -r '.background.detail // ""' "$BRIDGE" 2>/dev/null) + CURRENT_ICON=$(jq -r '.background.icon // ""' "$BRIDGE" 2>/dev/null) + + if [ "$TITLE" = "$CURRENT_TITLE" ] && [ "$ARTIST" = "$CURRENT_DETAIL" ] && [ "$ICON" = "$CURRENT_ICON" ]; then + exit 0 + fi +fi + +# Update bridge with new music data (preserves foreground) +"$PLUGIN_ROOT/bin/update-bridge.sh" "$SERVICE" "$ICON" "$TITLE" "$ARTIST" diff --git a/plugins/claude-status-hub/skills/hub-music.md b/plugins/claude-status-hub/skills/hub-music.md index d413483..5f711f2 100644 --- a/plugins/claude-status-hub/skills/hub-music.md +++ b/plugins/claude-status-hub/skills/hub-music.md @@ -168,4 +168,5 @@ jq --argjson config "$NEW_CONFIG" '.music.contextual = $config' \ ## Requirements - Music service must be configured (`/hub-setup` → background service) -- Chrome MCP must be available for browser automation +- Chrome MCP must be available for browser automation (playback control) +- For browser-based players (YouTube Music): `brew install nowplaying-cli` recommended for zero-token status detection diff --git a/plugins/claude-status-hub/tests/test-music-refresh.sh b/plugins/claude-status-hub/tests/test-music-refresh.sh new file mode 100755 index 0000000..f99b106 --- /dev/null +++ b/plugins/claude-status-hub/tests/test-music-refresh.sh @@ -0,0 +1,411 @@ +#!/bin/bash +# Test refresh-music.sh functionality +# Tests OS-level detection, skip-if-unchanged, and fallback behavior + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BIN_DIR="$(dirname "$SCRIPT_DIR")/bin" +SCRIPT="$BIN_DIR/refresh-music.sh" + +# Production paths +BRIDGE="/tmp/status-hub.json" +CONFIG="$HOME/.claude/status-config.json" + +# Backup existing files +BACKUP_BRIDGE="" +BACKUP_CONFIG="" +if [ -f "$BRIDGE" ]; then BACKUP_BRIDGE=$(cat "$BRIDGE"); fi +if [ -f "$CONFIG" ]; then BACKUP_CONFIG=$(cat "$CONFIG"); fi + +# Create mock bin directory +MOCK_BIN="/tmp/test-mock-bin-$$" +mkdir -p "$MOCK_BIN" + +# Ensure config directory exists +mkdir -p "$(dirname "$CONFIG")" + +cleanup() { + rm -rf "$MOCK_BIN" + rm -f "$BRIDGE" + if [ -n "$BACKUP_BRIDGE" ]; then + echo "$BACKUP_BRIDGE" > "$BRIDGE" + else + rm -f "$BRIDGE" + fi + if [ -n "$BACKUP_CONFIG" ]; then + echo "$BACKUP_CONFIG" > "$CONFIG" + else + rm -f "$CONFIG" + fi +} +trap cleanup EXIT + +TESTS_RUN=0 +TESTS_PASSED=0 +TESTS_FAILED=0 + +pass() { + TESTS_PASSED=$((TESTS_PASSED + 1)) + echo " PASS: $1" +} + +fail() { + TESTS_FAILED=$((TESTS_FAILED + 1)) + echo " FAIL: $1" + echo " Expected: $2" + echo " Got: $3" +} + +# Create a mock osascript that returns predefined values +create_mock_osascript() { + local title="$1" + local artist="$2" + local state="$3" # "playing" or "paused" + local app="$4" # "Spotify" or "Music" + + cat > "$MOCK_BIN/osascript" << ENDSCRIPT +#!/bin/bash +# Mock osascript +case "\$*" in + *"name of current track"*) echo "$title" ;; + *"artist of current track"*) echo "$artist" ;; + *"player state"*) echo "$state" ;; + *) echo "" ;; +esac +ENDSCRIPT + chmod +x "$MOCK_BIN/osascript" +} + +# Create a mock pgrep that reports app as running +create_mock_pgrep() { + local app="$1" + cat > "$MOCK_BIN/pgrep" << ENDSCRIPT +#!/bin/bash +# Mock pgrep - report $app as running +if [[ "\$*" == *"$app"* ]]; then + echo "12345" + exit 0 +fi +exit 1 +ENDSCRIPT + chmod +x "$MOCK_BIN/pgrep" +} + +# Create a mock nowplaying-cli +create_mock_nowplaying() { + local title="$1" + local artist="$2" + local rate="$3" # "1" = playing, "0" = paused + + cat > "$MOCK_BIN/nowplaying-cli" << ENDSCRIPT +#!/bin/bash +# Mock nowplaying-cli +case "\$2" in + title) echo "$title" ;; + artist) echo "$artist" ;; + playbackRate) echo "$rate" ;; + *) echo "" ;; +esac +ENDSCRIPT + chmod +x "$MOCK_BIN/nowplaying-cli" +} + +setup_config() { + local service="$1" + cat > "$CONFIG" << EOF +{ + "background": { + "service": "$service" + } +} +EOF +} + +setup_bridge() { + local icon="$1" + local title="$2" + local detail="$3" + local site="${4:-$SERVICE}" + cat > "$BRIDGE" << EOF +{ + "timestamp": 1000, + "lastActivity": 1000, + "background": { + "site": "$site", + "icon": "$icon", + "title": "$title", + "detail": "$detail" + }, + "foreground": [{"service":"test","icon":"T","title":"Test","detail":"item"}] +} +EOF +} + +# Run with mocked PATH +run_refresh() { + PATH="$MOCK_BIN:$PATH" "$SCRIPT" +} + +echo "=== Testing refresh-music.sh ===" +echo "" + +echo "Testing service=off exits early..." + +# Test: service=off exits without touching bridge +TESTS_RUN=$((TESTS_RUN + 1)) +setup_config "off" +rm -f "$BRIDGE" +run_refresh +if [ ! -f "$BRIDGE" ]; then + pass "service=off exits without creating bridge" +else + fail "service=off early exit" "no bridge" "bridge created" +fi + +echo "" +echo "Testing no config exits early..." + +# Test: missing config exits +TESTS_RUN=$((TESTS_RUN + 1)) +rm -f "$CONFIG" "$BRIDGE" +run_refresh +if [ ! -f "$BRIDGE" ]; then + pass "missing config exits without creating bridge" +else + fail "missing config exit" "no bridge" "bridge created" +fi + +echo "" +echo "Testing Spotify detection..." + +# Test: Spotify playing → updates bridge with ▶ +TESTS_RUN=$((TESTS_RUN + 1)) +setup_config "spotify" +rm -f "$BRIDGE" +create_mock_pgrep "Spotify" +create_mock_osascript "Bohemian Rhapsody" "Queen" "playing" "Spotify" +run_refresh +if [ -f "$BRIDGE" ]; then + icon=$(jq -r '.background.icon' "$BRIDGE") + title=$(jq -r '.background.title' "$BRIDGE") + detail=$(jq -r '.background.detail' "$BRIDGE") + if [ "$icon" = "▶" ] && [ "$title" = "Bohemian Rhapsody" ] && [ "$detail" = "Queen" ]; then + pass "Spotify playing → ▶ Bohemian Rhapsody / Queen" + else + fail "Spotify playing" "▶ Bohemian Rhapsody / Queen" "$icon $title / $detail" + fi +else + fail "Spotify playing" "bridge created" "no bridge" +fi + +# Test: Spotify paused → updates bridge with ⏸ +TESTS_RUN=$((TESTS_RUN + 1)) +setup_config "spotify" +rm -f "$BRIDGE" +create_mock_pgrep "Spotify" +create_mock_osascript "Bohemian Rhapsody" "Queen" "paused" "Spotify" +run_refresh +if [ -f "$BRIDGE" ]; then + icon=$(jq -r '.background.icon' "$BRIDGE") + if [ "$icon" = "⏸" ]; then + pass "Spotify paused → ⏸ icon" + else + fail "Spotify paused" "⏸" "$icon" + fi +else + fail "Spotify paused" "bridge created" "no bridge" +fi + +# Test: Spotify not running → exits silently +TESTS_RUN=$((TESTS_RUN + 1)) +setup_config "spotify" +rm -f "$BRIDGE" +# Mock pgrep to say Spotify is NOT running +cat > "$MOCK_BIN/pgrep" << 'ENDSCRIPT' +#!/bin/bash +exit 1 +ENDSCRIPT +chmod +x "$MOCK_BIN/pgrep" +run_refresh +if [ ! -f "$BRIDGE" ]; then + pass "Spotify not running → silent exit" +else + fail "Spotify not running" "no bridge" "bridge created" +fi + +echo "" +echo "Testing Apple Music detection..." + +# Test: Apple Music playing → updates bridge +TESTS_RUN=$((TESTS_RUN + 1)) +setup_config "apple-music" +rm -f "$BRIDGE" +create_mock_pgrep "Music" +create_mock_osascript "Let It Be" "The Beatles" "playing" "Music" +run_refresh +if [ -f "$BRIDGE" ]; then + icon=$(jq -r '.background.icon' "$BRIDGE") + title=$(jq -r '.background.title' "$BRIDGE") + if [ "$icon" = "▶" ] && [ "$title" = "Let It Be" ]; then + pass "Apple Music playing → ▶ Let It Be" + else + fail "Apple Music playing" "▶ Let It Be" "$icon $title" + fi +else + fail "Apple Music playing" "bridge created" "no bridge" +fi + +echo "" +echo "Testing YouTube Music (nowplaying-cli)..." + +# Test: YouTube Music with nowplaying-cli → updates bridge +TESTS_RUN=$((TESTS_RUN + 1)) +setup_config "youtube-music" +rm -f "$BRIDGE" +create_mock_nowplaying "Never Gonna Give You Up" "Rick Astley" "1" +run_refresh +if [ -f "$BRIDGE" ]; then + icon=$(jq -r '.background.icon' "$BRIDGE") + title=$(jq -r '.background.title' "$BRIDGE") + detail=$(jq -r '.background.detail' "$BRIDGE") + if [ "$icon" = "▶" ] && [ "$title" = "Never Gonna Give You Up" ] && [ "$detail" = "Rick Astley" ]; then + pass "YouTube Music (nowplaying-cli) → ▶ Never Gonna Give You Up / Rick Astley" + else + fail "YouTube Music nowplaying" "▶ Never Gonna Give You Up / Rick Astley" "$icon $title / $detail" + fi +else + fail "YouTube Music nowplaying" "bridge created" "no bridge" +fi + +# Test: YouTube Music without nowplaying-cli → silent exit +TESTS_RUN=$((TESTS_RUN + 1)) +setup_config "youtube-music" +rm -f "$BRIDGE" +rm -f "$MOCK_BIN/nowplaying-cli" +# Restrict PATH so no real nowplaying-cli is found +PATH="$MOCK_BIN:/usr/bin:/bin" "$SCRIPT" +if [ ! -f "$BRIDGE" ]; then + pass "YouTube Music without nowplaying-cli → silent exit" +else + fail "YouTube Music no CLI" "no bridge" "bridge created" +fi + +echo "" +echo "Testing skip-if-unchanged..." + +# Test: Same data → skips bridge write +TESTS_RUN=$((TESTS_RUN + 1)) +SERVICE="spotify" +setup_config "spotify" +setup_bridge "▶" "Bohemian Rhapsody" "Queen" "spotify" +ORIGINAL_TS=$(jq -r '.timestamp' "$BRIDGE") +create_mock_pgrep "Spotify" +create_mock_osascript "Bohemian Rhapsody" "Queen" "playing" "Spotify" +run_refresh +NEW_TS=$(jq -r '.timestamp' "$BRIDGE") +if [ "$ORIGINAL_TS" = "$NEW_TS" ]; then + pass "Unchanged music → skips bridge write" +else + fail "Skip-if-unchanged" "timestamp $ORIGINAL_TS" "timestamp $NEW_TS" +fi + +# Test: Different song → updates bridge +TESTS_RUN=$((TESTS_RUN + 1)) +setup_config "spotify" +setup_bridge "▶" "Old Song" "Old Artist" "spotify" +ORIGINAL_TS=$(jq -r '.timestamp' "$BRIDGE") +create_mock_pgrep "Spotify" +create_mock_osascript "New Song" "New Artist" "playing" "Spotify" +sleep 1 # Ensure timestamp differs +run_refresh +NEW_TS=$(jq -r '.timestamp' "$BRIDGE") +new_title=$(jq -r '.background.title' "$BRIDGE") +if [ "$new_title" = "New Song" ] && [ "$NEW_TS" != "$ORIGINAL_TS" ]; then + pass "Changed song → updates bridge" +else + fail "Changed song update" "New Song with new timestamp" "$new_title ts=$NEW_TS" +fi + +# Test: Same song but icon changed (playing→paused) → updates bridge +TESTS_RUN=$((TESTS_RUN + 1)) +setup_config "spotify" +setup_bridge "▶" "Same Song" "Same Artist" "spotify" +create_mock_pgrep "Spotify" +create_mock_osascript "Same Song" "Same Artist" "paused" "Spotify" +sleep 1 +run_refresh +new_icon=$(jq -r '.background.icon' "$BRIDGE") +if [ "$new_icon" = "⏸" ]; then + pass "Play state change → updates bridge" +else + fail "Play state change" "⏸" "$new_icon" +fi + +echo "" +echo "Testing foreground preservation..." + +# Test: Bridge update preserves foreground items +TESTS_RUN=$((TESTS_RUN + 1)) +setup_config "spotify" +setup_bridge "⏸" "Old" "Old" "spotify" +create_mock_pgrep "Spotify" +create_mock_osascript "New Track" "New Artist" "playing" "Spotify" +sleep 1 +run_refresh +fg_count=$(jq '.foreground | length' "$BRIDGE") +fg_title=$(jq -r '.foreground[0].title' "$BRIDGE") +if [ "$fg_count" = "1" ] && [ "$fg_title" = "Test" ]; then + pass "Foreground items preserved after music update" +else + fail "Foreground preservation" "1 item with title=Test" "$fg_count items, title=$fg_title" +fi + +echo "" +echo "Testing title truncation..." + +# Test: Long title gets truncated to 30 chars +TESTS_RUN=$((TESTS_RUN + 1)) +LONG_TITLE="This Is A Very Long Song Title That Exceeds Thirty Characters" +setup_config "spotify" +rm -f "$BRIDGE" +create_mock_pgrep "Spotify" +create_mock_osascript "$LONG_TITLE" "Artist" "playing" "Spotify" +run_refresh +title=$(jq -r '.background.title' "$BRIDGE") +title_len=${#title} +if [ "$title_len" -le 30 ]; then + pass "Long title truncated to ${title_len} chars (max 30)" +else + fail "Title truncation" "<=30 chars" "$title_len chars" +fi + +echo "" +echo "Testing generic fallback..." + +# Test: Unknown service with nowplaying-cli → works +TESTS_RUN=$((TESTS_RUN + 1)) +setup_config "tidal" +rm -f "$BRIDGE" +create_mock_nowplaying "Ocean Eyes" "Billie Eilish" "1" +run_refresh +if [ -f "$BRIDGE" ]; then + title=$(jq -r '.background.title' "$BRIDGE") + if [ "$title" = "Ocean Eyes" ]; then + pass "Unknown service with nowplaying-cli → works as fallback" + else + fail "Generic fallback" "Ocean Eyes" "$title" + fi +else + fail "Generic fallback" "bridge created" "no bridge" +fi + +echo "" +echo "=== Results ===" +echo "Tests run: $TESTS_RUN" +echo "Passed: $TESTS_PASSED" +echo "Failed: $TESTS_FAILED" + +if [ "$TESTS_FAILED" -gt 0 ]; then + exit 1 +fi