From 0c74907da2ce1258efd638f449183c046e341ccd Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 15 Jul 2025 15:13:45 +0200 Subject: [PATCH] [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 --- spine-cpp/tests/HeadlessTest.cpp | 2 +- .../spine/utils/JsonWriter.java | 186 ++++++----- tests/README.md | 216 ++++++------- tests/generate-claude-prompt.ts | 165 ---------- ...enerate-all.sh => generate-serializers.sh} | 13 +- tests/headless-test-runner.ts | 306 ------------------ tests/{ => src}/analyze-java-api.ts | 71 ++-- tests/{ => src}/generate-cpp-serializer.ts | 17 +- tests/{ => src}/generate-java-serializer.ts | 34 +- tests/{ => src}/generate-serializer-ir.ts | 4 +- tests/{ => src}/types.ts | 28 +- tests/test.sh | 23 ++ tests/tsconfig.json | 2 +- 13 files changed, 272 insertions(+), 795 deletions(-) delete mode 100644 tests/generate-claude-prompt.ts rename tests/{regenerate-all.sh => generate-serializers.sh} (53%) delete mode 100755 tests/headless-test-runner.ts rename tests/{ => src}/analyze-java-api.ts (95%) rename tests/{ => src}/generate-cpp-serializer.ts (97%) rename tests/{ => src}/generate-java-serializer.ts (96%) rename tests/{ => src}/generate-serializer-ir.ts (99%) rename tests/{ => src}/types.ts (72%) create mode 100755 tests/test.sh diff --git a/spine-cpp/tests/HeadlessTest.cpp b/spine-cpp/tests/HeadlessTest.cpp index 058c874bf..c02627f72 100644 --- a/spine-cpp/tests/HeadlessTest.cpp +++ b/spine-cpp/tests/HeadlessTest.cpp @@ -132,7 +132,7 @@ int main(int argc, char *argv[]) { // Use SkeletonSerializer for JSON output SkeletonSerializer serializer; - + // Print skeleton data printf("=== SKELETON DATA ===\n"); printf("%s", serializer.serializeSkeletonData(skeletonData).buffer()); diff --git a/spine-libgdx/spine-libgdx-tests/src/com/esotericsoftware/spine/utils/JsonWriter.java b/spine-libgdx/spine-libgdx-tests/src/com/esotericsoftware/spine/utils/JsonWriter.java index ca0e2fb42..6efdc35e5 100644 --- a/spine-libgdx/spine-libgdx-tests/src/com/esotericsoftware/spine/utils/JsonWriter.java +++ b/spine-libgdx/spine-libgdx-tests/src/com/esotericsoftware/spine/utils/JsonWriter.java @@ -1,115 +1,111 @@ + package com.esotericsoftware.spine.utils; import java.util.Locale; public class JsonWriter { - private final StringBuffer buffer = new StringBuffer(); - private int depth = 0; - private boolean needsComma = false; + private final StringBuffer buffer = new StringBuffer(); + private int depth = 0; + private boolean needsComma = false; - public void writeObjectStart() { - writeCommaIfNeeded(); - buffer.append("{"); - depth++; - needsComma = false; - } + public void writeObjectStart () { + writeCommaIfNeeded(); + buffer.append("{"); + depth++; + needsComma = false; + } - public void writeObjectEnd() { - depth--; - if (needsComma) { - buffer.append("\n"); - writeIndent(); - } - buffer.append("}"); - needsComma = true; - } + public void writeObjectEnd () { + depth--; + if (needsComma) { + buffer.append("\n"); + writeIndent(); + } + buffer.append("}"); + needsComma = true; + } - public void writeArrayStart() { - writeCommaIfNeeded(); - buffer.append("["); - depth++; - needsComma = false; - } + public void writeArrayStart () { + writeCommaIfNeeded(); + buffer.append("["); + depth++; + needsComma = false; + } - public void writeArrayEnd() { - depth--; - if (needsComma) { - buffer.append("\n"); - writeIndent(); - } - buffer.append("]"); - needsComma = true; - } + public void writeArrayEnd () { + depth--; + if (needsComma) { + buffer.append("\n"); + writeIndent(); + } + buffer.append("]"); + needsComma = true; + } - public void writeName(String name) { - writeCommaIfNeeded(); - buffer.append("\n"); - writeIndent(); - buffer.append("\"").append(name).append("\": "); - needsComma = false; - } + public void writeName (String name) { + writeCommaIfNeeded(); + buffer.append("\n"); + writeIndent(); + buffer.append("\"").append(name).append("\": "); + needsComma = false; + } - public void writeValue(String value) { - writeCommaIfNeeded(); - if (value == null) { - buffer.append("null"); - } else { - buffer.append("\"").append(escapeString(value)).append("\""); - } - needsComma = true; - } + public void writeValue (String value) { + writeCommaIfNeeded(); + if (value == null) { + buffer.append("null"); + } else { + buffer.append("\"").append(escapeString(value)).append("\""); + } + needsComma = true; + } - public void writeValue(float value) { - writeCommaIfNeeded(); - buffer.append(String.format(Locale.US, "%.6f", value).replaceAll("0+$", "").replaceAll("\\.$", "")); - needsComma = true; - } + public void writeValue (float value) { + writeCommaIfNeeded(); + buffer.append(String.format(Locale.US, "%.6f", value).replaceAll("0+$", "").replaceAll("\\.$", "")); + needsComma = true; + } - public void writeValue(int value) { - writeCommaIfNeeded(); - buffer.append(String.valueOf(value)); - needsComma = true; - } + public void writeValue (int value) { + writeCommaIfNeeded(); + buffer.append(String.valueOf(value)); + needsComma = true; + } - public void writeValue(boolean value) { - writeCommaIfNeeded(); - buffer.append(String.valueOf(value)); - needsComma = true; - } + public void writeValue (boolean value) { + writeCommaIfNeeded(); + buffer.append(String.valueOf(value)); + needsComma = true; + } - public void writeNull() { - writeCommaIfNeeded(); - buffer.append("null"); - needsComma = true; - } + public void writeNull () { + writeCommaIfNeeded(); + buffer.append("null"); + needsComma = true; + } - public void close() { - buffer.append("\n"); - } + public void close () { + buffer.append("\n"); + } - public String getString() { - return buffer.toString(); - } + public String getString () { + return buffer.toString(); + } - private void writeCommaIfNeeded() { - if (needsComma) { - buffer.append(","); - } - } + private void writeCommaIfNeeded () { + if (needsComma) { + buffer.append(","); + } + } - private void writeIndent() { - for (int i = 0; i < depth; i++) { - buffer.append(" "); - } - } + private void writeIndent () { + for (int i = 0; i < depth; i++) { + buffer.append(" "); + } + } - private String escapeString(String str) { - return str.replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\b", "\\b") - .replace("\f", "\\f") - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t"); - } -} \ No newline at end of file + private String escapeString (String str) { + return str.replace("\\", "\\\\").replace("\"", "\\\"").replace("\b", "\\b").replace("\f", "\\f").replace("\n", "\\n") + .replace("\r", "\\r").replace("\t", "\\t"); + } +} diff --git a/tests/README.md b/tests/README.md index 757938499..0c17e81d2 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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=" [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 [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 [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 [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 [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 [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 `""` +- 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 `""` 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` \ No newline at end of file diff --git a/tests/generate-claude-prompt.ts b/tests/generate-claude-prompt.ts deleted file mode 100644 index 59d3d5b1e..000000000 --- a/tests/generate-claude-prompt.ts +++ /dev/null @@ -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`, `IntArray`, `FloatArray`'); - output.push(' - C++ uses `Vector`'); - 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 }; \ No newline at end of file diff --git a/tests/regenerate-all.sh b/tests/generate-serializers.sh similarity index 53% rename from tests/regenerate-all.sh rename to tests/generate-serializers.sh index 4e3816462..ab69fd522 100755 --- a/tests/regenerate-all.sh +++ b/tests/generate-serializers.sh @@ -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." \ No newline at end of file +echo "Done." +popd > /dev/null \ No newline at end of file diff --git a/tests/headless-test-runner.ts b/tests/headless-test-runner.ts deleted file mode 100755 index 57dfa7e31..000000000 --- a/tests/headless-test-runner.ts +++ /dev/null @@ -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 { - 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 { - 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 [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; - 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 = {}; - - 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); -}); \ No newline at end of file diff --git a/tests/analyze-java-api.ts b/tests/src/analyze-java-api.ts similarity index 95% rename from tests/analyze-java-api.ts rename to tests/src/analyze-java-api.ts index bb9d7c4f6..2495e7326 100755 --- a/tests/analyze-java-api.ts +++ b/tests/src/analyze-java-api.ts @@ -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 }); } @@ -64,7 +64,7 @@ function analyzeClasses(symbols: Symbol[]): Map { function processSymbol(symbol: Symbol, parentName?: string) { if (symbol.kind !== 'class' && symbol.kind !== 'enum' && symbol.kind !== 'interface') return; - + // Filter: only process symbols in spine-libgdx/src, excluding SkeletonSerializer if (!symbol.file.startsWith(srcPath)) return; if (symbol.file.endsWith('SkeletonSerializer.java')) return; @@ -83,26 +83,12 @@ function analyzeClasses(symbols: Symbol[]): Map { isEnum: symbol.kind === 'enum', 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) { @@ -217,13 +203,13 @@ function findAccessibleTypes( } const classInfo = classMap.get(typeName)!; - + // Add the type itself if it's concrete if (!classInfo.isAbstract && !classInfo.isInterface && !classInfo.isEnum) { accessible.add(typeName); console.error(`Added concrete type: ${typeName}`); } - + // Find all concrete subclasses of this type const concreteClasses = findConcreteSubclasses(typeName); concreteClasses.forEach(c => accessible.add(c)); @@ -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 @@ -306,23 +283,23 @@ function loadExclusions(): { types: Set, methods: Map(); const methods = new Map>(); const fields = new Map>(); - + if (!fs.existsSync(exclusionsPath)) { return { types, methods, fields }; } - + const content = fs.readFileSync(exclusionsPath, 'utf-8'); const lines = content.split('\n'); - + for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; - + const parts = trimmed.split(/\s+/); if (parts.length < 2) continue; - + const [type, className, property] = parts; - + switch (type) { case 'type': types.add(className); @@ -345,7 +322,7 @@ function loadExclusions(): { types: Set, methods: Map, className: string, s // Build type parameter mapping based on supertype details const typeParamMap = new Map(); - + // Helper to build parameter mappings for a specific supertype function buildTypeParamMapping(currentClass: string, targetSupertype: string): Map { const mapping = new Map(); const currentInfo = classMap.get(currentClass); if (!currentInfo || !currentInfo.superTypeDetails) return mapping; - + // Find the matching supertype for (const supertype of currentInfo.superTypeDetails) { if (supertype.name === targetSupertype && supertype.typeArguments) { @@ -437,13 +414,13 @@ function getAllProperties(classMap: Map, className: string, s for (const superType of classInfo.superTypes) { // Build type parameter mapping for this supertype const supertypeMapping = buildTypeParamMapping(currentClass, superType); - + // Compose mappings - resolve type arguments through current mapping const composedMapping = new Map(); for (const [param, arg] of supertypeMapping) { composedMapping.set(param, resolveType(arg, currentTypeMap)); } - + // Try to find the supertype - it might be unqualified let superClassInfo = classMap.get(superType); @@ -548,7 +525,7 @@ function analyzeForSerialization(classMap: Map, symbolsFile: // Get only concrete implementations const concreteImplementations = findAllImplementations(classMap, className, true); classInfo.concreteImplementations = concreteImplementations; - + // Get all implementations (including intermediate abstract types) const allImplementations = findAllImplementations(classMap, className, false); classInfo.allImplementations = allImplementations; @@ -619,7 +596,7 @@ function analyzeForSerialization(classMap: Map, symbolsFile: // Load exclusions const exclusions = loadExclusions(); - + // Filter out excluded types from allTypesToGenerate const filteredTypesToGenerate = new Set(); for (const typeName of allTypesToGenerate) { @@ -629,12 +606,12 @@ function analyzeForSerialization(classMap: Map, symbolsFile: console.error(`Excluding type: ${typeName}`); } } - - + + // Update allTypesToGenerate to the filtered set allTypesToGenerate.clear(); filteredTypesToGenerate.forEach(type => allTypesToGenerate.add(type)); - + // Collect all properties for each type (including inherited ones) const typeProperties = new Map(); for (const typeName of allTypesToGenerate) { @@ -649,13 +626,13 @@ function analyzeForSerialization(classMap: Map, symbolsFile: typeProperties.set(abstractType, props); } } - + // Second pass: find additional concrete types referenced in properties const additionalTypes = new Set(); for (const [typeName, props] of typeProperties) { for (const prop of props) { const propType = prop.type.replace(/@Null\s+/g, '').trim(); - + // Check if it's a simple type name const typeMatch = propType.match(/^([A-Z]\w+)$/); if (typeMatch) { @@ -672,7 +649,7 @@ function analyzeForSerialization(classMap: Map, symbolsFile: } } } - + // Add the additional types (filtered) additionalTypes.forEach(type => { if (!isTypeExcluded(type, exclusions)) { @@ -681,7 +658,7 @@ function analyzeForSerialization(classMap: Map, symbolsFile: console.error(`Excluding additional type: ${type}`); } }); - + // Get properties for the additional types too for (const typeName of additionalTypes) { if (!isTypeExcluded(typeName, exclusions)) { diff --git a/tests/generate-cpp-serializer.ts b/tests/src/generate-cpp-serializer.ts similarity index 97% rename from tests/generate-cpp-serializer.ts rename to tests/src/generate-cpp-serializer.ts index 587f7267d..32318de43 100644 --- a/tests/generate-cpp-serializer.ts +++ b/tests/src/generate-cpp-serializer.ts @@ -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)); @@ -393,9 +393,9 @@ function generateCppFromIR(ir: SerializerIR): string { // Add reference versions for write methods (excluding custom implementations) cppOutput.push(' // Reference versions of write methods'); - const writeMethods = ir.writeMethods.filter(m => - !m.isAbstractType && - m.name !== 'writeSkin' && + const writeMethods = ir.writeMethods.filter(m => + !m.isAbstractType && + m.name !== 'writeSkin' && m.name !== 'writeSkinEntry' ); for (const method of writeMethods) { @@ -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 }); @@ -460,4 +457,4 @@ if (import.meta.url === `file://${process.argv[1]}`) { main(); } -export { generateCppFromIR }; \ No newline at end of file +export { generateCppFromIR }; diff --git a/tests/generate-java-serializer.ts b/tests/src/generate-java-serializer.ts similarity index 96% rename from tests/generate-java-serializer.ts rename to tests/src/generate-java-serializer.ts index 4ef278d85..bb6c67754 100644 --- a/tests/generate-java-serializer.ts +++ b/tests/src/generate-java-serializer.ts @@ -3,19 +3,19 @@ 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)); function generatePropertyCode(property: Property, indent: string, method?: WriteMethod): string[] { const lines: string[] = []; const accessor = `obj.${property.getter}`; - + switch (property.kind) { case "primitive": lines.push(`${indent}json.writeValue(${accessor});`); break; - + case "object": if (property.isNullable) { lines.push(`${indent}if (${accessor} == null) {`); @@ -27,7 +27,7 @@ function generatePropertyCode(property: Property, indent: string, method?: Write lines.push(`${indent}${property.writeMethodCall}(${accessor});`); } break; - + case "enum": if (property.isNullable) { lines.push(`${indent}if (${accessor} == null) {`); @@ -39,17 +39,17 @@ function generatePropertyCode(property: Property, indent: string, method?: Write lines.push(`${indent}json.writeValue(${accessor}.name());`); } break; - + case "array": // Special handling for Skin attachments - sort by slot index const isSkinAttachments = method?.paramType === 'Skin' && property.name === 'attachments' && property.elementType === 'SkinEntry'; const sortedAccessor = isSkinAttachments ? 'sortedAttachments' : accessor; - + if (isSkinAttachments) { lines.push(`${indent}Array<${property.elementType}> sortedAttachments = new Array<>(${accessor});`); lines.push(`${indent}sortedAttachments.sort((a, b) -> Integer.compare(a.getSlotIndex(), b.getSlotIndex()));`); } - + if (property.isNullable) { lines.push(`${indent}if (${accessor} == null) {`); lines.push(`${indent} json.writeNull();`); @@ -76,7 +76,7 @@ function generatePropertyCode(property: Property, indent: string, method?: Write lines.push(`${indent}json.writeArrayEnd();`); } break; - + case "nestedArray": if (property.isNullable) { lines.push(`${indent}if (${accessor} == null) {`); @@ -109,7 +109,7 @@ function generatePropertyCode(property: Property, indent: string, method?: Write } break; } - + return lines; } @@ -158,7 +158,7 @@ function generateJavaFromIR(ir: SerializerIR): string { for (const method of ir.writeMethods) { const shortName = method.paramType.split('.').pop()!; const className = method.paramType.includes('.') ? method.paramType : shortName; - + javaOutput.push(` private void ${method.name}(${className} obj) {`); if (method.isAbstractType) { @@ -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 }); @@ -334,4 +326,4 @@ if (import.meta.url === `file://${process.argv[1]}`) { main(); } -export { generateJavaFromIR }; \ No newline at end of file +export { generateJavaFromIR }; diff --git a/tests/generate-serializer-ir.ts b/tests/src/generate-serializer-ir.ts similarity index 99% rename from tests/generate-serializer-ir.ts rename to tests/src/generate-serializer-ir.ts index 220a50da2..b114b1b39 100644 --- a/tests/generate-serializer-ir.ts +++ b/tests/src/generate-serializer-ir.ts @@ -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)); diff --git a/tests/types.ts b/tests/src/types.ts similarity index 72% rename from tests/types.ts rename to tests/src/types.ts index bafa7c0ef..3b7e71338 100644 --- a/tests/types.ts +++ b/tests/src/types.ts @@ -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 diff --git a/tests/test.sh b/tests/test.sh new file mode 100755 index 000000000..492adf61d --- /dev/null +++ b/tests/test.sh @@ -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 "$@" \ No newline at end of file diff --git a/tests/tsconfig.json b/tests/tsconfig.json index ee3d819ff..7517ec3b1 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -12,6 +12,6 @@ "types": ["node"] }, "include": [ - "*.ts" + "src/*.ts" ] } \ No newline at end of file