spine-runtimes/tests/src/headless-test-runner.ts
2025-07-29 21:39:14 +02:00

707 lines
21 KiB
JavaScript
Executable File

#!/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 fat jar exists (specifically look for the headless test jar)
const fatJarFiles = execSync(`ls ${buildDir}/spine-headless-test-*.jar 2>/dev/null || true`, { encoding: 'utf8' }).trim();
if (!fatJarFiles) return true;
// Get jar modification time
const jarTime = statSync(fatJarFiles.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;
}
}
function needsHaxeBuild (): boolean {
const haxeDir = join(SPINE_ROOT, 'spine-haxe');
const buildDir = join(haxeDir, 'build');
const headlessTest = join(buildDir, 'headless-test', 'HeadlessTest');
try {
// Check if executable exists
if (!existsSync(headlessTest)) return true;
// Get executable modification time
const execTime = statSync(headlessTest).mtime.getTime();
// Check Haxe source files
const haxeSourceTime = getNewestFileTime(join(haxeDir, 'spine-haxe'), '*.hx');
const testSourceTime = getNewestFileTime(join(haxeDir, 'tests'), '*.hx');
const buildScriptTime = getNewestFileTime(haxeDir, 'build-headless-test.sh');
const newestSourceTime = Math.max(haxeSourceTime, testSourceTime, buildScriptTime);
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', 'haxe'].includes(language)) {
log_detail(`Invalid target language: ${language}. Must be cpp or haxe`);
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 fat jar file
const buildDir = join(testDir, 'build', 'libs');
const fatJarFiles = execSync(`ls ${buildDir}/spine-headless-test-*.jar`, { encoding: 'utf8' }).trim().split('\n');
if (fatJarFiles.length === 0) {
log_detail('No fat jar files found in build/libs directory');
process.exit(1);
}
const jarFile = fatJarFiles[0]; // Use the first fat 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 debug sanitize', {
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 executeHaxe (args: TestArgs): string {
const haxeDir = join(SPINE_ROOT, 'spine-haxe');
const testsDir = join(haxeDir, 'tests');
if (!existsSync(testsDir)) {
log_detail(`Haxe tests directory not found: ${testsDir}`);
process.exit(1);
}
// Check if we need to build
if (needsHaxeBuild()) {
log_action('Building Haxe HeadlessTest');
try {
execSync('./build-headless-test.sh', {
cwd: haxeDir,
stdio: ['inherit', 'pipe', 'inherit']
});
log_ok();
} catch (error: any) {
log_fail();
log_detail(`Haxe build failed: ${error.message}`);
process.exit(1);
}
}
// Run the headless test
const testArgs = [args.skeletonPath, args.atlasPath];
if (args.animationName) {
testArgs.push(args.animationName);
}
const buildDir = join(haxeDir, 'build');
const headlessTest = join(buildDir, 'headless-test', 'HeadlessTest');
if (!existsSync(headlessTest)) {
log_detail(`Haxe headless-test executable not found: ${headlessTest}`);
process.exit(1);
}
log_action('Running Haxe 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(`Haxe 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 if (language === 'haxe') {
targetOutput = executeHaxe(testArgs);
} else {
log_detail(`Unsupported target language: ${language}`);
process.exit(1);
}
const targetParsed = parseOutput(targetOutput);
saveJsonFiles(testArgs, targetParsed, javaParsed, fixFloats);
}
function verifyOutputsMatch (targetLanguage: string): void {
const outputDir = join(SPINE_ROOT, 'tests', 'output');
const outputFiles = [
`skeleton-data-java-json.json`,
`skeleton-data-${targetLanguage}-json.json`,
`skeleton-state-java-json.json`,
`skeleton-state-${targetLanguage}-json.json`,
`skeleton-data-java-skel.json`,
`skeleton-data-${targetLanguage}-skel.json`,
`skeleton-state-java-skel.json`,
`skeleton-state-${targetLanguage}-skel.json`
];
// Check if all files exist
const missingFiles = outputFiles.filter(file => !existsSync(join(outputDir, file)));
if (missingFiles.length > 0) {
log_detail(`Skipping diff check - missing files: ${missingFiles.join(', ')}`);
return;
}
log_action(`Verifying Java and ${targetLanguage} outputs match`);
const comparisons = [
[`skeleton-data-java-json.json`, `skeleton-data-${targetLanguage}-json.json`],
[`skeleton-state-java-json.json`, `skeleton-state-${targetLanguage}-json.json`],
[`skeleton-data-java-skel.json`, `skeleton-data-${targetLanguage}-skel.json`],
[`skeleton-state-java-skel.json`, `skeleton-state-${targetLanguage}-skel.json`]
];
let allMatch = true;
for (const [javaFile, targetFile] of comparisons) {
try {
const javaContent = execSync(`cat "${join(outputDir, javaFile)}"`, { encoding: 'utf8' });
const targetContent = execSync(`cat "${join(outputDir, targetFile)}"`, { encoding: 'utf8' });
if (javaContent !== targetContent) {
allMatch = false;
console.error(`\n❌ Files differ: ${javaFile} vs ${targetFile}`);
}
} catch (error: any) {
allMatch = false;
console.error(`\n❌ Error comparing ${javaFile} vs ${targetFile}: ${error.message}`);
}
}
if (allMatch) {
log_ok();
} else {
log_fail();
console.error(`\n❌ Java and ${targetLanguage} outputs do not match`);
process.exit(1);
}
}
function main (): void {
const args = validateArgs();
log_title('Spine Runtime Test');
// Clean output directory first
// TODO annoying during development of generators as it also smokes generator outputs
// 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')}`);
}
// Verify outputs match
verifyOutputsMatch(args.language);
}
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}