[tests] Improve snapshot testing infrastructure

- test.sh which given a language builds the headless test executable and runs the test with the given inputs
- generate-serializers.sh to (re-)generate all language specific serializers
- Improved README.md
- Removed headless-test-runner.ts, now fully expressed in more concise test.sh
This commit is contained in:
Mario Zechner 2025-07-15 15:13:45 +02:00
parent 366b965135
commit 0c74907da2
13 changed files with 272 additions and 795 deletions

View File

@ -1,3 +1,4 @@
package com.esotericsoftware.spine.utils;
import java.util.Locale;
@ -104,12 +105,7 @@ public class JsonWriter {
}
private String escapeString (String str) {
return str.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\b", "\\b")
.replace("\f", "\\f")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t");
return str.replace("\\", "\\\\").replace("\"", "\\\"").replace("\b", "\\b").replace("\f", "\\f").replace("\n", "\\n")
.replace("\r", "\\r").replace("\t", "\\t");
}
}

View File

@ -1,142 +1,128 @@
# Spine Runtimes Test Suite
# Spine Runtimes Snapshot Testing
This test suite is designed to ensure consistency across all Spine runtime implementations by comparing their outputs against the reference implementation (spine-libgdx).
This test suite implements snapshot testing to ensure all Spine runtime implementations produce identical outputs to the Java reference implementation (spine-libgdx).
## Purpose
## Why Snapshot Testing?
Unlike traditional unit tests, this test suite:
- Loads skeleton data and animations in each runtime
- Outputs all internal state in a consistent, diffable text format
- Compares outputs between runtimes to detect discrepancies
- Helps maintain consistency when porting changes from the reference implementation
When porting the Spine runtime to different languages, subtle bugs can be introduced - a constraint might be calculated incorrectly, the order of operations might have been ported incorrectly, or a default value might differ. Traditional unit tests can't catch all these discrepancies.
## HeadlessTest Locations
We use snapshot testing to solve this. Each runtime serializes its entire object graph to a standardized format, allowing byte-for-byte comparison between the reference implementation and ports. If outputs differ, we know exactly where the port diverged from the reference.
Each runtime has a HeadlessTest program that outputs skeleton data in a standardized format:
## The Challenge: Automatic Serializer Generation
- **Java (Reference)**: `spine-libgdx/spine-libgdx-tests/src/com/esotericsoftware/spine/HeadlessTest.java`
- **C++**: `spine-cpp/tests/HeadlessTest.cpp`
- **C**: `spine-c/tests/headless-test.c`
- **TypeScript**: `spine-ts/spine-core/tests/HeadlessTest.ts`
To serialize the complete object graph, we need serializers that output every field and getter from every type in the runtime. Writing these by hand is:
- Tedious and error-prone
- Likely to miss fields or getters
- Difficult to maintain as the API evolves
## Running Individual HeadlessTests
We thus implement **automatic serializer generation** using API analysis and code generation.
### Java (spine-libgdx)
## How It Works
### 1. API Analysis and core generation
We use [`lsp-cli`](https://github.com/badlogic/lsp-cli) to analyze the Java reference implementation:
```bash
cd spine-libgdx
./gradlew :spine-libgdx-tests:runHeadlessTest -Pargs="<skeleton-path> <atlas-path> [animation-name]"
# Example with spineboy:
./gradlew :spine-libgdx-tests:runHeadlessTest -Pargs="../examples/spineboy/export/spineboy-pro.json ../examples/spineboy/export/spineboy.atlas walk"
./generate-serializers.sh
```
### C++ (spine-cpp)
```bash
cd spine-cpp
./build.sh # Build if needed
./build/headless-test <skeleton-path> <atlas-path> [animation-name]
This process:
1. **Analyzes Java API** (`analyze-java-api.ts`): Uses Language Server Protocol to discover all types, fields, and getters in spine-libgdx (`tests/output/spine-libgdx-symbols.json`)
2. **Generates IR** (`generate-serializer-ir.ts`): Creates an enriched Intermediate Representation with all serialization metadata (`tests/output/analysis-result.json`)
3. **Generates Serializers**: Language-specific generators create serializers from the IR:
- `generate-java-serializer.ts``spine-libgdx/spine-libgdx-tests/src/com/esotericsoftware/spine/utils/SkeletonSerializer.java`
- `generate-cpp-serializer.ts``spine-cpp/tests/SkeletonSerializer.h`
- C, C#, Dart, Haxe, Swift and TypeScript TBD
# Example with spineboy:
./build/headless-test ../examples/spineboy/export/spineboy-pro.json ../examples/spineboy/export/spineboy.atlas walk
The IR contains everything needed to generate identical serializers across languages:
- Type hierarchies and inheritance chains
- All properties (fields and getters) per type
- Enum value mappings
- Abstract type handling with instanceof chains
- Property categorization (primitive, object, array, enum)
### 2. Snapshot Testing
Each runtime has a `HeadlessTest` that:
1. **Loads** a skeleton file and atlas
2. **Creates** a SkeletonData structure and serializes it
3. **Constructs** a Skeleton and AnimationState from the SkeletonData
4. **Applies** an animation (if specified)
5. **Serializes** the resulting Skeleton and AnimationState
Run tests with:
```bash
# Test any runtime
./test.sh <language> <skeleton-path> <atlas-path> [animation-name]
# Compare Java vs C++ for spineboy's walk animation
./test.sh java ../examples/spineboy/export/spineboy-pro.skel ../examples/spineboy/export/spineboy-pma.atlas walk > java-output.json
./test.sh cpp ../examples/spineboy/export/spineboy-pro.skel ../examples/spineboy/export/spineboy-pma.atlas walk > cpp-output.json
diff java-output.json cpp-output.json
```
### C (spine-c)
```bash
cd spine-c
./build.sh # Build if needed
./build/headless-test <skeleton-path> <atlas-path> [animation-name]
Languages: `java`, `cpp`, `c`, `ts`
# Example with spineboy:
./build/headless-test ../examples/spineboy/export/spineboy-pro.json ../examples/spineboy/export/spineboy.atlas walk
```
## Debugging Port Failures
### TypeScript (spine-ts)
```bash
cd spine-ts/spine-core
npx tsx tests/HeadlessTest.ts <skeleton-path> <atlas-path> [animation-name]
When outputs differ, you can pinpoint the exact issue:
# Example with spineboy:
npx tsx tests/HeadlessTest.ts ../../examples/spineboy/export/spineboy-pro.json ../../examples/spineboy/export/spineboy.atlas walk
```
2. **Value differences**: Different calculations or default values
3. **Animation differences**: Issues in constraint evaluation or animation mixing
## Running the Comparison Test
The main test runner compares all runtime outputs automatically:
```bash
./tests/headless-test-runner.ts <skeleton-path> <atlas-path> [animation-name]
```
This script will:
1. Check if each runtime's HeadlessTest needs rebuilding
2. Build any out-of-date HeadlessTests
3. Run each HeadlessTest with the same inputs
4. Compare outputs and report any differences
5. Save individual outputs to `tests/output/` for manual inspection
### Example Usage
```bash
# Test with spineboy walk animation
./tests/headless-test-runner.ts \
examples/spineboy/export/spineboy-pro.json \
examples/spineboy/export/spineboy-pma.atlas \
walk
# Test without animation (setup pose only)
./tests/headless-test-runner.ts \
examples/spineboy/export/spineboy-pro.json \
examples/spineboy/export/spineboy-pma.atlas
```
Example: If a transform constraint is ported incorrectly, the skeleton state after animation will differ, showing exactly which bones have wrong transforms. This is a starting point for debugging.
## Output Format
Each HeadlessTest outputs:
- **SKELETON DATA**: Static setup pose data (bones, slots, skins, animations metadata)
- **SKELETON STATE**: Runtime state after applying animations
- **ANIMATION STATE**: Current animation state with tracks and mixing information
The serializers produce consistent JSON with:
- All object properties in deterministic order
- Floats formatted to 6 decimal places
- Enums as strings
- Circular references marked as `"<circular>"`
- Type information for every object
The output uses consistent JSON formatting:
- Hierarchical structure with 2-space indentation
- Float values formatted to 6 decimal places
- Strings quoted, nulls explicitly shown
- Locale-independent number formatting (always uses `.` for decimals)
- Circular references marked as `"<circular>"` to prevent infinite recursion
- Each object includes a `"type"` field for easy identification
## Development Tools
### API Analyzer (Java)
Analyzes the spine-libgdx API to discover all types and their properties:
```bash
cd tests
npx tsx analyze-java-api.ts
# Output: output/analysis-result.json
Example output structure:
```
=== SKELETON DATA ===
{
"type": "SkeletonData",
"bones": [...],
"slots": [...],
...
}
=== SKELETON STATE ===
{
"type": "Skeleton",
"bones": [...],
"slots": [...],
...
}
=== ANIMATION STATE ===
{
"type": "AnimationState",
"tracks": [...],
...
}
```
### Serializer Generator (Java)
Generates SkeletonSerializer.java from the analysis:
```bash
cd tests
npx tsx generate-java-serializer.ts
# Output: ../spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/utils/SkeletonSerializer.java
## Project Structure
```
tests/
├── src/ # TypeScript source files
│ ├── headless-test-runner.ts # Main test runner
│ ├── analyze-java-api.ts # Java API analyzer
│ ├── generate-serializer-ir.ts # IR generator
│ ├── generate-java-serializer.ts # Java serializer generator
│ ├── generate-cpp-serializer.ts # C++ serializer generator
│ └── types.ts # Shared TypeScript types
├── test.sh # Main test script
├── generate-serializers.sh # Regenerate all serializers
└── output/ # Generated files (gitignored)
```
### Claude Prompt Generator
Generates a prompt for Claude to help port the serializer to other runtimes:
```bash
cd tests
npx tsx generate-claude-prompt.ts
# Output: output/port-serializer-prompt.txt
```
## HeadlessTest Locations
## Troubleshooting
If outputs differ between runtimes:
1. Check `tests/output/` for the full outputs from each runtime
2. Use a diff tool to compare the files
3. Common issues:
- Number formatting differences (should be fixed by locale settings)
- Missing or extra fields in data structures
- Different default values
- Rounding differences
- **Java**: `spine-libgdx/spine-libgdx-tests/src/com/esotericsoftware/spine/HeadlessTest.java`
- **C++**: `spine-cpp/tests/HeadlessTest.cpp`
- **C**: `spine-c/tests/headless-test.c`
- **TypeScript**: `spine-ts/spine-core/tests/HeadlessTest.ts`

View File

@ -1,165 +0,0 @@
#!/usr/bin/env tsx
import * as fs from 'fs';
import * as path from 'path';
import type { ClassInfo, PropertyInfo } from './types';
interface SerializedAnalysisResult {
classMap: [string, ClassInfo][];
accessibleTypes: string[];
abstractTypes: [string, string[]][];
allTypesToGenerate: string[];
typeProperties: [string, PropertyInfo[]][];
}
function generateClaudePrompt(analysisData: SerializedAnalysisResult): string {
const output: string[] = [];
// Convert arrays back to Maps
const classMap = new Map(analysisData.classMap);
const abstractTypes = new Map(analysisData.abstractTypes);
const typeProperties = new Map(analysisData.typeProperties);
output.push('# Spine Java Serialization Methods to Generate');
output.push('');
output.push('You need to generate writeXXX() methods for the SkeletonSerializer class.');
output.push('Each method should serialize all properties accessible via getter methods.');
output.push('');
output.push('## Task: Port the Java SkeletonSerializer to Other Runtimes');
output.push('');
output.push('A complete Java SkeletonSerializer has been generated at:');
output.push('`spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/utils/SkeletonSerializer.java`');
output.push('');
output.push('Port this serializer to:');
output.push('- **C++**: Create `SkeletonSerializer.h/cpp` in `spine-cpp/src/spine/`');
output.push('- **C**: Create `spine_skeleton_serializer.h/c` in `spine-c/src/spine/`');
output.push('- **TypeScript**: Create `SkeletonSerializer.ts` in `spine-ts/spine-core/src/`');
output.push('');
output.push('## Important Porting Notes');
output.push('');
output.push('1. **Language Differences**:');
output.push(' - Java uses getter methods (`getName()`), TypeScript may use properties (`.name`)');
output.push(' - C uses function calls (`spBoneData_getName()`)');
output.push(' - Adapt to each language\'s idioms');
output.push('2. **Type Checking**:');
output.push(' - Java uses `instanceof`');
output.push(' - C++ uses custom RTTI (`object->getRTTI().instanceOf()`)');
output.push(' - C uses type fields or function pointers');
output.push(' - TypeScript uses `instanceof`');
output.push('3. **Collections**:');
output.push(' - Java uses `Array<T>`, `IntArray`, `FloatArray`');
output.push(' - C++ uses `Vector<T>`');
output.push(' - C uses arrays with size fields');
output.push(' - TypeScript uses arrays `T[]`');
output.push('');
output.push('## Types Reference');
output.push('');
// First emit abstract types that need instanceof delegation
for (const [className, classInfo] of classMap) {
if ((classInfo.isAbstract || classInfo.isInterface) && classInfo.concreteImplementations && classInfo.concreteImplementations.length > 0) {
output.push(`### ${className} (${classInfo.isInterface ? 'interface' : 'abstract'})`);
if (classInfo.superTypes.length > 0) {
output.push(`Extends: ${classInfo.superTypes.join(', ')}`);
}
output.push('');
output.push('This is an abstract class. Generate a write' + className.split('.').pop() + '() method that checks instanceof for these concrete implementations:');
for (const impl of classInfo.concreteImplementations.sort()) {
output.push(`- ${impl}`);
}
output.push('');
output.push('Example implementation:');
output.push('```java');
output.push(`private void write${className.split('.').pop()}(JsonWriter json, ${className.split('.').pop()} obj) throws IOException {`);
const first = classInfo.concreteImplementations[0];
if (first) {
const shortName = first.split('.').pop()!;
output.push(` if (obj instanceof ${shortName}) {`);
output.push(` write${shortName}(json, (${shortName}) obj);`);
output.push(' } // ... etc for all concrete types');
output.push(' else {');
output.push(` throw new RuntimeException("Unknown ${className.split('.').pop()} type: " + obj.getClass().getName());`);
output.push(' }');
}
output.push('}');
output.push('```');
output.push('');
}
}
// Then emit concrete types
const sortedTypes = Array.from(analysisData.allTypesToGenerate).sort();
for (const typeName of sortedTypes) {
const classInfo = classMap.get(typeName)!;
output.push(`### ${typeName}`);
if (classInfo && classInfo.superTypes.length > 0) {
output.push(`Extends: ${classInfo.superTypes.join(', ')}`);
}
output.push('');
const properties = typeProperties.get(typeName) || [];
const getters = properties.filter(p => p.isGetter);
const fields = properties.filter(p => !p.isGetter);
if (getters.length > 0 || fields.length > 0) {
if (fields.length > 0) {
output.push('Public fields:');
output.push('```java');
for (const field of fields) {
output.push(`${field.name} // ${field.type}`);
}
output.push('```');
output.push('');
}
if (getters.length > 0) {
output.push('Getters to serialize:');
output.push('```java');
for (const getter of getters) {
output.push(`${getter.name} // returns ${getter.type}`);
}
output.push('```');
}
} else {
output.push('*No properties found*');
}
output.push('');
}
return output.join('\n');
}
async function main() {
try {
// Read analysis result
const analysisFile = path.join(process.cwd(), 'output', 'analysis-result.json');
if (!fs.existsSync(analysisFile)) {
console.error('Analysis result not found. Run analyze-java-api.ts first.');
process.exit(1);
}
const analysisData: SerializedAnalysisResult = JSON.parse(fs.readFileSync(analysisFile, 'utf8'));
// Generate Claude prompt
const prompt = generateClaudePrompt(analysisData);
// Write the prompt file
const outputFile = path.join(process.cwd(), 'output', 'port-serializer-to-other-runtimes.md');
fs.writeFileSync(outputFile, prompt);
console.log(`Claude prompt written to: ${outputFile}`);
} catch (error: any) {
console.error('Error:', error.message);
process.exit(1);
}
}
// Allow running as a script or importing the function
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
export { generateClaudePrompt };

