diff --git a/.github/workflows/CLAUDE.md b/.github/workflows/CLAUDE.md
new file mode 100644
index 0000000..ed1ea43
--- /dev/null
+++ b/.github/workflows/CLAUDE.md
@@ -0,0 +1,11 @@
+
+# Recent Activity
+
+
+
+### Jan 13, 2026
+
+| ID | Time | T | Title | Read |
+|----|------|---|-------|------|
+| #167 | 10:49 PM | 🟣 | Test Automation Orchestrator for Themer-Up | ~817 |
+
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..d3b8752
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,206 @@
+name: CI Pipeline
+
+on:
+ push:
+ branches: [main, develop]
+ pull_request:
+ branches: [main]
+
+# Cancel in-progress runs for the same branch
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ BATS_VERSION: "1.10.0"
+
+jobs:
+ # ============================================
+ # Stage 1: Static Analysis (runs in parallel)
+ # ============================================
+
+ shellcheck:
+ name: ShellCheck Linting
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Run ShellCheck
+ uses: ludeeus/action-shellcheck@master
+ with:
+ scandir: './scripts'
+ format: gcc
+ severity: warning
+
+ validate-json:
+ name: Validate JSON Configs
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Validate theme JSON files
+ run: |
+ echo "Validating JSON files..."
+ for file in themes/*/iterm2.json themes/*/vscode.json themes/*/theme.json; do
+ if [[ -f "$file" ]]; then
+ echo "Checking $file"
+ python3 -m json.tool "$file" > /dev/null || exit 1
+ fi
+ done
+ echo "All JSON files valid!"
+
+ validate-yaml:
+ name: Validate YAML Configs
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Install yamllint
+ run: pip install yamllint
+
+ - name: Validate YAML files
+ run: |
+ for file in themes/*/*.yaml; do
+ if [[ -f "$file" ]]; then
+ echo "Checking $file"
+ yamllint -d relaxed "$file" || exit 1
+ fi
+ done
+ echo "All YAML files valid!"
+
+ # ============================================
+ # Stage 2: Unit Tests (parallel by script)
+ # ============================================
+
+ unit-tests:
+ name: Unit Tests
+ runs-on: macos-latest
+ needs: [shellcheck] # Only run if linting passes
+ strategy:
+ fail-fast: false
+ matrix:
+ test-file:
+ - backup
+ - restore
+ - apply
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Cache BATS installation
+ uses: actions/cache@v4
+ id: bats-cache
+ with:
+ path: ~/.local/bats
+ key: bats-${{ env.BATS_VERSION }}-macos
+
+ - name: Install BATS
+ if: steps.bats-cache.outputs.cache-hit != 'true'
+ run: |
+ git clone https://github.com/bats-core/bats-core.git
+ cd bats-core
+ ./install.sh ~/.local/bats
+
+ - name: Add BATS to PATH
+ run: echo "$HOME/.local/bats/bin" >> "$GITHUB_PATH"
+
+ - name: Run unit tests
+ env:
+ TEST_FILE: ${{ matrix.test-file }}
+ run: |
+ bats "tests/unit/${TEST_FILE}.bats" --tap
+
+ # ============================================
+ # Stage 3: Integration Tests (after unit tests)
+ # ============================================
+
+ integration-tests:
+ name: Integration Tests
+ runs-on: macos-latest
+ needs: [unit-tests] # Only run if unit tests pass
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Cache BATS installation
+ uses: actions/cache@v4
+ id: bats-cache
+ with:
+ path: ~/.local/bats
+ key: bats-${{ env.BATS_VERSION }}-macos
+
+ - name: Install BATS
+ if: steps.bats-cache.outputs.cache-hit != 'true'
+ run: |
+ git clone https://github.com/bats-core/bats-core.git
+ cd bats-core
+ ./install.sh ~/.local/bats
+
+ - name: Add BATS to PATH
+ run: echo "$HOME/.local/bats/bin" >> "$GITHUB_PATH"
+
+ - name: Run integration tests
+ run: |
+ bats tests/integration/*.bats --tap
+
+ # ============================================
+ # Stage 4: Theme Validation (parallel check)
+ # ============================================
+
+ theme-validation:
+ name: Validate Themes
+ runs-on: ubuntu-latest
+ needs: [validate-json, validate-yaml]
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Check theme completeness
+ run: |
+ REQUIRED_FILES=("iterm2.json" "p10k.zsh" "mprocs.yaml" "theme.json")
+
+ for theme_dir in themes/*/; do
+ theme_name=$(basename "$theme_dir")
+ echo "Validating theme: $theme_name"
+
+ for file in "${REQUIRED_FILES[@]}"; do
+ if [[ ! -f "${theme_dir}${file}" ]]; then
+ echo "ERROR: Missing $file in $theme_name"
+ exit 1
+ fi
+ done
+
+ echo " All required files present"
+ done
+
+ # ============================================
+ # Final: Summary Job
+ # ============================================
+
+ ci-success:
+ name: CI Success
+ runs-on: ubuntu-latest
+ needs: [shellcheck, validate-json, validate-yaml, unit-tests, integration-tests, theme-validation]
+ if: always()
+ steps:
+ - name: Check all jobs passed
+ env:
+ SHELLCHECK_RESULT: ${{ needs.shellcheck.result }}
+ JSON_RESULT: ${{ needs.validate-json.result }}
+ YAML_RESULT: ${{ needs.validate-yaml.result }}
+ UNIT_RESULT: ${{ needs.unit-tests.result }}
+ INTEGRATION_RESULT: ${{ needs.integration-tests.result }}
+ THEME_RESULT: ${{ needs.theme-validation.result }}
+ run: |
+ if [[ "$SHELLCHECK_RESULT" != "success" ]] || \
+ [[ "$JSON_RESULT" != "success" ]] || \
+ [[ "$YAML_RESULT" != "success" ]] || \
+ [[ "$UNIT_RESULT" != "success" ]] || \
+ [[ "$INTEGRATION_RESULT" != "success" ]] || \
+ [[ "$THEME_RESULT" != "success" ]]; then
+ echo "One or more jobs failed"
+ exit 1
+ fi
+ echo "All CI checks passed!"
diff --git a/.shellcheckrc b/.shellcheckrc
new file mode 100644
index 0000000..038a813
--- /dev/null
+++ b/.shellcheckrc
@@ -0,0 +1,18 @@
+# ShellCheck configuration for themer-up
+# https://www.shellcheck.net/wiki/
+
+# Set severity to warning (ignore style/info suggestions)
+# Style issues (SC2250, SC2292) are valid suggestions but not errors
+severity=warning
+
+# Exclude warnings that don't apply to our use case
+# SC2312: Consider invoking this command separately (we handle exit codes)
+# SC2012: Use find instead of ls (our use case is controlled)
+# SC2129: Consider using grouped redirects (readability preference)
+exclude=SC2312,SC2012,SC2129
+
+# Set default shell dialect
+shell=bash
+
+# External sources (for sourced files)
+external-sources=true
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..b1f1964
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,126 @@
+# Themer-Up Makefile
+# Orchestrates testing, linting, and development tasks
+
+.PHONY: all test test-unit test-integration lint clean install-deps help
+
+# Default target
+all: lint test
+
+# ─────────────────────────────────────────────────────
+# Testing Targets
+# ─────────────────────────────────────────────────────
+
+## Run all tests (linting + unit + integration)
+test: lint test-unit test-integration
+
+## Run unit tests only (fast feedback)
+test-unit:
+ @echo "Running unit tests..."
+ @bats tests/unit/*.bats --tap
+
+## Run integration tests only
+test-integration:
+ @echo "Running integration tests..."
+ @bats tests/integration/*.bats --tap
+
+## Run tests in parallel (requires GNU parallel)
+test-parallel:
+ @./scripts/test.sh --parallel
+
+## Run tests with verbose output
+test-verbose:
+ @./scripts/test.sh --verbose
+
+## Run fast tests only (skip integration)
+test-fast:
+ @./scripts/test.sh --fast
+
+# ─────────────────────────────────────────────────────
+# Linting Targets
+# ─────────────────────────────────────────────────────
+
+## Run shellcheck on all scripts
+lint:
+ @./scripts/lint.sh
+
+## Validate all JSON config files
+lint-json:
+ @echo "Validating JSON files..."
+ @for f in themes/*/iterm2.json themes/*/vscode.json themes/*/theme.json; do \
+ if [ -f "$$f" ]; then python3 -m json.tool "$$f" > /dev/null && echo " ✓ $$f"; fi; \
+ done
+
+## Validate all YAML config files
+lint-yaml:
+ @echo "Validating YAML files..."
+ @for f in themes/*/*.yaml; do \
+ if [ -f "$$f" ]; then yamllint -d relaxed "$$f" && echo " ✓ $$f"; fi; \
+ done
+
+# ─────────────────────────────────────────────────────
+# Development Targets
+# ─────────────────────────────────────────────────────
+
+## Install development dependencies
+install-deps:
+ @echo "Installing dependencies..."
+ @which bats > /dev/null || brew install bats-core
+ @which shellcheck > /dev/null || brew install shellcheck
+ @which yamllint > /dev/null || pip install yamllint
+ @echo "Done!"
+
+## Apply synthwave theme (default)
+apply:
+ @./scripts/apply.sh synthwave
+
+## Create a backup of current configs
+backup:
+ @./scripts/backup.sh
+
+## Restore from latest backup
+restore:
+ @./scripts/restore.sh
+
+## Clean temporary test files
+clean:
+ @echo "Cleaning temporary files..."
+ @rm -rf /tmp/themer-test-*
+ @rm -f /tmp/bats_output
+ @echo "Done!"
+
+# ─────────────────────────────────────────────────────
+# CI Simulation
+# ─────────────────────────────────────────────────────
+
+## Simulate full CI pipeline locally
+ci: lint lint-json test-unit test-integration
+ @echo ""
+ @echo "✅ CI simulation passed!"
+
+# ─────────────────────────────────────────────────────
+# Help
+# ─────────────────────────────────────────────────────
+
+## Show this help message
+help:
+ @echo "Themer-Up Development Commands"
+ @echo "══════════════════════════════"
+ @echo ""
+ @grep -E '^##' Makefile | sed 's/## / /'
+ @echo ""
+ @echo "Usage: make "
+ @echo ""
+ @echo "Testing:"
+ @echo " make test - Run all tests"
+ @echo " make test-unit - Run unit tests only"
+ @echo " make test-fast - Skip integration tests"
+ @echo " make test-parallel - Run tests in parallel"
+ @echo ""
+ @echo "Linting:"
+ @echo " make lint - Run shellcheck"
+ @echo " make lint-json - Validate JSON files"
+ @echo ""
+ @echo "Development:"
+ @echo " make install-deps - Install dev dependencies"
+ @echo " make apply - Apply synthwave theme"
+ @echo " make ci - Simulate CI locally"
diff --git a/scripts/apply.sh b/scripts/apply.sh
index 3516368..ba8b732 100755
--- a/scripts/apply.sh
+++ b/scripts/apply.sh
@@ -3,7 +3,8 @@
set -e
-THEMER_DIR="$HOME/dev/themer-up"
+# Allow override via environment variable for testing and custom installs
+THEMER_DIR="${THEMER_DIR:-$HOME/dev/themer-up}"
THEME_NAME="${1:-synthwave}"
THEME_DIR="$THEMER_DIR/themes/$THEME_NAME"
diff --git a/scripts/backup.sh b/scripts/backup.sh
index 6907fc3..b262ea8 100755
--- a/scripts/backup.sh
+++ b/scripts/backup.sh
@@ -1,7 +1,9 @@
#!/bin/bash
# Backup current theme configs before applying new theme
-BACKUP_DIR="$HOME/.themer-up-backup/$(date +%Y%m%d_%H%M%S)"
+# Support custom backup location via environment variable
+BACKUP_BASE="${THEMER_BACKUP_DIR:-$HOME/.themer-up-backup}"
+BACKUP_DIR="${BACKUP_BASE}/$(date +%Y%m%d_%H%M%S)"
mkdir -p "$BACKUP_DIR"
echo "Backing up to: $BACKUP_DIR"
@@ -43,6 +45,6 @@ if [ -f "$HOME/.tmux.conf" ]; then
fi
# Keep only last 5 backups
-ls -dt "$HOME/.themer-up-backup"/*/ 2>/dev/null | tail -n +6 | xargs rm -rf 2>/dev/null || true
+ls -dt "${BACKUP_BASE}"/*/ 2>/dev/null | tail -n +6 | xargs rm -rf 2>/dev/null || true
echo "Backup complete."
diff --git a/scripts/lint.sh b/scripts/lint.sh
new file mode 100755
index 0000000..0511f60
--- /dev/null
+++ b/scripts/lint.sh
@@ -0,0 +1,61 @@
+#!/bin/bash
+# Run shellcheck on all shell scripts
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+
+# Colors
+GREEN='\033[0;32m'
+RED='\033[0;31m'
+CYAN='\033[0;36m'
+NC='\033[0m'
+
+echo -e "${CYAN}Running ShellCheck on themer-up scripts...${NC}"
+echo ""
+
+# Find all shell scripts
+scripts=(
+ "$PROJECT_DIR/scripts/apply.sh"
+ "$PROJECT_DIR/scripts/backup.sh"
+ "$PROJECT_DIR/scripts/restore.sh"
+)
+
+# Also check test files
+test_files=()
+while IFS= read -r -d '' file; do
+ test_files+=("$file")
+done < <(find "$PROJECT_DIR/tests" -name "*.bash" -print0 2>/dev/null)
+
+all_files=("${scripts[@]}" "${test_files[@]}")
+
+failed=0
+passed=0
+
+for file in "${all_files[@]}"; do
+ if [[ -f "$file" ]]; then
+ echo -n "Checking $(basename "$file")... "
+ # Use -S warning to only fail on warnings/errors, not style/info
+ if shellcheck -S warning "$file" 2>/dev/null; then
+ echo -e "${GREEN}OK${NC}"
+ ((passed++))
+ else
+ echo -e "${RED}FAILED${NC}"
+ ((failed++))
+ # Show details
+ shellcheck -S warning "$file" || true
+ fi
+ fi
+done
+
+echo ""
+echo "────────────────────────────"
+echo -e "Passed: ${GREEN}${passed}${NC}"
+echo -e "Failed: ${RED}${failed}${NC}"
+
+if [[ $failed -gt 0 ]]; then
+ exit 1
+fi
+
+echo -e "${GREEN}All checks passed!${NC}"
diff --git a/scripts/restore.sh b/scripts/restore.sh
index caee899..3e9e882 100755
--- a/scripts/restore.sh
+++ b/scripts/restore.sh
@@ -1,7 +1,9 @@
#!/bin/bash
# Restore from the most recent backup
-BACKUP_DIR=$(ls -dt "$HOME/.themer-up-backup"/*/ 2>/dev/null | head -1)
+# Support custom backup location via environment variable
+BACKUP_BASE="${THEMER_BACKUP_DIR:-$HOME/.themer-up-backup}"
+BACKUP_DIR=$(ls -dt "${BACKUP_BASE}"/*/ 2>/dev/null | head -1)
if [ -z "$BACKUP_DIR" ]; then
echo "No backups found."
diff --git a/scripts/test.sh b/scripts/test.sh
new file mode 100755
index 0000000..b0def14
--- /dev/null
+++ b/scripts/test.sh
@@ -0,0 +1,190 @@
+#!/bin/bash
+# Test orchestration script - runs tests with intelligent execution
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+TESTS_DIR="$PROJECT_DIR/tests"
+
+# Colors
+GREEN='\033[0;32m'
+RED='\033[0;31m'
+CYAN='\033[0;36m'
+YELLOW='\033[0;33m'
+NC='\033[0m'
+
+# Parse arguments
+PARALLEL=false
+FAST=false
+VERBOSE=false
+
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ -p|--parallel)
+ PARALLEL=true
+ shift
+ ;;
+ -f|--fast)
+ FAST=true
+ shift
+ ;;
+ -v|--verbose)
+ VERBOSE=true
+ shift
+ ;;
+ -h|--help)
+ echo "Usage: $0 [OPTIONS]"
+ echo ""
+ echo "Options:"
+ echo " -p, --parallel Run unit tests in parallel"
+ echo " -f, --fast Skip integration tests (unit tests only)"
+ echo " -v, --verbose Verbose output"
+ echo " -h, --help Show this help message"
+ exit 0
+ ;;
+ *)
+ echo "Unknown option: $1"
+ exit 1
+ ;;
+ esac
+done
+
+# Check for BATS
+if ! command -v bats &> /dev/null; then
+ echo -e "${RED}Error: BATS not found${NC}"
+ echo "Install with: brew install bats-core"
+ exit 1
+fi
+
+echo -e "${CYAN}╔════════════════════════════════════════╗${NC}"
+echo -e "${CYAN}║ Themer-Up Test Orchestrator ║${NC}"
+echo -e "${CYAN}╚════════════════════════════════════════╝${NC}"
+echo ""
+
+# Track timing
+START_TIME=$(date +%s)
+
+# ─────────────────────────────────────────────────────
+# Stage 1: Static Analysis
+# ─────────────────────────────────────────────────────
+echo -e "${CYAN}Stage 1: Static Analysis${NC}"
+echo "────────────────────────────────────────"
+
+if command -v shellcheck &> /dev/null; then
+ echo -n " ShellCheck... "
+ if "$SCRIPT_DIR/lint.sh" > /dev/null 2>&1; then
+ echo -e "${GREEN}PASS${NC}"
+ else
+ echo -e "${RED}FAIL${NC}"
+ if $VERBOSE; then
+ "$SCRIPT_DIR/lint.sh"
+ fi
+ exit 1
+ fi
+else
+ echo -e " ShellCheck... ${YELLOW}SKIPPED${NC} (not installed)"
+fi
+
+# Validate JSON
+echo -n " JSON validation... "
+json_errors=0
+for file in "$PROJECT_DIR"/themes/*/iterm2.json "$PROJECT_DIR"/themes/*/vscode.json "$PROJECT_DIR"/themes/*/theme.json; do
+ if [[ -f "$file" ]]; then
+ if ! python3 -m json.tool "$file" > /dev/null 2>&1; then
+ ((json_errors++))
+ fi
+ fi
+done
+if [[ $json_errors -eq 0 ]]; then
+ echo -e "${GREEN}PASS${NC}"
+else
+ echo -e "${RED}FAIL${NC} ($json_errors files)"
+ exit 1
+fi
+
+echo ""
+
+# ─────────────────────────────────────────────────────
+# Stage 2: Unit Tests
+# ─────────────────────────────────────────────────────
+echo -e "${CYAN}Stage 2: Unit Tests${NC}"
+echo "────────────────────────────────────────"
+
+# Build bats options based on flags
+bats_opts="--tap"
+if $VERBOSE; then
+ bats_opts="--tap --verbose-run"
+fi
+
+unit_tests=(
+ "$TESTS_DIR/unit/backup.bats"
+ "$TESTS_DIR/unit/restore.bats"
+ "$TESTS_DIR/unit/apply.bats"
+)
+
+if $PARALLEL && command -v parallel &> /dev/null; then
+ echo " Running in parallel mode..."
+ printf '%s\n' "${unit_tests[@]}" | parallel -j 3 "bats {} $bats_opts" 2>&1 | while read -r line; do
+ echo " $line"
+ done
+else
+ for test_file in "${unit_tests[@]}"; do
+ if [[ -f "$test_file" ]]; then
+ test_name=$(basename "$test_file" .bats)
+ echo -n " $test_name... "
+ # shellcheck disable=SC2086
+ if bats "$test_file" $bats_opts > /tmp/bats_output 2>&1; then
+ passed=$(grep -c "^ok" /tmp/bats_output || echo 0)
+ echo -e "${GREEN}PASS${NC} ($passed tests)"
+ else
+ failed=$(grep -c "^not ok" /tmp/bats_output || echo 0)
+ echo -e "${RED}FAIL${NC} ($failed failed)"
+ if $VERBOSE; then
+ cat /tmp/bats_output
+ fi
+ exit 1
+ fi
+ fi
+ done
+fi
+
+echo ""
+
+# ─────────────────────────────────────────────────────
+# Stage 3: Integration Tests
+# ─────────────────────────────────────────────────────
+if ! $FAST; then
+ echo -e "${CYAN}Stage 3: Integration Tests${NC}"
+ echo "────────────────────────────────────────"
+
+ if [[ -f "$TESTS_DIR/integration/theme_workflow.bats" ]]; then
+ echo -n " theme_workflow... "
+ # shellcheck disable=SC2086
+ if bats "$TESTS_DIR/integration/theme_workflow.bats" $bats_opts > /tmp/bats_output 2>&1; then
+ passed=$(grep -c "^ok" /tmp/bats_output || echo 0)
+ echo -e "${GREEN}PASS${NC} ($passed tests)"
+ else
+ failed=$(grep -c "^not ok" /tmp/bats_output || echo 0)
+ echo -e "${RED}FAIL${NC} ($failed failed)"
+ if $VERBOSE; then
+ cat /tmp/bats_output
+ fi
+ exit 1
+ fi
+ fi
+
+ echo ""
+fi
+
+# ─────────────────────────────────────────────────────
+# Summary
+# ─────────────────────────────────────────────────────
+END_TIME=$(date +%s)
+DURATION=$((END_TIME - START_TIME))
+
+echo -e "${GREEN}╔════════════════════════════════════════╗${NC}"
+echo -e "${GREEN}║ All Tests Passed! ║${NC}"
+echo -e "${GREEN}╚════════════════════════════════════════╝${NC}"
+echo ""
+echo "Duration: ${DURATION}s"
diff --git a/tests/CLAUDE.md b/tests/CLAUDE.md
new file mode 100644
index 0000000..ed1ea43
--- /dev/null
+++ b/tests/CLAUDE.md
@@ -0,0 +1,11 @@
+
+# Recent Activity
+
+
+
+### Jan 13, 2026
+
+| ID | Time | T | Title | Read |
+|----|------|---|-------|------|
+| #167 | 10:49 PM | 🟣 | Test Automation Orchestrator for Themer-Up | ~817 |
+
\ No newline at end of file
diff --git a/tests/integration/CLAUDE.md b/tests/integration/CLAUDE.md
new file mode 100644
index 0000000..ed1ea43
--- /dev/null
+++ b/tests/integration/CLAUDE.md
@@ -0,0 +1,11 @@
+
+# Recent Activity
+
+
+
+### Jan 13, 2026
+
+| ID | Time | T | Title | Read |
+|----|------|---|-------|------|
+| #167 | 10:49 PM | 🟣 | Test Automation Orchestrator for Themer-Up | ~817 |
+
\ No newline at end of file
diff --git a/tests/integration/theme_workflow.bats b/tests/integration/theme_workflow.bats
new file mode 100644
index 0000000..8144b5d
--- /dev/null
+++ b/tests/integration/theme_workflow.bats
@@ -0,0 +1,149 @@
+#!/usr/bin/env bats
+# Integration tests for complete theme application workflow
+
+load '../test_helper'
+
+setup() {
+ setup_test_home
+ export THEMER_DIR="${BATS_TEST_DIRNAME}/../.."
+
+ # Create realistic initial state
+ echo '{"Name": "Default", "Guid": "default-profile"}' > "$TEST_HOME/Library/Application Support/iTerm2/DynamicProfiles/themer-up.json"
+ echo "# Original zshrc content" > "$TEST_HOME/.zshrc"
+ echo '" Original vimrc' > "$TEST_HOME/.vimrc"
+}
+
+teardown() {
+ teardown_test_home
+}
+
+# --- Full Workflow Tests ---
+
+@test "workflow: apply -> backup -> apply different -> restore returns to first" {
+ # Step 1: Apply synthwave
+ run "$SCRIPTS_DIR/apply.sh" "synthwave"
+ [ "$status" -eq 0 ]
+
+ # Capture what was applied
+ original_iterm=$(cat "$TEST_HOME/Library/Application Support/iTerm2/DynamicProfiles/themer-up.json")
+
+ # Step 2: Manually modify to simulate "different theme state"
+ echo '{"modified": true}' > "$TEST_HOME/Library/Application Support/iTerm2/DynamicProfiles/themer-up.json"
+
+ # Step 3: Backup current state
+ run "$SCRIPTS_DIR/backup.sh"
+ [ "$status" -eq 0 ]
+
+ # Step 4: Apply synthwave again (simulating re-apply)
+ run "$SCRIPTS_DIR/apply.sh" "synthwave"
+ [ "$status" -eq 0 ]
+
+ # Step 5: Restore should bring back the modified state
+ run "$SCRIPTS_DIR/restore.sh"
+ [ "$status" -eq 0 ]
+
+ restored=$(cat "$TEST_HOME/Library/Application Support/iTerm2/DynamicProfiles/themer-up.json")
+ [[ "$restored" == *"modified"* ]]
+}
+
+@test "workflow: multiple applies don't duplicate zshrc entries" {
+ run "$SCRIPTS_DIR/apply.sh" "synthwave"
+ [ "$status" -eq 0 ]
+
+ run "$SCRIPTS_DIR/apply.sh" "synthwave"
+ [ "$status" -eq 0 ]
+
+ run "$SCRIPTS_DIR/apply.sh" "synthwave"
+ [ "$status" -eq 0 ]
+
+ # Should only have one source line for p10k-themer
+ count=$(grep -c "p10k-themer.zsh" "$TEST_HOME/.zshrc" || echo 0)
+ [ "$count" -eq 1 ]
+}
+
+@test "workflow: backup rotation preserves recent backups" {
+ # Create initial state and apply
+ run "$SCRIPTS_DIR/apply.sh" "synthwave"
+ [ "$status" -eq 0 ]
+
+ # Run backup multiple times
+ for i in {1..7}; do
+ sleep 0.2 # Ensure unique timestamps
+ run "$SCRIPTS_DIR/backup.sh"
+ [ "$status" -eq 0 ]
+ done
+
+ # Should have at most 5 backups
+ backup_count=$(find "$TEST_BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')
+ [ "$backup_count" -le 5 ]
+}
+
+# --- Theme Validation Tests ---
+
+@test "theme: synthwave contains all required config files" {
+ theme_dir="${THEMER_DIR}/themes/synthwave"
+
+ assert_file_exists "${theme_dir}/iterm2.json"
+ assert_file_exists "${theme_dir}/p10k.zsh"
+ assert_file_exists "${theme_dir}/mprocs.yaml"
+ assert_file_exists "${theme_dir}/vscode.json"
+ assert_file_exists "${theme_dir}/theme.json"
+ assert_file_exists "${theme_dir}/vim.vim"
+ assert_file_exists "${theme_dir}/tmux.conf"
+}
+
+@test "theme: iterm2.json is valid JSON" {
+ theme_dir="${THEMER_DIR}/themes/synthwave"
+
+ run cat "${theme_dir}/iterm2.json"
+ [ "$status" -eq 0 ]
+
+ # Validate JSON
+ run bash -c "cat '${theme_dir}/iterm2.json' | python3 -m json.tool > /dev/null 2>&1"
+ [ "$status" -eq 0 ]
+}
+
+@test "theme: vscode.json is valid JSON" {
+ theme_dir="${THEMER_DIR}/themes/synthwave"
+
+ run bash -c "cat '${theme_dir}/vscode.json' | python3 -m json.tool > /dev/null 2>&1"
+ [ "$status" -eq 0 ]
+}
+
+@test "theme: theme.json contains required color definitions" {
+ theme_dir="${THEMER_DIR}/themes/synthwave"
+
+ assert_file_contains "${theme_dir}/theme.json" "background"
+ assert_file_contains "${theme_dir}/theme.json" "foreground"
+ assert_file_contains "${theme_dir}/theme.json" "accent1"
+}
+
+# --- Error Handling Tests ---
+
+@test "error: apply fails gracefully with missing theme directory" {
+ run "$SCRIPTS_DIR/apply.sh" "this-theme-does-not-exist"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"not found"* ]]
+}
+
+@test "error: restore fails gracefully with no backups" {
+ rm -rf "$TEST_BACKUP_DIR"/*
+
+ run "$SCRIPTS_DIR/restore.sh"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"No backups"* ]]
+}
+
+# --- Cross-Tool Consistency Tests ---
+
+@test "consistency: applied theme creates coherent environment" {
+ run "$SCRIPTS_DIR/apply.sh" "synthwave"
+ [ "$status" -eq 0 ]
+
+ # All expected files should exist after apply
+ assert_file_exists "$TEST_HOME/Library/Application Support/iTerm2/DynamicProfiles/themer-up.json"
+ assert_file_exists "$TEST_HOME/.p10k-themer.zsh"
+ assert_file_exists "$TEST_HOME/.config/mprocs/claude.yaml"
+ assert_file_exists "$TEST_HOME/.vim/colors/synthwave.vim"
+ assert_file_exists "$TEST_HOME/.config/nvim/colors/synthwave.vim"
+}
diff --git a/tests/test_helper.bash b/tests/test_helper.bash
new file mode 100644
index 0000000..2a6e00d
--- /dev/null
+++ b/tests/test_helper.bash
@@ -0,0 +1,106 @@
+#!/usr/bin/env bash
+# BATS test helper - shared setup and utilities for all tests
+
+# Project paths - resolve from this helper file's location
+_HELPER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+THEMER_DIR="$(cd "${_HELPER_DIR}/.." && pwd)"
+export THEMER_DIR
+export SCRIPTS_DIR="${THEMER_DIR}/scripts"
+export THEMES_DIR="${THEMER_DIR}/themes"
+
+# Test fixture paths (isolated from real user configs)
+export TEST_HOME="${BATS_TMPDIR}/themer-test-home"
+export TEST_BACKUP_DIR="${TEST_HOME}/.themer-up-backup"
+
+# Override HOME for isolated testing
+setup_test_home() {
+ export ORIGINAL_HOME="$HOME"
+ export HOME="$TEST_HOME"
+
+ # Export THEMER_DIR so scripts can find theme files
+ export THEMER_DIR
+
+ # Export backup dir override so scripts use test location
+ export THEMER_BACKUP_DIR="$TEST_BACKUP_DIR"
+
+ # Create mock directory structure
+ mkdir -p "$TEST_HOME/Library/Application Support/iTerm2/DynamicProfiles"
+ mkdir -p "$TEST_HOME/Library/Application Support/Code/User"
+ mkdir -p "$TEST_HOME/.config/mprocs"
+ mkdir -p "$TEST_HOME/.config/nvim/colors"
+ mkdir -p "$TEST_HOME/.vim/colors"
+ mkdir -p "$TEST_HOME/.themer-up-backup"
+
+ # Create minimal .zshrc and .vimrc
+ touch "$TEST_HOME/.zshrc"
+ touch "$TEST_HOME/.vimrc"
+}
+
+teardown_test_home() {
+ export HOME="$ORIGINAL_HOME"
+ unset THEMER_BACKUP_DIR
+ rm -rf "$TEST_HOME"
+}
+
+# Assertions
+assert_file_exists() {
+ local file="$1"
+ if [[ ! -f "$file" ]]; then
+ echo "Expected file to exist: $file" >&2
+ return 1
+ fi
+}
+
+assert_file_not_exists() {
+ local file="$1"
+ if [[ -f "$file" ]]; then
+ echo "Expected file NOT to exist: $file" >&2
+ return 1
+ fi
+}
+
+assert_file_contains() {
+ local file="$1"
+ local pattern="$2"
+ if ! grep -q "$pattern" "$file" 2>/dev/null; then
+ echo "Expected file $file to contain: $pattern" >&2
+ return 1
+ fi
+}
+
+assert_dir_exists() {
+ local dir="$1"
+ if [[ ! -d "$dir" ]]; then
+ echo "Expected directory to exist: $dir" >&2
+ return 1
+ fi
+}
+
+# Theme validation helpers
+theme_file_count() {
+ local theme="$1"
+ find "${THEMES_DIR}/${theme}" -type f | wc -l | tr -d ' '
+}
+
+validate_theme_structure() {
+ local theme="$1"
+ local theme_dir="${THEMES_DIR}/${theme}"
+
+ [[ -f "${theme_dir}/theme.json" ]] || return 1
+ [[ -f "${theme_dir}/iterm2.json" ]] || return 1
+}
+
+# Mock external commands for isolation
+create_mock_commands() {
+ local mock_bin="${TEST_HOME}/bin"
+ mkdir -p "$mock_bin"
+ export PATH="${mock_bin}:$PATH"
+
+ # Mock code command
+ cat > "${mock_bin}/code" << 'EOF'
+#!/bin/bash
+echo "Mock: code $@"
+exit 0
+EOF
+ chmod +x "${mock_bin}/code"
+}
diff --git a/tests/unit/CLAUDE.md b/tests/unit/CLAUDE.md
new file mode 100644
index 0000000..ed1ea43
--- /dev/null
+++ b/tests/unit/CLAUDE.md
@@ -0,0 +1,11 @@
+
+# Recent Activity
+
+
+
+### Jan 13, 2026
+
+| ID | Time | T | Title | Read |
+|----|------|---|-------|------|
+| #167 | 10:49 PM | 🟣 | Test Automation Orchestrator for Themer-Up | ~817 |
+
\ No newline at end of file
diff --git a/tests/unit/apply.bats b/tests/unit/apply.bats
new file mode 100644
index 0000000..90280cb
--- /dev/null
+++ b/tests/unit/apply.bats
@@ -0,0 +1,119 @@
+#!/usr/bin/env bats
+# Unit tests for apply.sh
+
+load '../test_helper'
+
+setup() {
+ setup_test_home
+ export THEMER_DIR="${BATS_TEST_DIRNAME}/../.."
+
+ # Create a mock backup script that doesn't fail
+ mkdir -p "$TEST_HOME/scripts"
+ cat > "$TEST_HOME/scripts/backup.sh" << 'EOF'
+#!/bin/bash
+mkdir -p "$HOME/.themer-up-backup/mock_backup"
+echo "Mock backup"
+EOF
+ chmod +x "$TEST_HOME/scripts/backup.sh"
+
+ # Temporarily patch the apply script to use our mock backup
+ # We'll test with the real script but mock external dependencies
+}
+
+teardown() {
+ teardown_test_home
+}
+
+@test "apply.sh exits with error for non-existent theme" {
+ run "$SCRIPTS_DIR/apply.sh" "nonexistent-theme"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"not found"* ]]
+}
+
+@test "apply.sh shows available themes when theme not found" {
+ run "$SCRIPTS_DIR/apply.sh" "nonexistent-theme"
+ [[ "$output" == *"Available themes"* ]] || [[ "$output" == *"synthwave"* ]]
+}
+
+@test "apply.sh uses synthwave as default theme" {
+ # The script should reference synthwave if no arg provided
+ # We can verify by checking the script's default behavior
+ run bash -c "source $SCRIPTS_DIR/apply.sh 2>&1 || true" <<< ""
+ # Script will try to apply synthwave by default
+ [[ "$output" == *"synthwave"* ]] || [ "$status" -eq 0 ]
+}
+
+@test "apply.sh creates backup before applying" {
+ run "$SCRIPTS_DIR/apply.sh" "synthwave"
+
+ # Backup should have been attempted (directory created)
+ assert_dir_exists "$TEST_BACKUP_DIR"
+}
+
+@test "apply.sh copies iTerm2 profile" {
+ run "$SCRIPTS_DIR/apply.sh" "synthwave"
+ [ "$status" -eq 0 ]
+
+ iterm_profile="$TEST_HOME/Library/Application Support/iTerm2/DynamicProfiles/themer-up.json"
+ assert_file_exists "$iterm_profile"
+}
+
+@test "apply.sh copies p10k theme file" {
+ run "$SCRIPTS_DIR/apply.sh" "synthwave"
+ [ "$status" -eq 0 ]
+
+ assert_file_exists "$TEST_HOME/.p10k-themer.zsh"
+}
+
+@test "apply.sh adds p10k source line to .zshrc if not present" {
+ run "$SCRIPTS_DIR/apply.sh" "synthwave"
+ [ "$status" -eq 0 ]
+
+ assert_file_contains "$TEST_HOME/.zshrc" "p10k-themer.zsh"
+}
+
+@test "apply.sh does not duplicate p10k source line" {
+ # Pre-add the source line
+ echo "[[ -f ~/.p10k-themer.zsh ]] && source ~/.p10k-themer.zsh" >> "$TEST_HOME/.zshrc"
+
+ run "$SCRIPTS_DIR/apply.sh" "synthwave"
+ [ "$status" -eq 0 ]
+
+ # Count occurrences - should be exactly 1
+ count=$(grep -c "p10k-themer.zsh" "$TEST_HOME/.zshrc" || echo 0)
+ [ "$count" -eq 1 ]
+}
+
+@test "apply.sh copies mprocs config" {
+ run "$SCRIPTS_DIR/apply.sh" "synthwave"
+ [ "$status" -eq 0 ]
+
+ assert_file_exists "$TEST_HOME/.config/mprocs/claude.yaml"
+}
+
+@test "apply.sh installs vim colorscheme" {
+ run "$SCRIPTS_DIR/apply.sh" "synthwave"
+ [ "$status" -eq 0 ]
+
+ assert_file_exists "$TEST_HOME/.vim/colors/synthwave.vim"
+ assert_file_exists "$TEST_HOME/.config/nvim/colors/synthwave.vim"
+}
+
+@test "apply.sh adds colorscheme to vimrc" {
+ run "$SCRIPTS_DIR/apply.sh" "synthwave"
+ [ "$status" -eq 0 ]
+
+ assert_file_contains "$TEST_HOME/.vimrc" "colorscheme synthwave"
+}
+
+@test "apply.sh outputs success message" {
+ run "$SCRIPTS_DIR/apply.sh" "synthwave"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"applied"* ]]
+}
+
+@test "apply.sh shows restart instructions" {
+ run "$SCRIPTS_DIR/apply.sh" "synthwave"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Restart iTerm2"* ]] || [[ "$output" == *"Actions needed"* ]]
+}
diff --git a/tests/unit/backup.bats b/tests/unit/backup.bats
new file mode 100644
index 0000000..3b83135
--- /dev/null
+++ b/tests/unit/backup.bats
@@ -0,0 +1,86 @@
+#!/usr/bin/env bats
+# Unit tests for backup.sh
+
+load '../test_helper'
+
+setup() {
+ setup_test_home
+
+ # Patch THEMER_DIR in the script for testing
+ export THEMER_DIR="${BATS_TEST_DIRNAME}/../.."
+
+ # Create files that would normally exist in user's home
+ echo '{"theme": "old"}' > "$TEST_HOME/Library/Application Support/iTerm2/DynamicProfiles/themer-up.json"
+ echo 'old p10k config' > "$TEST_HOME/.p10k-themer.zsh"
+ echo 'old mprocs config' > "$TEST_HOME/.config/mprocs/claude.yaml"
+}
+
+teardown() {
+ teardown_test_home
+}
+
+@test "backup.sh creates timestamped backup directory" {
+ run "$SCRIPTS_DIR/backup.sh"
+ [ "$status" -eq 0 ]
+
+ # Check that a backup directory was created
+ backup_count=$(find "$TEST_BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')
+ [ "$backup_count" -ge 1 ]
+}
+
+@test "backup.sh copies iTerm2 config when present" {
+ run "$SCRIPTS_DIR/backup.sh"
+ [ "$status" -eq 0 ]
+
+ # Find the latest backup
+ latest_backup=$(ls -dt "$TEST_BACKUP_DIR"/*/ 2>/dev/null | head -1)
+
+ assert_file_exists "${latest_backup}iterm2.json"
+ assert_file_contains "${latest_backup}iterm2.json" "old"
+}
+
+@test "backup.sh copies p10k config when present" {
+ run "$SCRIPTS_DIR/backup.sh"
+ [ "$status" -eq 0 ]
+
+ latest_backup=$(ls -dt "$TEST_BACKUP_DIR"/*/ 2>/dev/null | head -1)
+ assert_file_exists "${latest_backup}p10k.zsh"
+}
+
+@test "backup.sh copies mprocs config when present" {
+ run "$SCRIPTS_DIR/backup.sh"
+ [ "$status" -eq 0 ]
+
+ latest_backup=$(ls -dt "$TEST_BACKUP_DIR"/*/ 2>/dev/null | head -1)
+ assert_file_exists "${latest_backup}mprocs.yaml"
+}
+
+@test "backup.sh handles missing files gracefully" {
+ # Remove all config files
+ rm -f "$TEST_HOME/Library/Application Support/iTerm2/DynamicProfiles/themer-up.json"
+ rm -f "$TEST_HOME/.p10k-themer.zsh"
+ rm -f "$TEST_HOME/.config/mprocs/claude.yaml"
+
+ run "$SCRIPTS_DIR/backup.sh"
+ [ "$status" -eq 0 ]
+
+ # Backup dir should still be created (possibly empty)
+ assert_dir_exists "$TEST_BACKUP_DIR"
+}
+
+@test "backup.sh keeps only last 5 backups" {
+ # Create 6 old backup directories
+ for i in {1..6}; do
+ old_backup="$TEST_BACKUP_DIR/2024010${i}_120000"
+ mkdir -p "$old_backup"
+ touch "$old_backup/iterm2.json"
+ sleep 0.1 # Ensure different timestamps
+ done
+
+ run "$SCRIPTS_DIR/backup.sh"
+ [ "$status" -eq 0 ]
+
+ # Should have at most 5 backup directories (5 old + 1 new = 6, then prune to 5)
+ backup_count=$(find "$TEST_BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')
+ [ "$backup_count" -le 5 ]
+}
diff --git a/tests/unit/restore.bats b/tests/unit/restore.bats
new file mode 100644
index 0000000..79e7953
--- /dev/null
+++ b/tests/unit/restore.bats
@@ -0,0 +1,82 @@
+#!/usr/bin/env bats
+# Unit tests for restore.sh
+
+load '../test_helper'
+
+setup() {
+ setup_test_home
+ export THEMER_DIR="${BATS_TEST_DIRNAME}/../.."
+
+ # Create a mock backup to restore from
+ export MOCK_BACKUP="$TEST_BACKUP_DIR/20240115_120000"
+ mkdir -p "$MOCK_BACKUP"
+
+ # Create backup files
+ echo '{"theme": "backup_theme"}' > "$MOCK_BACKUP/iterm2.json"
+ echo 'backup p10k config' > "$MOCK_BACKUP/p10k.zsh"
+ echo 'backup mprocs config' > "$MOCK_BACKUP/mprocs.yaml"
+}
+
+teardown() {
+ teardown_test_home
+}
+
+@test "restore.sh exits with error when no backups exist" {
+ rm -rf "$TEST_BACKUP_DIR"/*
+
+ run "$SCRIPTS_DIR/restore.sh"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"No backups found"* ]]
+}
+
+@test "restore.sh restores iTerm2 config" {
+ run "$SCRIPTS_DIR/restore.sh"
+ [ "$status" -eq 0 ]
+
+ restored="$TEST_HOME/Library/Application Support/iTerm2/DynamicProfiles/themer-up.json"
+ assert_file_exists "$restored"
+ assert_file_contains "$restored" "backup_theme"
+}
+
+@test "restore.sh restores p10k config" {
+ run "$SCRIPTS_DIR/restore.sh"
+ [ "$status" -eq 0 ]
+
+ assert_file_exists "$TEST_HOME/.p10k-themer.zsh"
+ assert_file_contains "$TEST_HOME/.p10k-themer.zsh" "backup p10k"
+}
+
+@test "restore.sh restores mprocs config" {
+ run "$SCRIPTS_DIR/restore.sh"
+ [ "$status" -eq 0 ]
+
+ assert_file_exists "$TEST_HOME/.config/mprocs/claude.yaml"
+ assert_file_contains "$TEST_HOME/.config/mprocs/claude.yaml" "backup mprocs"
+}
+
+@test "restore.sh uses most recent backup when multiple exist" {
+ # Create an older backup with different content
+ older_backup="$TEST_BACKUP_DIR/20240110_100000"
+ mkdir -p "$older_backup"
+ echo '{"theme": "older_theme"}' > "$older_backup/iterm2.json"
+
+ # Create a newer backup
+ newer_backup="$TEST_BACKUP_DIR/20240120_140000"
+ mkdir -p "$newer_backup"
+ echo '{"theme": "newer_theme"}' > "$newer_backup/iterm2.json"
+
+ run "$SCRIPTS_DIR/restore.sh"
+ [ "$status" -eq 0 ]
+
+ # Should restore from newer backup
+ restored="$TEST_HOME/Library/Application Support/iTerm2/DynamicProfiles/themer-up.json"
+ assert_file_contains "$restored" "newer_theme"
+}
+
+@test "restore.sh outputs success messages" {
+ run "$SCRIPTS_DIR/restore.sh"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"iTerm2 restored"* ]]
+ [[ "$output" == *"Powerlevel10k restored"* ]]
+ [[ "$output" == *"mprocs restored"* ]]
+}