From ab469cbefd3a3d97d9574dd91114de14eba9e129 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 21 Jul 2025 00:58:43 +0200 Subject: [PATCH] [tests] headless-test-runner.ts with language specific fixes (e.g. icon: null (Java) > icon: "" (C++), C++ JSON will have null as well) --- spine-cpp/src/no-cpprt.cpp | 9 +- spine-cpp/tests/HeadlessTest.cpp | 32 +- .../esotericsoftware/spine/HeadlessTest.java | 19 +- tests/src/headless-test-runner.ts | 567 ++++++++++++++++++ 4 files changed, 607 insertions(+), 20 deletions(-) create mode 100755 tests/src/headless-test-runner.ts diff --git a/spine-cpp/src/no-cpprt.cpp b/spine-cpp/src/no-cpprt.cpp index 21f0d463b..1c84271c0 100644 --- a/spine-cpp/src/no-cpprt.cpp +++ b/spine-cpp/src/no-cpprt.cpp @@ -27,7 +27,14 @@ * THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. *****************************************************************************/ -#include +// Avoid including any standard headers for maximum portability +#ifdef __cplusplus +extern "C" { +#endif +typedef __SIZE_TYPE__ size_t; +#ifdef __cplusplus +} +#endif // Stubs for C++ stdlib functions spine-cpp depends on. Used for nostdcpp builds. // These are weak symbols to allow overriding in custom builds, e.g. headless-test-nostdcpp diff --git a/spine-cpp/tests/HeadlessTest.cpp b/spine-cpp/tests/HeadlessTest.cpp index c02627f72..7a0c43c32 100644 --- a/spine-cpp/tests/HeadlessTest.cpp +++ b/spine-cpp/tests/HeadlessTest.cpp @@ -106,14 +106,16 @@ int main(int argc, char *argv[]) { // Create skeleton instance Skeleton skeleton(*skeletonData); - // Create animation state - AnimationStateData stateData(skeletonData); - AnimationState state(&stateData); - skeleton.setupPose(); - // Set animation or setup pose + // Set animation if provided + AnimationState *state = nullptr; + AnimationStateData *stateData = nullptr; if (animationName != nullptr) { + // Create animation state only when needed + stateData = new AnimationStateData(skeletonData); + state = new AnimationState(stateData); + // Find and set animation Animation *animation = skeletonData->findAnimation(animationName); if (!animation) { @@ -122,10 +124,10 @@ int main(int argc, char *argv[]) { delete atlas; return 1; } - state.setAnimation(0, animation, true); + state->setAnimation(0, animation, true); // Update and apply - state.update(0.016f); - state.apply(skeleton); + state->update(0.016f); + state->apply(skeleton); } skeleton.updateWorldTransform(Physics_Update); @@ -141,11 +143,19 @@ int main(int argc, char *argv[]) { printf("\n=== SKELETON STATE ===\n"); printf("%s", serializer.serializeSkeleton(&skeleton).buffer()); - // Print animation state - printf("\n=== ANIMATION STATE ===\n"); - printf("%s", serializer.serializeAnimationState(&state).buffer()); + // Print animation state only if animation was loaded + if (state != nullptr) { + printf("\n=== ANIMATION STATE ===\n"); + printf("%s", serializer.serializeAnimationState(state).buffer()); + } // Cleanup + if (state != nullptr) { + delete state; + } + if (stateData != nullptr) { + delete stateData; + } delete skeletonData; delete atlas; diff --git a/spine-libgdx/spine-libgdx-tests/src/com/esotericsoftware/spine/HeadlessTest.java b/spine-libgdx/spine-libgdx-tests/src/com/esotericsoftware/spine/HeadlessTest.java index 9dc14ea71..e121c386a 100644 --- a/spine-libgdx/spine-libgdx-tests/src/com/esotericsoftware/spine/HeadlessTest.java +++ b/spine-libgdx/spine-libgdx-tests/src/com/esotericsoftware/spine/HeadlessTest.java @@ -137,14 +137,15 @@ public class HeadlessTest implements ApplicationListener { // Create skeleton instance Skeleton skeleton = new Skeleton(skeletonData); - // Create animation state - AnimationStateData stateData = new AnimationStateData(skeletonData); - AnimationState state = new AnimationState(stateData); - skeleton.setupPose(); - // Set animation or setup pose + // Set animation if provided + AnimationState state = null; if (animationName != null) { + // Create animation state only when needed + AnimationStateData stateData = new AnimationStateData(skeletonData); + state = new AnimationState(stateData); + // Find and set animation Animation animation = skeletonData.findAnimation(animationName); if (animation == null) { @@ -163,9 +164,11 @@ public class HeadlessTest implements ApplicationListener { System.out.println("\n=== SKELETON STATE ==="); System.out.println(serializer.serializeSkeleton(skeleton)); - // Print animation state as JSON - System.out.println("\n=== ANIMATION STATE ==="); - System.out.println(serializer.serializeAnimationState(state)); + // Print animation state as JSON only if animation was loaded + if (state != null) { + System.out.println("\n=== ANIMATION STATE ==="); + System.out.println(serializer.serializeAnimationState(state)); + } } catch (Exception e) { e.printStackTrace(); diff --git a/tests/src/headless-test-runner.ts b/tests/src/headless-test-runner.ts new file mode 100755 index 000000000..290d7444d --- /dev/null +++ b/tests/src/headless-test-runner.ts @@ -0,0 +1,567 @@ +#!/usr/bin/env node + +import { existsSync, writeFileSync, mkdirSync, rmSync, statSync } from 'fs'; +import { resolve, dirname, join } from 'path'; +import { execSync } from 'child_process'; +import { fileURLToPath } from 'url'; + +// Logging functions following formatters/logging/README.md style +function log_title(title: string): void { + console.error(`\x1b[1m${title}\x1b[0m`); +} + +function log_action(action: string): void { + process.stderr.write(` ${action}... `); +} + +function log_ok(): void { + console.error('\x1b[32m✓\x1b[0m'); +} + +function log_fail(): void { + console.error('\x1b[31m✗\x1b[0m'); +} + +function log_detail(detail: string): void { + console.error(`\x1b[90m${detail}\x1b[0m`); +} + +function log_summary(summary: string): void { + console.error(`\x1b[1m${summary}\x1b[0m`); +} + +function cleanOutputDirectory(): void { + const outputDir = join(SPINE_ROOT, 'tests', 'output'); + + log_action('Cleaning output directory'); + try { + if (existsSync(outputDir)) { + rmSync(outputDir, { recursive: true, force: true }); + } + mkdirSync(outputDir, { recursive: true }); + log_ok(); + } catch (error: any) { + log_fail(); + log_detail(`Failed to clean output directory: ${error.message}`); + process.exit(1); + } +} + +function getNewestFileTime(directory: string, pattern: string): number { + try { + const files = execSync(`find "${directory}" -name "${pattern}" -type f`, { encoding: 'utf8' }) + .trim() + .split('\n') + .filter(f => f); + + if (files.length === 0) return 0; + + return Math.max(...files.map(file => { + try { + return statSync(file).mtime.getTime(); + } catch { + return 0; + } + })); + } catch { + return 0; + } +} + +function needsJavaBuild(): boolean { + const javaDir = join(SPINE_ROOT, 'spine-libgdx'); + const testDir = join(javaDir, 'spine-libgdx-tests'); + const buildDir = join(testDir, 'build', 'libs'); + + try { + // Check if jar exists + const jarFiles = execSync(`ls ${buildDir}/*.jar 2>/dev/null || true`, { encoding: 'utf8' }).trim(); + if (!jarFiles) return true; + + // Get jar modification time + const jarTime = statSync(jarFiles.split('\n')[0]).mtime.getTime(); + + // Check Java source files + const javaSourceTime = getNewestFileTime(join(SPINE_ROOT, 'spine-libgdx'), '*.java'); + + // Check Gradle files + const gradleTime = getNewestFileTime(javaDir, 'build.gradle*'); + + return javaSourceTime > jarTime || gradleTime > jarTime; + } catch { + return true; + } +} + +function needsCppBuild(): boolean { + const cppDir = join(SPINE_ROOT, 'spine-cpp'); + const buildDir = join(cppDir, 'build'); + const headlessTest = join(buildDir, 'headless-test'); + + try { + // Check if executable exists + if (!existsSync(headlessTest)) return true; + + // Get executable modification time + const execTime = statSync(headlessTest).mtime.getTime(); + + // Check C++ source files + const cppSourceTime = getNewestFileTime(join(SPINE_ROOT, 'spine-cpp'), '*.cpp'); + const hppSourceTime = getNewestFileTime(join(SPINE_ROOT, 'spine-cpp'), '*.h'); + const cmakeTime = getNewestFileTime(cppDir, 'CMakeLists.txt'); + + const newestSourceTime = Math.max(cppSourceTime, hppSourceTime, cmakeTime); + + return newestSourceTime > execTime; + } catch { + return true; + } +} + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const SPINE_ROOT = resolve(__dirname, '../..'); + +interface TestArgs { + language: string; + skeletonPath: string; + atlasPath: string; + animationName?: string; +} + +interface SkeletonFiles { + jsonPath: string; + skelPath: string; + atlasPath: string; +} + +function findSkeletonFiles(skeletonName: string): SkeletonFiles { + const examplesDir = join(SPINE_ROOT, 'examples', skeletonName); + const exportDir = join(examplesDir, 'export'); + + if (!existsSync(exportDir)) { + log_detail(`Export directory not found: ${exportDir}`); + process.exit(1); + } + + // Try to find skeleton files (pro first, then ess) + let jsonPath: string | null = null; + let skelPath: string | null = null; + + const proJson = join(exportDir, `${skeletonName}-pro.json`); + const proSkel = join(exportDir, `${skeletonName}-pro.skel`); + const essJson = join(exportDir, `${skeletonName}-ess.json`); + const essSkel = join(exportDir, `${skeletonName}-ess.skel`); + + if (existsSync(proJson) && existsSync(proSkel)) { + jsonPath = proJson; + skelPath = proSkel; + } else if (existsSync(essJson) && existsSync(essSkel)) { + jsonPath = essJson; + skelPath = essSkel; + } else { + log_detail(`Could not find matching .json and .skel files for ${skeletonName}`); + log_detail(`Tried: ${proJson}, ${proSkel}, ${essJson}, ${essSkel}`); + process.exit(1); + } + + // Try to find atlas file (pma first, then regular) + let atlasPath: string | null = null; + const pmaAtlas = join(exportDir, `${skeletonName}-pma.atlas`); + const regularAtlas = join(exportDir, `${skeletonName}.atlas`); + + if (existsSync(pmaAtlas)) { + atlasPath = pmaAtlas; + } else if (existsSync(regularAtlas)) { + atlasPath = regularAtlas; + } else { + log_detail(`Could not find atlas file for ${skeletonName}`); + log_detail(`Tried: ${pmaAtlas}, ${regularAtlas}`); + process.exit(1); + } + + return { + jsonPath, + skelPath, + atlasPath + }; +} + +function validateArgs(): { language: string; files?: SkeletonFiles; skeletonPath?: string; atlasPath?: string; animationName?: string; fixFloats?: boolean } { + const args = process.argv.slice(2); + + if (args.length < 2) { + log_detail('Usage: headless-test-runner -s [animation-name] [-f]'); + log_detail(' headless-test-runner [animation-name] [-f]'); + log_detail('Target languages: cpp'); + log_detail('Flags: -f (fix floats and propertyIds to match Java values)'); + process.exit(1); + } + + // Check for -f flag + const fixFloats = args.includes('-f'); + const filteredArgs = args.filter(arg => arg !== '-f'); + + const [language, ...restArgs] = filteredArgs; + + if (!['cpp'].includes(language)) { + log_detail(`Invalid target language: ${language}. Must be cpp`); + process.exit(1); + } + + // Check if using -s flag + if (restArgs[0] === '-s') { + if (restArgs.length < 2) { + log_detail('Usage: headless-test-runner -s [animation-name]'); + process.exit(1); + } + + const [, skeletonName, animationName] = restArgs; + const files = findSkeletonFiles(skeletonName); + + return { + language, + files, + animationName, + fixFloats + }; + } else { + // Explicit paths mode + if (restArgs.length < 2) { + log_detail('Usage: headless-test-runner [animation-name]'); + process.exit(1); + } + + const [skeletonPath, atlasPath, animationName] = restArgs; + const resolvedSkeletonPath = resolve(skeletonPath); + const resolvedAtlasPath = resolve(atlasPath); + + if (!existsSync(resolvedSkeletonPath)) { + log_detail(`Skeleton file not found: ${resolvedSkeletonPath}`); + process.exit(1); + } + + if (!existsSync(resolvedAtlasPath)) { + log_detail(`Atlas file not found: ${resolvedAtlasPath}`); + process.exit(1); + } + + return { + language, + skeletonPath: resolvedSkeletonPath, + atlasPath: resolvedAtlasPath, + animationName, + fixFloats + }; + } +} + +function executeJava(args: TestArgs): string { + const javaDir = join(SPINE_ROOT, 'spine-libgdx'); + const testDir = join(javaDir, 'spine-libgdx-tests'); + + if (!existsSync(testDir)) { + log_detail(`Java test directory not found: ${testDir}`); + process.exit(1); + } + + // Check if we need to build + if (needsJavaBuild()) { + // Check if we have a gradle wrapper or gradle + const hasGradlew = existsSync(join(javaDir, 'gradlew')); + const gradleCmd = hasGradlew ? './gradlew' : 'gradle'; + + // Build the fat jar + log_action('Building Java HeadlessTest fat jar'); + try { + execSync(`${gradleCmd} :spine-libgdx-tests:fatJar`, { + cwd: javaDir, + stdio: ['inherit', 'pipe', 'inherit'] + }); + log_ok(); + } catch (error: any) { + log_fail(); + log_detail(`Java build failed: ${error.message}`); + process.exit(1); + } + } + + // Find the jar file + const buildDir = join(testDir, 'build', 'libs'); + const jarFiles = execSync(`ls ${buildDir}/*.jar`, { encoding: 'utf8' }).trim().split('\n'); + + if (jarFiles.length === 0) { + log_detail('No jar files found in build/libs directory'); + process.exit(1); + } + + const jarFile = jarFiles[0]; // Use the first jar file found + + // Run the HeadlessTest from jar + const testArgs = [args.skeletonPath, args.atlasPath]; + if (args.animationName) { + testArgs.push(args.animationName); + } + + log_action('Running Java HeadlessTest'); + try { + const output = execSync(`java -cp "${jarFile}" com.esotericsoftware.spine.HeadlessTest ${testArgs.join(' ')}`, { + encoding: 'utf8', + maxBuffer: 50 * 1024 * 1024 // 50MB buffer for large output + }); + log_ok(); + return output; + } catch (error: any) { + log_fail(); + log_detail(`Java execution failed: ${error.message}`); + process.exit(1); + } +} + +function executeCpp(args: TestArgs): string { + const cppDir = join(SPINE_ROOT, 'spine-cpp'); + const testsDir = join(cppDir, 'tests'); + + if (!existsSync(testsDir)) { + log_detail(`C++ tests directory not found: ${testsDir}`); + process.exit(1); + } + + // Check if we need to build + if (needsCppBuild()) { + // Build using build.sh + log_action('Building C++ tests'); + try { + execSync('./build.sh clean release', { + cwd: cppDir, + stdio: ['inherit', 'pipe', 'inherit'] + }); + log_ok(); + } catch (error: any) { + log_fail(); + log_detail(`C++ build failed: ${error.message}`); + process.exit(1); + } + } + + // Now run the headless test directly + const testArgs = [args.skeletonPath, args.atlasPath]; + if (args.animationName) { + testArgs.push(args.animationName); + } + + const buildDir = join(cppDir, 'build'); + const headlessTest = join(buildDir, 'headless-test'); + + if (!existsSync(headlessTest)) { + log_detail(`C++ headless-test executable not found: ${headlessTest}`); + process.exit(1); + } + + log_action('Running C++ HeadlessTest'); + try { + const output = execSync(`${headlessTest} ${testArgs.join(' ')}`, { + encoding: 'utf8', + maxBuffer: 50 * 1024 * 1024 // 50MB buffer for large output + }); + log_ok(); + return output; + } catch (error: any) { + log_fail(); + log_detail(`C++ execution failed: ${error.message}`); + process.exit(1); + } +} + +function parseOutput(output: string): { skeletonData: any, skeletonState: any, animationState?: any } { + // Split output into sections + const sections = output.split(/=== [A-Z ]+? ===/); + + if (sections.length < 3) { + log_detail(`Expected at least 2 sections in output, got: ${sections.length - 1}`); + process.exit(1); + } + + try { + const result: { skeletonData: any, skeletonState: any, animationState?: any } = { + skeletonData: JSON.parse(sections[1].trim()), + skeletonState: JSON.parse(sections[2].trim()) + }; + + // Animation state is optional (only present if animation was loaded) + if (sections.length >= 4 && sections[3].trim()) { + result.animationState = JSON.parse(sections[3].trim()); + } + + return result; + } catch (error) { + log_detail(`Failed to parse JSON output: ${error}`); + log_detail(`Section lengths: ${sections.map(s => s.length)}`); + process.exit(1); + } +} + +function fixFloatsAndPropertyIds(target: any, reference: any, path: string = '', targetLanguage: string = 'cpp'): any { + // Handle null values + if (reference === null || target === null) return target; + + // Handle circular references + if (reference === '' || target === '') return target; + + // Handle arrays + if (Array.isArray(reference) && Array.isArray(target)) { + return target.map((item: any, index: number) => { + if (index < reference.length) { + return fixFloatsAndPropertyIds(item, reference[index], `${path}[${index}]`, targetLanguage); + } + return item; + }); + } + + // Handle objects + if (typeof reference === 'object' && typeof target === 'object') { + const result: any = {}; + + // Copy all target properties + for (const key in target) { + if (target.hasOwnProperty(key)) { + if (reference.hasOwnProperty(key)) { + // Special cases where we always use Java value + if (key === 'propertyIds') { + // PropertyIds are encoded differently across all languages + result[key] = reference[key]; + } else if (targetLanguage === 'cpp' && ( + (reference[key] === null && target[key] === '') || + (reference[key] === '' && target[key] === null) || + (key === 'bones' && reference[key] === null && Array.isArray(target[key]) && target[key].length === 0) + )) { + // C++ specific fixes: null vs empty string, null vs empty array for bones + result[key] = reference[key]; + } else { + result[key] = fixFloatsAndPropertyIds(target[key], reference[key], `${path}.${key}`, targetLanguage); + } + } else { + result[key] = target[key]; + } + } + } + + // Add any missing properties from reference (shouldn't happen, but defensive) + for (const key in reference) { + if (reference.hasOwnProperty(key) && !result.hasOwnProperty(key)) { + result[key] = reference[key]; + } + } + + return result; + } + + // Handle primitive values + if (typeof reference === 'number' && typeof target === 'number') { + // Check if both are floats and within epsilon + const diff = Math.abs(reference - target); + if (diff <= 0.001 && diff > 0) { + return reference; // Use Java value + } + } + + + return target; +} + +function saveJsonFiles(args: TestArgs, parsed: any, javaParsed?: any, fixFloats?: boolean): void { + // Ensure output directory exists + const outputDir = join(SPINE_ROOT, 'tests', 'output'); + if (!existsSync(outputDir)) { + mkdirSync(outputDir, { recursive: true }); + } + + // Determine file format from skeleton path + const format = args.skeletonPath.endsWith('.json') ? 'json' : 'skel'; + + // Apply float fixing if enabled and we have Java reference data + let outputData = parsed; + if (fixFloats && javaParsed && args.language !== 'java') { + log_action('Fixing floats and propertyIds to match Java values'); + outputData = { + skeletonData: fixFloatsAndPropertyIds(parsed.skeletonData, javaParsed.skeletonData, '', args.language), + skeletonState: fixFloatsAndPropertyIds(parsed.skeletonState, javaParsed.skeletonState, '', args.language), + animationState: parsed.animationState && javaParsed.animationState ? + fixFloatsAndPropertyIds(parsed.animationState, javaParsed.animationState, '', args.language) : parsed.animationState + }; + log_ok(); + } + + // Save files with language and format in filename + writeFileSync(join(outputDir, `skeleton-data-${args.language}-${format}.json`), JSON.stringify(outputData.skeletonData, null, 2)); + writeFileSync(join(outputDir, `skeleton-state-${args.language}-${format}.json`), JSON.stringify(outputData.skeletonState, null, 2)); + + // Only save animation state if it exists + if (outputData.animationState) { + writeFileSync(join(outputDir, `animation-state-${args.language}-${format}.json`), JSON.stringify(outputData.animationState, null, 2)); + } +} + +function runTestsForFiles(language: string, skeletonPath: string, atlasPath: string, animationName?: string, fixFloats?: boolean): void { + const testArgs: TestArgs = { + language, + skeletonPath, + atlasPath, + animationName + }; + + const fileType = skeletonPath.endsWith('.json') ? 'JSON' : 'BINARY'; + const fileName = skeletonPath.split('/').pop() || skeletonPath; + + log_detail(`Testing ${fileType}: ${fileName}`); + + // Always run Java first (reference implementation) + const javaOutput = executeJava(testArgs); + const javaParsed = parseOutput(javaOutput); + saveJsonFiles({ ...testArgs, language: 'java' }, javaParsed); + + // Run target language + let targetOutput: string; + if (language === 'cpp') { + targetOutput = executeCpp(testArgs); + } else { + log_detail(`Unsupported target language: ${language}`); + process.exit(1); + } + + const targetParsed = parseOutput(targetOutput); + saveJsonFiles(testArgs, targetParsed, javaParsed, fixFloats); +} + +function main(): void { + const args = validateArgs(); + + log_title('Spine Runtime Test'); + + // Clean output directory first + cleanOutputDirectory(); + + if (args.files) { + // Auto-discovery mode: run tests for both JSON and binary files + const jsonFile = args.files.jsonPath.split('/').pop() || args.files.jsonPath; + const skelFile = args.files.skelPath.split('/').pop() || args.files.skelPath; + const atlasFile = args.files.atlasPath.split('/').pop() || args.files.atlasPath; + + log_detail(`Files: ${jsonFile}, ${skelFile}, ${atlasFile}`); + + runTestsForFiles(args.language, args.files.jsonPath, args.files.atlasPath, args.animationName, args.fixFloats); + runTestsForFiles(args.language, args.files.skelPath, args.files.atlasPath, args.animationName, args.fixFloats); + + log_summary('✓ All tests completed'); + log_detail(`JSON files saved to: ${join(SPINE_ROOT, 'tests', 'output')}`); + } else { + // Explicit paths mode: run test for single file + runTestsForFiles(args.language, args.skeletonPath!, args.atlasPath!, args.animationName, args.fixFloats); + log_summary('✓ Test completed'); + log_detail(`JSON files saved to: ${join(SPINE_ROOT, 'tests', 'output')}`); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} \ No newline at end of file