[tests] headless-test-runner.ts with language specific fixes (e.g. icon: null (Java) > icon: "" (C++), C++ JSON will have null as well)

This commit is contained in:
Mario Zechner 2025-07-21 00:58:43 +02:00
parent 1a10d185a3
commit ab469cbefd
4 changed files with 607 additions and 20 deletions

View File

@ -27,7 +27,14 @@
* THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*****************************************************************************/
#include <cstddef>
// 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

View File

@ -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;

View File

@ -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();

567
tests/src/headless-test-runner.ts Executable file
View File

@ -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 <target-language> -s <skeleton-name> [animation-name] [-f]');
log_detail(' headless-test-runner <target-language> <skeleton-path> <atlas-path> [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 <target-language> -s <skeleton-name> [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 <target-language> <skeleton-path> <atlas-path> [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 === '<circular>' || target === '<circular>') 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();
}