View File

@ -3,18 +3,19 @@
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR/.."
pushd "$SCRIPT_DIR" > /dev/null
echo "Analyzing Java API..."
npx tsx tests/analyze-java-api.ts
npx tsx src/analyze-java-api.ts
echo "Generating serializer IR..."
npx tsx tests/generate-serializer-ir.ts
npx tsx src/generate-serializer-ir.ts
echo "Generating Java SkeletonSerializer..."
npx tsx tests/generate-java-serializer.ts
npx tsx src/generate-java-serializer.ts
echo "Generating C++ SkeletonSerializer..."
npx tsx tests/generate-cpp-serializer.ts
npx tsx src/generate-cpp-serializer.ts
echo "Done."
popd > /dev/null

View File

@ -1,306 +0,0 @@
#!/usr/bin/env npx tsx
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { promisify } from 'util';
const writeFile = promisify(fs.writeFile);
const mkdir = promisify(fs.mkdir);
const stat = promisify(fs.stat);
// Helper function to get modification time of a file
async function getMTime(filePath: string): Promise<number> {
try {
const stats = await stat(filePath);
return stats.mtimeMs;
} catch {
return 0;
}
}
// Helper function to find newest file in a directory pattern
async function getNewestFileTime(baseDir: string, patterns: string[]): Promise<number> {
let newest = 0;
for (const pattern of patterns) {
const globPattern = path.join(baseDir, pattern);
const files = execSync(`find "${baseDir}" -name "${pattern.split('/').pop()}" -type f 2>/dev/null || true`, {
encoding: 'utf8'
}).trim().split('\n').filter(f => f);
for (const file of files) {
const mtime = await getMTime(file);
if (mtime > newest) newest = mtime;
}
}
return newest;
}
// Parse command line arguments
const args = process.argv.slice(2);
if (args.length < 2) {
console.error('Usage: headless-test-runner.ts <skeleton-path> <atlas-path> [animation-name]');
process.exit(1);
}
const [skeletonPath, atlasPath, animationName] = args;
// Get absolute paths
const absoluteSkeletonPath = path.resolve(skeletonPath);
const absoluteAtlasPath = path.resolve(atlasPath);
// Script paths
const scriptDir = path.dirname(new URL(import.meta.url).pathname);
const rootDir = path.dirname(scriptDir);
const outputDir = path.join(scriptDir, 'output');
interface RuntimeConfig {
name: string;
buildCheck: () => Promise<boolean>;
build: () => void;
run: () => string;
}
// Runtime configurations
const runtimes: RuntimeConfig[] = [
{
name: 'java',
buildCheck: async () => {
const classPath = path.join(rootDir, 'spine-libgdx/spine-libgdx-tests/build/classes/java/main/com/esotericsoftware/spine/HeadlessTest.class');
if (!fs.existsSync(classPath)) return false;
// Check if any source files are newer than the class file
const classTime = await getMTime(classPath);
const sourceTime = await getNewestFileTime(
path.join(rootDir, 'spine-libgdx'),
['spine-libgdx/src/**/*.java', 'spine-libgdx-tests/src/**/*.java']
);
return sourceTime <= classTime;
},
build: () => {
console.log(' Building Java runtime...');
execSync('./gradlew :spine-libgdx-tests:build', {
cwd: path.join(rootDir, 'spine-libgdx'),
stdio: 'inherit'
});
},
run: () => {
const args = animationName
? `${absoluteSkeletonPath} ${absoluteAtlasPath} ${animationName}`
: `${absoluteSkeletonPath} ${absoluteAtlasPath}`;
const output = execSync(
`./gradlew -q --no-daemon --max-workers=1 :spine-libgdx-tests:runHeadlessTest -Pargs="${args}"`,
{
cwd: path.join(rootDir, 'spine-libgdx'),
encoding: 'utf8',
maxBuffer: 1024 * 1024 * 10 // 10MB buffer
}
);
// Find the start of actual output and return everything from there
const lines = output.split('\n');
const startIndex = lines.findIndex(line => line === '=== SKELETON DATA ===');
if (startIndex !== -1) {
return lines.slice(startIndex).join('\n').trim();
}
// Fallback to full output if marker not found
return output.trim();
}
},
{
name: 'cpp',
buildCheck: async () => {
const execPath = path.join(rootDir, 'spine-cpp/build/headless-test');
if (!fs.existsSync(execPath)) return false;
// Check if any source files are newer than the executable
const execTime = await getMTime(execPath);
const sourceTime = await getNewestFileTime(
path.join(rootDir, 'spine-cpp'),
['spine-cpp/src/**/*.cpp', 'spine-cpp/include/**/*.h', 'tests/DebugPrinter.cpp']
);
return sourceTime <= execTime;
},
build: () => {
console.log(' Building C++ runtime...');
execSync('./build.sh clean', {
cwd: path.join(rootDir, 'spine-cpp/'),
stdio: 'inherit'
});
},
run: () => {
return execSync(
animationName
? `./build/headless-test "${absoluteSkeletonPath}" "${absoluteAtlasPath}" "${animationName}"`
: `./build/headless-test "${absoluteSkeletonPath}" "${absoluteAtlasPath}"`,
{
cwd: path.join(rootDir, 'spine-cpp'),
encoding: 'utf8',
maxBuffer: 1024 * 1024 * 10 // 10MB buffer
}
).trim();
}
},
{
name: 'c',
buildCheck: async () => {
const execPath = path.join(rootDir, 'spine-c/build/headless-test');
if (!fs.existsSync(execPath)) return false;
// Check if any source files are newer than the executable
const execTime = await getMTime(execPath);
const sourceTime = await getNewestFileTime(
path.join(rootDir, 'spine-c'),
['src/**/*.c', 'include/**/*.h', 'tests/debug-printer.c']
);
return sourceTime <= execTime;
},
build: () => {
console.log(' Building C runtime...');
execSync('./build.sh', {
cwd: path.join(rootDir, 'spine-c/'),
stdio: 'inherit'
});
},
run: () => {
return execSync(
animationName
? `./build/headless-test "${absoluteSkeletonPath}" "${absoluteAtlasPath}" "${animationName}"`
: `./build/headless-test "${absoluteSkeletonPath}" "${absoluteAtlasPath}"`,
{
cwd: path.join(rootDir, 'spine-c'),
encoding: 'utf8',
maxBuffer: 1024 * 1024 * 10 // 10MB buffer
}
).trim();
}
},
{
name: 'ts',
buildCheck: async () => {
// For TypeScript, just check if the HeadlessTest.ts file exists
const headlessTestPath = path.join(rootDir, 'spine-ts/spine-core/tests/HeadlessTest.ts');
return fs.existsSync(headlessTestPath);
},
build: () => {}, // No build needed
run: () => {
return execSync(
animationName
? `npx tsx tests/HeadlessTest.ts "${absoluteSkeletonPath}" "${absoluteAtlasPath}" "${animationName}"`
: `npx tsx tests/HeadlessTest.ts "${absoluteSkeletonPath}" "${absoluteAtlasPath}"`,
{
cwd: path.join(rootDir, 'spine-ts/spine-core'),
encoding: 'utf8',
maxBuffer: 1024 * 1024 * 10 // 10MB buffer
}
).trim();
}
}
];
async function main() {
// Clean and recreate output directory
if (fs.existsSync(outputDir)) {
fs.rmSync(outputDir, { recursive: true });
}
await mkdir(outputDir, { recursive: true });
console.log('Comparing HeadlessTest outputs for:');
console.log(` Skeleton: ${absoluteSkeletonPath}`);
console.log(` Atlas: ${absoluteAtlasPath}`);
console.log(` Animation: ${animationName}`);
console.log('');
// Run all runtimes and collect outputs
const outputs: Record<string, string> = {};
for (const runtime of runtimes) {
console.log(`Running ${runtime.name.toUpperCase()} HeadlessTest...`);
try {
// Build if needed
if (!(await runtime.buildCheck())) {
runtime.build();
}
// Run and capture output
const output = runtime.run();
outputs[runtime.name] = output;
// Save output to file
await writeFile(path.join(outputDir, `${runtime.name}.txt`), output);
console.log(' Done.');
} catch (error) {
console.error(` Error: ${error instanceof Error ? error.message : JSON.stringify(error)}`);
outputs[runtime.name] = `Error: ${error instanceof Error ? error.message : JSON.stringify(error)}`;
}
}
console.log('');
console.log('Comparing outputs...');
console.log('');
// Compare outputs
const reference = 'java';
let allMatch = true;
for (const runtime of runtimes) {
if (runtime.name !== reference) {
process.stdout.write(`Comparing ${reference} vs ${runtime.name}: `);
if (outputs[reference] === outputs[runtime.name]) {
console.log('✓ MATCH');
} else {
console.log('✗ DIFFER');
allMatch = false;
// Show first few differences
const refLines = outputs[reference].split('\n');
const runtimeLines = outputs[runtime.name].split('\n');
const maxLines = Math.max(refLines.length, runtimeLines.length);
let diffCount = 0;
console.log(' First differences:');
for (let i = 0; i < maxLines && diffCount < 5; i++) {
if (refLines[i] !== runtimeLines[i]) {
diffCount++;
console.log(` Line ${i + 1}:`);
if (refLines[i] !== undefined) {
console.log(` - ${refLines[i]}`);
}
if (runtimeLines[i] !== undefined) {
console.log(` + ${runtimeLines[i]}`);
}
}
}
if (diffCount === 0 && refLines.length !== runtimeLines.length) {
console.log(` Different number of lines: ${refLines.length} vs ${runtimeLines.length}`);
}
// Save outputs for manual diff
console.log(` Full outputs saved to: ${outputDir}/`);
}
}
}
console.log('');
if (allMatch) {
console.log('✓ All outputs match!');
process.exit(0);
} else {
console.log(`✗ Outputs differ. Check the output files in ${outputDir}/`);
process.exit(1);
}
}
// Run main function
main().catch(error => {
console.error('Error:', error);
process.exit(1);
});

