Files
projectm/scripts/test-macos-framework.sh
Mischa Spiegelmock f67dac9481 Fix macOS framework build to properly include headers (#964)
* Fix macOS framework build to properly include headers

The previous implementation using CMake's built-in FRAMEWORK property
had two issues:
1. Headers were not copied into the framework at all
2. PUBLIC_HEADER flattens directory structure, breaking C++ interface

This replaces the CMake FRAMEWORK support with a custom MacOSFramework
cmake module that:
- Builds proper framework bundles from scratch
- Preserves header directory hierarchy (Audio/, Renderer/ subdirs)
- Creates correct symlink structure (Versions/A, Current, etc.)
- Generates Info.plist with bundle metadata

Also adds CI test script (scripts/test-macos-framework.sh) that validates:
- Framework directory structure
- Header completeness
- Linkability (compile and link test program)

Fixes the empty framework issue reported after ef00cfc8e.

* Fix review issues in macOS framework build

- Add missing Renderer/TextureTypes.hpp to C++ framework headers
- Skip pkg-config generation for playlist in framework mode
- Use stored framework path property for install instead of TARGET_FILE_DIR
- Show compiler errors on linkability test failure instead of suppressing
- Fix comment about framework output location

* Add framework CI jobs and harden test script

New CI job matrix (build-framework):
- Tests framework builds on both arm64 and x86_64
- Tests with and without C++ interface
- Runs strict validation after build AND after install
- Verifies installed frameworks match build output

Test script improvements:
- Exhaustive header lists (all C API + all C++ headers)
- Exact header count validation (catches stale/unexpected files)
- Strict mode (STRICT=1) where SKIPs become FAILs
- Info.plist CFBundleExecutable validation
- Symlink target verification (Current, Headers, Resources)
- Mach-O dylib binary type check
- dylib install name validation
- Flexible framework search across build tree and install prefix
- Test pass counter in summary

---------

Co-authored-by: Mischa <mish@Kensington.local>
2026-02-16 09:00:52 -08:00

351 lines
13 KiB
Bash
Executable File

#!/bin/bash
# test-macos-framework.sh
# CI test script for validating macOS framework builds
#
# Usage: ./scripts/test-macos-framework.sh <dir> [<dir>...]
#
# Searches the given directories for projectM-4.framework and
# projectM-4-playlist.framework. Works with both build trees
# (where frameworks are in src/libprojectM/ and src/playlist/)
# and install prefixes (where frameworks are in lib/).
#
# Environment:
# STRICT=1 (default) SKIPs become FAILs - use for CI
# STRICT=0 SKIPs are warnings - use for local testing
#
# This script validates:
# 1. Framework directory structure (symlinks, directories, Info.plist)
# 2. Header completeness (every expected header must be present)
# 3. No unexpected headers (catch stale or misplaced files)
# 4. Linkability (compile and link a test program against framework)
# 5. dylib identity (install name matches framework convention)
set -euo pipefail
# Cleanup temp files on exit
TEMP_FILES=()
cleanup() {
for f in "${TEMP_FILES[@]}"; do
rm -f "$f" 2>/dev/null || true
done
}
trap cleanup EXIT
SEARCH_DIRS=("${@:-.}")
STRICT="${STRICT:-1}" # In strict mode, SKIPs become FAILs
# Find a framework by name in the search directories
find_framework() {
local name="$1"
for dir in "${SEARCH_DIRS[@]}"; do
# Check common locations
for candidate in \
"$dir/$name.framework" \
"$dir/src/libprojectM/$name.framework" \
"$dir/src/playlist/$name.framework" \
"$dir/lib/$name.framework"; do
if [ -d "$candidate" ]; then
echo "$candidate"
return 0
fi
done
done
return 1
}
echo "=== macOS Framework CI Tests ==="
echo "Search directories: ${SEARCH_DIRS[*]}"
echo "Strict mode: $STRICT"
echo ""
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m' # No Color
TESTS_RUN=0
TESTS_PASSED=0
pass() {
echo -e "${GREEN}PASS${NC}: $1"
TESTS_RUN=$((TESTS_RUN + 1))
TESTS_PASSED=$((TESTS_PASSED + 1))
}
fail() {
echo -e "${RED}FAIL${NC}: $1"
exit 1
}
skip() {
if [ "$STRICT" = "1" ]; then
fail "SKIP not allowed in strict mode: $1"
else
echo -e "${YELLOW}SKIP${NC}: $1"
fi
}
# Test 1: Framework Structure Validation
test_structure() {
local framework_path="$1"
local framework_name="$2"
echo "--- Testing structure: $framework_name.framework ---"
# Check framework directory exists
[ -d "$framework_path" ] || fail "Framework does not exist: $framework_path"
# Check Versions directory
[ -d "$framework_path/Versions" ] || fail "Missing Versions directory"
[ -d "$framework_path/Versions/A" ] || fail "Missing Versions/A directory"
[ -L "$framework_path/Versions/Current" ] || fail "Missing Versions/Current symlink"
# Verify Current symlink target
local current_target
current_target=$(readlink "$framework_path/Versions/Current")
[ "$current_target" = "A" ] || fail "Versions/Current symlink points to '$current_target', expected 'A'"
# Check Version A contents
[ -f "$framework_path/Versions/A/$framework_name" ] || fail "Missing binary: Versions/A/$framework_name"
[ -d "$framework_path/Versions/A/Headers" ] || fail "Missing Headers directory"
[ -d "$framework_path/Versions/A/Resources" ] || fail "Missing Resources directory"
[ -f "$framework_path/Versions/A/Resources/Info.plist" ] || fail "Missing Info.plist"
# Validate Info.plist has correct bundle executable name
local bundle_exec
bundle_exec=$(/usr/libexec/PlistBuddy -c "Print :CFBundleExecutable" "$framework_path/Versions/A/Resources/Info.plist" 2>/dev/null || true)
[ "$bundle_exec" = "$framework_name" ] || fail "Info.plist CFBundleExecutable is '$bundle_exec', expected '$framework_name'"
# Check top-level symlinks exist and are symlinks (not copies)
[ -L "$framework_path/$framework_name" ] || fail "Missing top-level binary symlink"
[ -L "$framework_path/Headers" ] || fail "Missing top-level Headers symlink"
[ -L "$framework_path/Resources" ] || fail "Missing top-level Resources symlink"
# Verify symlinks point to correct locations
local binary_target headers_target resources_target
binary_target=$(readlink "$framework_path/$framework_name")
headers_target=$(readlink "$framework_path/Headers")
resources_target=$(readlink "$framework_path/Resources")
[ "$binary_target" = "Versions/Current/$framework_name" ] || fail "Binary symlink points to '$binary_target', expected 'Versions/Current/$framework_name'"
[ "$headers_target" = "Versions/Current/Headers" ] || fail "Headers symlink points to '$headers_target', expected 'Versions/Current/Headers'"
[ "$resources_target" = "Versions/Current/Resources" ] || fail "Resources symlink points to '$resources_target', expected 'Versions/Current/Resources'"
# Verify the binary is a Mach-O dylib
local file_type
file_type=$(file -b "$framework_path/Versions/A/$framework_name")
echo "$file_type" | grep -q "Mach-O" || fail "Binary is not Mach-O: $file_type"
echo "$file_type" | grep -qi "dynamically linked\|dynamic library" || fail "Binary is not a dynamic library: $file_type"
pass "Structure validation for $framework_name.framework"
}
# Test 2: Header Completeness
# Usage: test_headers <framework_path> <framework_name> <header_subdir> <expected_headers...>
test_headers() {
local framework_path="$1"
local framework_name="$2"
local header_subdir="$3"
shift 3
local expected_headers=("$@")
echo "--- Testing headers: $framework_name.framework ---"
# Headers are in Headers/<header-subdir>/ to support #include <subdir/header.h>
local headers_dir="$framework_path/Headers/$header_subdir"
[ -d "$headers_dir" ] || fail "Headers directory does not exist: $headers_dir"
# Check each expected header is present
for header in "${expected_headers[@]}"; do
[ -f "$headers_dir/$header" ] || fail "Missing expected header: $header_subdir/$header"
done
# Check for unexpected headers (catches stale files or wrong headers being copied)
local actual_count expected_count
actual_count=$(find "$headers_dir" -name "*.h" -o -name "*.hpp" | wc -l | tr -d ' ')
expected_count=${#expected_headers[@]}
echo " Found $actual_count header files (expected $expected_count)"
if [ "$actual_count" -ne "$expected_count" ]; then
echo " Expected headers:"
for h in "${expected_headers[@]}"; do echo " $h"; done
echo " Actual headers:"
find "$headers_dir" \( -name "*.h" -o -name "*.hpp" \) -exec basename {} \; | sort | while read -r h; do echo " $h"; done
fail "Header count mismatch: found $actual_count, expected $expected_count"
fi
pass "Header validation for $framework_name.framework ($actual_count headers)"
}
# Test 3: Linkability Test
test_linkability() {
local framework_path="$1"
local framework_name="$2"
local test_code="$3"
echo "--- Testing linkability: $framework_name.framework ---"
local framework_parent
framework_parent=$(dirname "$framework_path")
# Headers are in Headers/<framework-name>/ to support include patterns like
# #include <projectM-4/projectM.h>, so we add -I Framework.framework/Headers
local include_path="$framework_path/Headers"
local test_file test_output
test_file=$(mktemp -t framework_test).c
test_output=$(mktemp -t framework_test)
TEMP_FILES+=("$test_file" "$test_output")
echo "$test_code" > "$test_file"
local compile_errors
if compile_errors=$(clang -F "$framework_parent" -I "$include_path" -framework "$framework_name" "$test_file" -o "$test_output" 2>&1); then
pass "Linkability test for $framework_name.framework"
else
echo "$compile_errors"
fail "Failed to compile and link against $framework_name.framework"
fi
}
# Test 4: dylib install name check
test_install_name() {
local framework_path="$1"
local framework_name="$2"
echo "--- Testing install name: $framework_name.framework ---"
local binary="$framework_path/Versions/A/$framework_name"
local install_name
install_name=$(otool -D "$binary" 2>/dev/null | tail -1)
# For a framework, the install name should contain the framework name
if echo "$install_name" | grep -q "$framework_name"; then
echo " Install name: $install_name"
pass "Install name check for $framework_name.framework"
else
echo " Install name: $install_name"
fail "Install name does not reference framework name '$framework_name'"
fi
}
# ============================================================================
# Main tests
# ============================================================================
# projectM-4.framework
PROJECTM_FRAMEWORK=$(find_framework "projectM-4" || true)
if [ -n "$PROJECTM_FRAMEWORK" ] && [ -d "$PROJECTM_FRAMEWORK" ]; then
echo "Found: $PROJECTM_FRAMEWORK"
test_structure "$PROJECTM_FRAMEWORK" "projectM-4"
# All expected C API headers (must be exhaustive)
PROJECTM_HEADERS=(
"projectM.h"
"audio.h"
"callbacks.h"
"core.h"
"debug.h"
"logging.h"
"memory.h"
"parameters.h"
"render_opengl.h"
"touch.h"
"types.h"
"user_sprites.h"
"projectM_export.h"
"version.h"
)
# C++ headers are present when built with ENABLE_CXX_INTERFACE
# Check if any C++ headers exist to determine if we should validate them
if [ -d "$PROJECTM_FRAMEWORK/Headers/projectM-4/Audio" ]; then
PROJECTM_HEADERS+=(
"projectM_cxx_export.h"
"Logging.hpp"
"ProjectM.hpp"
"Audio/AudioConstants.hpp"
"Audio/FrameAudioData.hpp"
"Audio/Loudness.hpp"
"Audio/MilkdropFFT.hpp"
"Audio/PCM.hpp"
"Audio/WaveformAligner.hpp"
"Renderer/RenderContext.hpp"
"Renderer/TextureTypes.hpp"
)
fi
test_headers "$PROJECTM_FRAMEWORK" "projectM-4" "projectM-4" "${PROJECTM_HEADERS[@]}"
# Linkability test with the C API
PROJECTM_TEST_CODE='
#include <projectM-4/projectM.h>
int main() {
projectm_handle pm = projectm_create();
if (pm) projectm_destroy(pm);
return 0;
}
'
test_linkability "$PROJECTM_FRAMEWORK" "projectM-4" "$PROJECTM_TEST_CODE"
test_install_name "$PROJECTM_FRAMEWORK" "projectM-4"
else
skip "projectM-4.framework not found in search directories"
fi
echo ""
# projectM-4-playlist.framework
PLAYLIST_FRAMEWORK=$(find_framework "projectM-4-playlist" || true)
if [ -n "$PLAYLIST_FRAMEWORK" ] && [ -d "$PLAYLIST_FRAMEWORK" ]; then
echo "Found: $PLAYLIST_FRAMEWORK"
test_structure "$PLAYLIST_FRAMEWORK" "projectM-4-playlist"
# All expected playlist headers (must be exhaustive)
PLAYLIST_HEADERS=(
"playlist.h"
"playlist_callbacks.h"
"playlist_core.h"
"playlist_filter.h"
"playlist_items.h"
"playlist_memory.h"
"playlist_playback.h"
"playlist_types.h"
"projectM_playlist_export.h"
)
test_headers "$PLAYLIST_FRAMEWORK" "projectM-4-playlist" "projectM-4" "${PLAYLIST_HEADERS[@]}"
# Linkability test (needs projectM framework too)
PLAYLIST_TEST_CODE='
#include <projectM-4/playlist.h>
int main() {
return 0;
}
'
if [ -d "$PROJECTM_FRAMEWORK" ]; then
echo "--- Testing linkability: projectM-4-playlist.framework ---"
framework_parent=$(dirname "$PLAYLIST_FRAMEWORK")
projectm_parent=$(dirname "$PROJECTM_FRAMEWORK")
include_path="$PLAYLIST_FRAMEWORK/Headers"
projectm_include_path="$PROJECTM_FRAMEWORK/Headers"
test_file=$(mktemp -t framework_test).c
test_output=$(mktemp -t framework_test)
TEMP_FILES+=("$test_file" "$test_output")
echo "$PLAYLIST_TEST_CODE" > "$test_file"
compile_errors=""
if compile_errors=$(clang -F "$framework_parent" -F "$projectm_parent" \
-I "$include_path" -I "$projectm_include_path" \
-framework "projectM-4-playlist" -framework "projectM-4" \
"$test_file" -o "$test_output" 2>&1); then
pass "Linkability test for projectM-4-playlist.framework"
else
echo "$compile_errors"
fail "Failed to compile and link against projectM-4-playlist.framework"
fi
else
skip "Cannot test playlist linkability without projectM-4.framework"
fi
test_install_name "$PLAYLIST_FRAMEWORK" "projectM-4-playlist"
else
skip "projectM-4-playlist.framework not found in search directories"
fi
echo ""
echo "=== All framework tests passed ($TESTS_PASSED/$TESTS_RUN) ==="