View File

@ -9,7 +9,7 @@ import type { Symbol, LspOutput, ClassInfo, PropertyInfo, AnalysisResult } from
const __dirname = path.dirname(fileURLToPath(import.meta.url));
function ensureOutputDir(): string {
const outputDir = path.resolve(__dirname, '..', 'output');
const outputDir = path.resolve(__dirname, '../../output');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
@ -84,25 +84,11 @@ function analyzeClasses(symbols: Symbol[]): Map<string, ClassInfo> {
typeParameters: symbol.typeParameters || []
};
// No need to parse superTypes from preview anymore - lsp-cli handles this properly now
// Check if abstract class
if (symbol.preview && symbol.preview.includes('abstract ')) {
classInfo.isAbstract = true;
}
// Log type parameter information if available
if (symbol.typeParameters && symbol.typeParameters.length > 0) {
console.error(`Class ${className} has type parameters: ${symbol.typeParameters.join(', ')}`);
}
if (symbol.supertypes) {
for (const supertype of symbol.supertypes) {
if (supertype.typeArguments && supertype.typeArguments.length > 0) {
console.error(` extends ${supertype.name}<${supertype.typeArguments.join(', ')}>`);
}
}
}
// Find all getter methods, public fields, inner classes, and enum values
if (symbol.children) {
for (const child of symbol.children) {
@ -270,15 +256,6 @@ function findAccessibleTypes(
const typeMatches = returnType.match(/\b([A-Z]\w+(?:\.[A-Z]\w+)*)\b/g);
if (typeMatches) {
for (const match of typeMatches) {
if (match === 'BoneLocal') {
console.error(`Found BoneLocal in return type of ${typeName}`);
}
if (classMap.has(match) && !visited.has(match)) {
toVisit.push(match);
if (match === 'BoneLocal') {
console.error(`Added BoneLocal to toVisit`);
}
}
// For non-qualified names, also try as inner class
if (!match.includes('.')) {
// Try as inner class of current type and its parents

View File

@ -3,7 +3,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import type { SerializerIR, PublicMethod, WriteMethod, Property } from './generate-serializer-ir';
import type { Property, SerializerIR } from './generate-serializer-ir';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@ -420,7 +420,7 @@ function generateCppFromIR(ir: SerializerIR): string {
async function main() {
try {
// Read the IR file
const irFile = path.resolve(__dirname, 'output', 'serializer-ir.json');
const irFile = path.resolve(__dirname, '../../output/serializer-ir.json');
if (!fs.existsSync(irFile)) {
console.error('Serializer IR not found. Run generate-serializer-ir.ts first.');
process.exit(1);
@ -434,10 +434,7 @@ async function main() {
// Write the C++ file
const cppFile = path.resolve(
__dirname,
'..',
'spine-cpp',
'tests',
'SkeletonSerializer.h'
'../../../spine-cpp/tests/SkeletonSerializer.h'
);
fs.mkdirSync(path.dirname(cppFile), { recursive: true });

View File

@ -3,7 +3,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import type { SerializerIR, PublicMethod, WriteMethod, Property } from './generate-serializer-ir';
import type { Property, SerializerIR, WriteMethod } from './generate-serializer-ir';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@ -290,7 +290,7 @@ function generateJavaFromIR(ir: SerializerIR): string {
async function main() {
try {
// Read the IR file
const irFile = path.resolve(__dirname, 'output', 'serializer-ir.json');
const irFile = path.resolve(__dirname, '../../output/serializer-ir.json');
if (!fs.existsSync(irFile)) {
console.error('Serializer IR not found. Run generate-serializer-ir.ts first.');
process.exit(1);
@ -304,15 +304,7 @@ async function main() {
// Write the Java file
const javaFile = path.resolve(
__dirname,
'..',
'spine-libgdx',
'spine-libgdx-tests',
'src',
'com',
'esotericsoftware',
'spine',
'utils',
'SkeletonSerializer.java'
'../../../spine-libgdx/spine-libgdx-tests/src/com/esotericsoftware/spine/utils/SkeletonSerializer.java'
);
fs.mkdirSync(path.dirname(javaFile), { recursive: true });

View File

@ -539,7 +539,7 @@ function analyzePropertyWithDetails(prop: PropertyInfo, propName: string, getter
async function main() {
try {
// Read analysis result
const analysisFile = path.resolve(__dirname, '..', 'output', 'analysis-result.json');
const analysisFile = path.resolve(__dirname, '../../output/analysis-result.json');
if (!fs.existsSync(analysisFile)) {
console.error('Analysis result not found. Run analyze-java-api.ts first.');
process.exit(1);
@ -551,7 +551,7 @@ async function main() {
const ir = generateSerializerIR(analysisData);
// Write the IR file
const irFile = path.resolve(__dirname, 'output', 'serializer-ir.json');
const irFile = path.resolve(__dirname, '../../output/serializer-ir.json');
fs.mkdirSync(path.dirname(irFile), { recursive: true });
fs.writeFileSync(irFile, JSON.stringify(ir, null, 2));

View File

@ -1,30 +1,6 @@
import { Symbol, Supertype, LspOutput } from '@mariozechner/lsp-cli';
// Shared types for the Spine serializer generator
// Match lsp-cli's Supertype interface
export interface Supertype {
name: string;
typeArguments?: string[];
}
// Match lsp-cli's SymbolInfo interface (we call it Symbol for backward compatibility)
export interface Symbol {
name: string;
kind: string;
file: string;
preview: string;
documentation?: string;
typeParameters?: string[];
supertypes?: Supertype[];
children?: Symbol[];
// We don't need range and definition for our use case
}
export interface LspOutput {
language: string;
directory: string;
symbols: Symbol[];
}
export interface ClassInfo {
className: string;
superTypes: string[]; // Just the names for backward compatibility

23
tests/test.sh Executable file
View File

@ -0,0 +1,23 @@
#!/bin/bash
# Change to the script's directory
cd "$(dirname "$0")"
# Check if node/npm is available
if ! command -v npm &> /dev/null; then
echo "Error: npm is not installed" >&2
exit 1
fi
# Install dependencies if node_modules doesn't exist
if [ ! -d "node_modules" ]; then
echo "Installing dependencies..." >&2
if ! npm install > /tmp/npm-install.log 2>&1; then
echo "npm install failed! Output:" >&2
cat /tmp/npm-install.log >&2
exit 1
fi
fi
# Run the TypeScript headless test runner with all arguments
npx tsx src/headless-test-runner.ts "$@"

View File

@ -12,6 +12,6 @@
"types": ["node"]
},
"include": [
"*.ts"
"src/*.ts"
]
}