Initial commit
This commit is contained in:
424
skills/unity-test-runner/SKILL.md
Normal file
424
skills/unity-test-runner/SKILL.md
Normal file
@@ -0,0 +1,424 @@
|
||||
---
|
||||
name: unity-test-runner
|
||||
description: Execute and analyze Unity Test Framework tests from the command line. This skill automates test execution for Unity projects by detecting the Unity Editor, configuring test parameters (EditMode/PlayMode), running tests via CLI, parsing XML results, and generating detailed failure reports. Use this when running Unity tests, validating game logic, or debugging test failures.
|
||||
---
|
||||
|
||||
# Unity Test Runner
|
||||
|
||||
## Overview
|
||||
|
||||
This skill enables automated execution and analysis of Unity Test Framework tests directly from the command line. It handles the complete test workflow: detecting Unity Editor installations across platforms (Windows/macOS/Linux), configuring test parameters, executing tests in EditMode or PlayMode, parsing NUnit XML results, and generating detailed failure reports with actionable insights.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when:
|
||||
- Executing Unity Test Framework tests from command line
|
||||
- Running PlayMode or EditMode tests for game logic validation
|
||||
- Analyzing test failures and generating failure reports
|
||||
- Integrating Unity tests into CI/CD pipelines
|
||||
- Debugging test failures with detailed stack traces and file locations
|
||||
- Validating Unity project changes before commits
|
||||
|
||||
**Example user requests:**
|
||||
- "Run all Unity tests in my project"
|
||||
- "Execute PlayMode tests and show me the results"
|
||||
- "Run tests in the Combat category"
|
||||
- "Check if my Unity tests are passing"
|
||||
- "Run EditMode tests only"
|
||||
|
||||
## Workflow
|
||||
|
||||
Follow this workflow when the skill is invoked:
|
||||
|
||||
### 1. Detect Unity Editor Installation
|
||||
|
||||
Use the `find-unity-editor.js` script to automatically locate the Unity Editor:
|
||||
|
||||
```bash
|
||||
node scripts/find-unity-editor.js --json
|
||||
```
|
||||
|
||||
**Script behavior:**
|
||||
- Scans platform-specific default installation paths
|
||||
- Detects all installed Unity versions
|
||||
- Returns the latest version by default
|
||||
- Can target specific version with `--version <version>` flag
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"found": true,
|
||||
"editorPath": "C:\\Program Files\\Unity\\Hub\\Editor\\2021.3.15f1\\Editor\\Unity.exe",
|
||||
"version": "2021.3.15f1",
|
||||
"platform": "win32",
|
||||
"allVersions": ["2021.3.15f1", "2020.3.30f1"]
|
||||
}
|
||||
```
|
||||
|
||||
**If multiple versions are found:**
|
||||
1. Present all available versions to the user
|
||||
2. Ask user to confirm which version to use
|
||||
3. Or use the latest version by default
|
||||
|
||||
**If no Unity Editor is found:**
|
||||
- Report error with searched paths
|
||||
- Ask user to provide Unity Editor path manually
|
||||
- Store the path for future use
|
||||
|
||||
### 2. Verify Unity Project Path
|
||||
|
||||
Confirm the current directory contains a valid Unity project using cross-platform checks:
|
||||
|
||||
```typescript
|
||||
// Use Read tool to check for Unity project indicators
|
||||
Read({ file_path: "ProjectSettings/ProjectVersion.txt" })
|
||||
|
||||
// Use Glob to verify Assets directory exists
|
||||
Glob({ pattern: "Assets/*", path: "." })
|
||||
```
|
||||
|
||||
**Validation steps:**
|
||||
1. Verify `Assets/` directory exists
|
||||
2. Verify `ProjectSettings/ProjectVersion.txt` exists
|
||||
3. Read `ProjectVersion.txt` to get Unity version
|
||||
4. Warn if Editor version doesn't match project version
|
||||
|
||||
**Example ProjectVersion.txt:**
|
||||
```
|
||||
m_EditorVersion: 2021.3.15f1
|
||||
m_EditorVersionWithRevision: 2021.3.15f1 (e8e88743f9e5)
|
||||
```
|
||||
|
||||
### 3. Configure Test Settings
|
||||
|
||||
Determine test execution parameters. Use `AskUserQuestion` tool if parameters are not specified:
|
||||
|
||||
**Required settings:**
|
||||
- **Test Mode**: EditMode, PlayMode, or Both
|
||||
- **Test Platform**: EditMode tests use "EditMode", PlayMode can specify platform (e.g., "StandaloneWindows64", "Android", "iOS")
|
||||
|
||||
**Optional settings:**
|
||||
- **Test Categories**: Semicolon-separated list (e.g., "Combat;AI;Physics")
|
||||
- **Test Filter**: Regex pattern or semicolon-separated test names
|
||||
- **Results Output Path**: Default to `TestResults.xml` in project root
|
||||
|
||||
**Configuration example:**
|
||||
```typescript
|
||||
AskUserQuestion({
|
||||
questions: [{
|
||||
question: "Which test mode should be executed?",
|
||||
header: "Test Mode",
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ label: "EditMode Only", description: "Fast unit tests without Play Mode" },
|
||||
{ label: "PlayMode Only", description: "Full Unity engine tests" },
|
||||
{ label: "Both Modes", description: "Run all tests (slower)" }
|
||||
]
|
||||
}]
|
||||
})
|
||||
```
|
||||
|
||||
### 4. Execute Tests via Command Line
|
||||
|
||||
Build and execute the Unity command line test command:
|
||||
|
||||
**Command structure:**
|
||||
```bash
|
||||
<UnityEditorPath> -runTests -batchmode -projectPath <ProjectPath> \
|
||||
-testPlatform <EditMode|PlayMode> \
|
||||
-testResults <OutputPath> \
|
||||
[-testCategory <Categories>] \
|
||||
[-testFilter <Filter>] \
|
||||
-logFile -
|
||||
```
|
||||
|
||||
**Example commands:**
|
||||
|
||||
**EditMode tests:**
|
||||
```bash
|
||||
"C:\Program Files\Unity\Hub\Editor\2021.3.15f1\Editor\Unity.exe" \
|
||||
-runTests -batchmode \
|
||||
-projectPath "D:\Projects\MyGame" \
|
||||
-testPlatform EditMode \
|
||||
-testResults "TestResults-EditMode.xml" \
|
||||
-logFile -
|
||||
```
|
||||
|
||||
**PlayMode tests with category filter:**
|
||||
```bash
|
||||
"C:\Program Files\Unity\Hub\Editor\2021.3.15f1\Editor\Unity.exe" \
|
||||
-runTests -batchmode \
|
||||
-projectPath "D:\Projects\MyGame" \
|
||||
-testPlatform PlayMode \
|
||||
-testResults "TestResults-PlayMode.xml" \
|
||||
-testCategory "Combat;AI" \
|
||||
-logFile -
|
||||
```
|
||||
|
||||
**Execution notes:**
|
||||
- Use `Bash` tool with `run_in_background: true` for long-running tests
|
||||
- Set timeout appropriately (default: 5-10 minutes, adjust based on test count)
|
||||
- Monitor output for progress indicators
|
||||
- Capture both stdout and stderr
|
||||
|
||||
**Example execution:**
|
||||
```typescript
|
||||
Bash({
|
||||
command: `"${unityPath}" -runTests -batchmode -projectPath "${projectPath}" -testPlatform EditMode -testResults "TestResults.xml" -logFile -`,
|
||||
description: "Execute Unity EditMode tests",
|
||||
timeout: 300000, // 5 minutes
|
||||
run_in_background: true
|
||||
})
|
||||
```
|
||||
|
||||
### 5. Parse Test Results
|
||||
|
||||
After tests complete, parse the NUnit XML results using `parse-test-results.js`:
|
||||
|
||||
```bash
|
||||
node scripts/parse-test-results.js TestResults.xml --json
|
||||
```
|
||||
|
||||
**Script output:**
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"total": 10,
|
||||
"passed": 7,
|
||||
"failed": 2,
|
||||
"skipped": 1,
|
||||
"duration": 12.345
|
||||
},
|
||||
"failures": [
|
||||
{
|
||||
"name": "TestPlayerTakeDamage",
|
||||
"fullName": "Tests.Combat.PlayerTests.TestPlayerTakeDamage",
|
||||
"message": "Expected: 90\n But was: 100",
|
||||
"stackTrace": "at Tests.Combat.PlayerTests.TestPlayerTakeDamage () [0x00001] in Assets/Tests/Combat/PlayerTests.cs:42",
|
||||
"file": "Assets/Tests/Combat/PlayerTests.cs",
|
||||
"line": 42
|
||||
}
|
||||
],
|
||||
"allTests": [...]
|
||||
}
|
||||
```
|
||||
|
||||
**Result analysis:**
|
||||
1. Extract test summary statistics
|
||||
2. Identify all failed tests
|
||||
3. Extract file paths and line numbers from stack traces
|
||||
4. Categorize failures by type (assertion, exception, timeout)
|
||||
|
||||
### 6. Analyze Test Failures
|
||||
|
||||
For each failed test, analyze the failure using `references/test-patterns.json`:
|
||||
|
||||
**Analysis steps:**
|
||||
|
||||
1. **Load test patterns database:**
|
||||
```typescript
|
||||
Read({ file_path: "references/test-patterns.json" })
|
||||
```
|
||||
|
||||
2. **Match failure message against patterns:**
|
||||
- Assertion failures: `Expected: <X> But was: <Y>`
|
||||
- Null reference failures: `Expected: not null But was: <null>`
|
||||
- Timeout failures: `TimeoutException|Test exceeded time limit`
|
||||
- Threading errors: `Can't be called from.*main thread`
|
||||
- Object lifetime issues: `has been destroyed|MissingReferenceException`
|
||||
|
||||
3. **Determine failure category:**
|
||||
- ValueMismatch: Incorrect assertion value
|
||||
- NullValue: Unexpected null reference
|
||||
- Performance: Timeout or slow execution
|
||||
- TestSetup: Setup/TearDown failure
|
||||
- ObjectLifetime: Destroyed object access
|
||||
- Threading: Wrong thread execution
|
||||
|
||||
4. **Generate fix suggestions:**
|
||||
- Load common solutions from test-patterns.json
|
||||
- Match solutions to failure pattern
|
||||
- Provide concrete code examples
|
||||
|
||||
**Example failure analysis:**
|
||||
|
||||
```markdown
|
||||
**Test**: Tests.Combat.PlayerTests.TestPlayerTakeDamage
|
||||
**Location**: Assets/Tests/Combat/PlayerTests.cs:42
|
||||
**Result**: FAILED
|
||||
|
||||
**Failure Message**:
|
||||
Expected: 90
|
||||
But was: 100
|
||||
|
||||
**Analysis**:
|
||||
- Category: ValueMismatch (Assertion Failure)
|
||||
- Pattern: Expected/actual value mismatch
|
||||
- Root Cause: Player health not decreasing after TakeDamage() call
|
||||
|
||||
**Possible Causes**:
|
||||
1. TakeDamage() method not implemented correctly
|
||||
2. Player health not initialized properly
|
||||
3. Damage value passed incorrectly
|
||||
|
||||
**Suggested Solutions**:
|
||||
1. Verify TakeDamage() implementation:
|
||||
```csharp
|
||||
public void TakeDamage(int damage) {
|
||||
health -= damage; // Ensure this line exists
|
||||
}
|
||||
```
|
||||
|
||||
2. Check test setup:
|
||||
```csharp
|
||||
[SetUp]
|
||||
public void SetUp() {
|
||||
player = new Player();
|
||||
player.Health = 100; // Ensure proper initialization
|
||||
}
|
||||
```
|
||||
|
||||
3. Verify test assertion:
|
||||
```csharp
|
||||
player.TakeDamage(10);
|
||||
Assert.AreEqual(90, player.Health); // Expected: 90
|
||||
```
|
||||
```
|
||||
|
||||
### 7. Generate Test Report
|
||||
|
||||
Create a comprehensive test report for the user:
|
||||
|
||||
**Report structure:**
|
||||
|
||||
```markdown
|
||||
# Unity Test Results
|
||||
|
||||
## Summary
|
||||
- **Total Tests**: 10
|
||||
- **✓ Passed**: 7 (70%)
|
||||
- **✗ Failed**: 2 (20%)
|
||||
- **⊘ Skipped**: 1 (10%)
|
||||
- **Duration**: 12.35s
|
||||
|
||||
## Test Breakdown
|
||||
- **EditMode Tests**: 5 passed, 1 failed
|
||||
- **PlayMode Tests**: 2 passed, 1 failed
|
||||
|
||||
## Failed Tests
|
||||
|
||||
### 1. Tests.Combat.PlayerTests.TestPlayerTakeDamage
|
||||
**Location**: Assets/Tests/Combat/PlayerTests.cs:42
|
||||
|
||||
**Failure**: Expected: 90, But was: 100
|
||||
|
||||
**Analysis**: Player health not decreasing after TakeDamage() call.
|
||||
|
||||
**Suggested Fix**: Verify TakeDamage() implementation decreases health correctly.
|
||||
|
||||
---
|
||||
|
||||
### 2. Tests.AI.EnemyTests.TestEnemyChasePlayer
|
||||
**Location**: Assets/Tests/AI/EnemyTests.cs:67
|
||||
|
||||
**Failure**: TimeoutException - Test exceeded time limit (5s)
|
||||
|
||||
**Analysis**: Infinite loop or missing yield in coroutine test.
|
||||
|
||||
**Suggested Fix**: Add `[UnityTest]` attribute and use `yield return null` in test loop.
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
1. Review failed test locations and fix implementation
|
||||
2. Re-run tests after fixes by re-invoking the skill
|
||||
3. Consider adding more assertions for edge cases
|
||||
```
|
||||
|
||||
**Report delivery:**
|
||||
- Present report in formatted Markdown
|
||||
- Highlight critical failures
|
||||
- Provide file:line references for quick navigation
|
||||
- Offer to help fix specific failures if user requests
|
||||
|
||||
## Best Practices
|
||||
|
||||
When using this skill:
|
||||
|
||||
1. **Run EditMode tests first** - They're faster and catch basic logic errors
|
||||
- Reserve PlayMode tests for Unity-specific features
|
||||
- Use EditMode for pure C# logic and data structures
|
||||
|
||||
2. **Use test categories** - Filter tests for faster iteration
|
||||
- `-testCategory "Combat"` runs only Combat tests
|
||||
- Helpful during active development of specific features
|
||||
|
||||
3. **Monitor test duration** - Set appropriate timeouts
|
||||
- EditMode: 1-3 minutes typical
|
||||
- PlayMode: 5-15 minutes typical
|
||||
- Adjust timeout based on test count
|
||||
|
||||
4. **Check Unity version compatibility** - Ensure Editor matches project version
|
||||
- Mismatched versions may cause test failures
|
||||
- Test results may be inconsistent across versions
|
||||
|
||||
5. **Parse results immediately** - Don't wait for manual review
|
||||
- Automated parsing catches issues faster
|
||||
- Provides actionable file:line information
|
||||
|
||||
6. **Analyze failure patterns** - Look for common causes
|
||||
- Similar failures often indicate systemic issues
|
||||
- Fix root cause instead of individual symptoms
|
||||
|
||||
7. **Preserve test results** - Keep XML files for debugging
|
||||
- Results contain full stack traces
|
||||
- Useful for comparing test runs
|
||||
|
||||
8. **Handle long-running tests** - Use background execution
|
||||
- Monitor progress with `BashOutput` tool
|
||||
- Provide status updates to user
|
||||
|
||||
## Resources
|
||||
|
||||
### scripts/find-unity-editor.js
|
||||
|
||||
Cross-platform Unity Editor path detection script. Automatically scans default installation directories for Windows, macOS, and Linux, detects all installed Unity versions, and returns the latest version or a specific requested version.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Find latest Unity version
|
||||
node scripts/find-unity-editor.js --json
|
||||
|
||||
# Find specific version
|
||||
node scripts/find-unity-editor.js --version 2021.3.15f1 --json
|
||||
```
|
||||
|
||||
**Output**: JSON with Unity Editor path, version, platform, and all available versions.
|
||||
|
||||
### scripts/parse-test-results.js
|
||||
|
||||
NUnit XML results parser for Unity Test Framework output. Extracts test statistics, failure details, stack traces, and file locations from XML results.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Parse test results with JSON output
|
||||
node scripts/parse-test-results.js TestResults.xml --json
|
||||
|
||||
# Parse with formatted console output
|
||||
node scripts/parse-test-results.js TestResults.xml
|
||||
```
|
||||
|
||||
**Output**: JSON with test summary, failure details including file paths and line numbers, and full test list.
|
||||
|
||||
### references/test-patterns.json
|
||||
|
||||
Comprehensive database of Unity testing patterns, NUnit assertions, common failure patterns, and best practices. Includes:
|
||||
- NUnit assertion reference (equality, collections, exceptions, Unity-specific)
|
||||
- Common failure patterns with regex matching
|
||||
- Failure categories and root cause analysis
|
||||
- Solution templates with code examples
|
||||
- EditMode vs PlayMode guidance
|
||||
- Unity-specific testing patterns (coroutines, scenes, prefabs, physics)
|
||||
- Testing best practices
|
||||
|
||||
**Usage**: Load this file when analyzing test failures to match failure messages against patterns and generate fix suggestions.
|
||||
384
skills/unity-test-runner/references/test-patterns.json
Normal file
384
skills/unity-test-runner/references/test-patterns.json
Normal file
@@ -0,0 +1,384 @@
|
||||
{
|
||||
"nunitAssertions": {
|
||||
"equality": {
|
||||
"assertions": [
|
||||
{
|
||||
"method": "Assert.AreEqual(expected, actual)",
|
||||
"description": "Verifies that two values are equal",
|
||||
"example": "Assert.AreEqual(100, player.Health);"
|
||||
},
|
||||
{
|
||||
"method": "Assert.AreNotEqual(expected, actual)",
|
||||
"description": "Verifies that two values are not equal",
|
||||
"example": "Assert.AreNotEqual(0, enemy.Speed);"
|
||||
}
|
||||
]
|
||||
},
|
||||
"identity": {
|
||||
"assertions": [
|
||||
{
|
||||
"method": "Assert.AreSame(expected, actual)",
|
||||
"description": "Verifies that two objects refer to the same object instance",
|
||||
"example": "Assert.AreSame(playerInstance, savedPlayer);"
|
||||
},
|
||||
{
|
||||
"method": "Assert.AreNotSame(expected, actual)",
|
||||
"description": "Verifies that two objects do not refer to the same object instance",
|
||||
"example": "Assert.AreNotSame(player1, player2);"
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullity": {
|
||||
"assertions": [
|
||||
{
|
||||
"method": "Assert.IsNull(object)",
|
||||
"description": "Verifies that an object is null",
|
||||
"example": "Assert.IsNull(destroyedEnemy);"
|
||||
},
|
||||
{
|
||||
"method": "Assert.IsNotNull(object)",
|
||||
"description": "Verifies that an object is not null",
|
||||
"example": "Assert.IsNotNull(spawnedPlayer);"
|
||||
}
|
||||
]
|
||||
},
|
||||
"boolean": {
|
||||
"assertions": [
|
||||
{
|
||||
"method": "Assert.IsTrue(condition)",
|
||||
"description": "Verifies that a condition is true",
|
||||
"example": "Assert.IsTrue(player.IsAlive);"
|
||||
},
|
||||
{
|
||||
"method": "Assert.IsFalse(condition)",
|
||||
"description": "Verifies that a condition is false",
|
||||
"example": "Assert.IsFalse(enemy.IsInvincible);"
|
||||
}
|
||||
]
|
||||
},
|
||||
"collections": {
|
||||
"assertions": [
|
||||
{
|
||||
"method": "Assert.Contains(item, collection)",
|
||||
"description": "Verifies that a collection contains a specific item",
|
||||
"example": "Assert.Contains(weapon, inventory.Items);"
|
||||
},
|
||||
{
|
||||
"method": "CollectionAssert.AreEqual(expected, actual)",
|
||||
"description": "Verifies that two collections are equal",
|
||||
"example": "CollectionAssert.AreEqual(expectedItems, actualItems);"
|
||||
},
|
||||
{
|
||||
"method": "CollectionAssert.IsEmpty(collection)",
|
||||
"description": "Verifies that a collection is empty",
|
||||
"example": "CollectionAssert.IsEmpty(emptyInventory);"
|
||||
},
|
||||
{
|
||||
"method": "CollectionAssert.IsNotEmpty(collection)",
|
||||
"description": "Verifies that a collection is not empty",
|
||||
"example": "CollectionAssert.IsNotEmpty(player.Skills);"
|
||||
}
|
||||
]
|
||||
},
|
||||
"exceptions": {
|
||||
"assertions": [
|
||||
{
|
||||
"method": "Assert.Throws<TException>(() => { code })",
|
||||
"description": "Verifies that a specific exception type is thrown",
|
||||
"example": "Assert.Throws<ArgumentNullException>(() => player.Attack(null));"
|
||||
},
|
||||
{
|
||||
"method": "Assert.DoesNotThrow(() => { code })",
|
||||
"description": "Verifies that no exception is thrown",
|
||||
"example": "Assert.DoesNotThrow(() => player.Move(Vector3.zero));"
|
||||
}
|
||||
]
|
||||
},
|
||||
"unity": {
|
||||
"assertions": [
|
||||
{
|
||||
"method": "LogAssert.Expect(LogType, message)",
|
||||
"description": "Expects a specific Unity log message",
|
||||
"example": "LogAssert.Expect(LogType.Warning, \"Player health low\");"
|
||||
},
|
||||
{
|
||||
"method": "LogAssert.NoUnexpectedReceived()",
|
||||
"description": "Verifies no unexpected log messages were received",
|
||||
"example": "LogAssert.NoUnexpectedReceived();"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"commonFailurePatterns": [
|
||||
{
|
||||
"pattern": "Expected: <(.+?)>.*?But was: <(.+?)>",
|
||||
"type": "AssertionFailure",
|
||||
"category": "ValueMismatch",
|
||||
"description": "Value assertion failed - expected value doesn't match actual value",
|
||||
"commonCauses": [
|
||||
"Incorrect expected value in test",
|
||||
"Logic error in tested code",
|
||||
"Timing issue (value not yet updated)",
|
||||
"Floating-point precision error"
|
||||
],
|
||||
"solutions": [
|
||||
{
|
||||
"condition": "Floating-point comparison",
|
||||
"fix": "Use Assert.AreEqual(expected, actual, delta) with tolerance",
|
||||
"example": "Assert.AreEqual(1.0f, result, 0.001f);"
|
||||
},
|
||||
{
|
||||
"condition": "Async operation",
|
||||
"fix": "Add yield return to wait for operation completion",
|
||||
"example": "yield return new WaitForSeconds(0.1f);"
|
||||
},
|
||||
{
|
||||
"condition": "Frame-dependent value",
|
||||
"fix": "Use yield return null to wait for next frame",
|
||||
"example": "yield return null; // Wait one frame"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"pattern": "Expected: not null.*But was: <null>",
|
||||
"type": "NullReferenceFailure",
|
||||
"category": "NullValue",
|
||||
"description": "Expected non-null value but received null",
|
||||
"commonCauses": [
|
||||
"Object not instantiated",
|
||||
"Component not attached",
|
||||
"Resource not loaded",
|
||||
"Missing dependency injection"
|
||||
],
|
||||
"solutions": [
|
||||
{
|
||||
"condition": "GameObject component",
|
||||
"fix": "Ensure GameObject has required component",
|
||||
"example": "var component = gameObject.AddComponent<PlayerController>();"
|
||||
},
|
||||
{
|
||||
"condition": "Resource loading",
|
||||
"fix": "Use Resources.Load or proper asset loading",
|
||||
"example": "var prefab = Resources.Load<GameObject>(\"Prefabs/Player\");"
|
||||
},
|
||||
{
|
||||
"condition": "Scene object reference",
|
||||
"fix": "Use GameObject.Find or proper scene setup",
|
||||
"example": "var player = GameObject.FindGameObjectWithTag(\"Player\");"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"pattern": "TimeoutException|Test exceeded time limit",
|
||||
"type": "TimeoutFailure",
|
||||
"category": "Performance",
|
||||
"description": "Test execution exceeded time limit",
|
||||
"commonCauses": [
|
||||
"Infinite loop in test or code",
|
||||
"Deadlock in async operations",
|
||||
"Slow operation without proper timeout",
|
||||
"Missing yield in coroutine test"
|
||||
],
|
||||
"solutions": [
|
||||
{
|
||||
"condition": "Coroutine test",
|
||||
"fix": "Add [UnityTest] attribute and use yield return",
|
||||
"example": "[UnityTest] public IEnumerator TestCoroutine() { yield return null; }"
|
||||
},
|
||||
{
|
||||
"condition": "Async operation",
|
||||
"fix": "Add timeout and proper await/yield",
|
||||
"example": "yield return new WaitForSecondsRealtime(5f);"
|
||||
},
|
||||
{
|
||||
"condition": "Infinite loop detection",
|
||||
"fix": "Add loop counter or timeout check",
|
||||
"example": "int maxIterations = 100; while(condition && maxIterations-- > 0) { }"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"pattern": "SetUp.*TearDown.*failed",
|
||||
"type": "FixtureFailure",
|
||||
"category": "TestSetup",
|
||||
"description": "Test setup or teardown method failed",
|
||||
"commonCauses": [
|
||||
"Scene loading failure",
|
||||
"Resource initialization error",
|
||||
"Missing test dependencies",
|
||||
"Cleanup error in previous test"
|
||||
],
|
||||
"solutions": [
|
||||
{
|
||||
"condition": "Scene loading",
|
||||
"fix": "Use SceneManager.LoadScene in UnitySetUp",
|
||||
"example": "[UnitySetUp] public IEnumerator SetUp() { yield return SceneManager.LoadSceneAsync(\"TestScene\"); }"
|
||||
},
|
||||
{
|
||||
"condition": "GameObject cleanup",
|
||||
"fix": "Use Object.DestroyImmediate in TearDown",
|
||||
"example": "[TearDown] public void TearDown() { Object.DestroyImmediate(testObject); }"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"pattern": "MissingReferenceException|The object of type.*has been destroyed",
|
||||
"type": "DestroyedObjectReference",
|
||||
"category": "ObjectLifetime",
|
||||
"description": "Attempted to access a destroyed Unity object",
|
||||
"commonCauses": [
|
||||
"Object destroyed before test completes",
|
||||
"Accessing object after scene unload",
|
||||
"Component removed during test",
|
||||
"Improper test cleanup order"
|
||||
],
|
||||
"solutions": [
|
||||
{
|
||||
"condition": "Test cleanup",
|
||||
"fix": "Check if object exists before accessing",
|
||||
"example": "if (testObject != null && testObject) { /* access */ }"
|
||||
},
|
||||
{
|
||||
"condition": "DontDestroyOnLoad objects",
|
||||
"fix": "Manually destroy objects in TearDown",
|
||||
"example": "[TearDown] public void TearDown() { Object.DestroyImmediate(GameObject.Find(\"Persistent\")); }"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"pattern": "Can't be called from.*main thread",
|
||||
"type": "ThreadingError",
|
||||
"category": "Threading",
|
||||
"description": "Unity API called from wrong thread",
|
||||
"commonCauses": [
|
||||
"Async/await without proper context",
|
||||
"Threading operation accessing Unity API",
|
||||
"Task.Run accessing GameObject",
|
||||
"Background thread creating Unity objects"
|
||||
],
|
||||
"solutions": [
|
||||
{
|
||||
"condition": "Async operations",
|
||||
"fix": "Use UnityMainThreadDispatcher or yield return",
|
||||
"example": "yield return new WaitForSeconds(1f); // Keeps on main thread"
|
||||
},
|
||||
{
|
||||
"condition": "Thread synchronization",
|
||||
"fix": "Queue operations for main thread execution",
|
||||
"example": "UnityMainThreadDispatcher.Instance().Enqueue(() => { /* Unity API calls */ });"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"testModes": {
|
||||
"EditMode": {
|
||||
"description": "Tests that run in the Unity Editor without entering Play Mode",
|
||||
"useCases": [
|
||||
"Editor scripts and tools testing",
|
||||
"Non-MonoBehaviour class testing",
|
||||
"Fast unit tests without scene loading",
|
||||
"Utility and helper function testing"
|
||||
],
|
||||
"limitations": [
|
||||
"Cannot test MonoBehaviour lifecycle methods (Start, Update, etc.)",
|
||||
"Cannot test physics or coroutines",
|
||||
"No scene loading or GameObject instantiation"
|
||||
],
|
||||
"attributes": [
|
||||
"[Test] - Standard NUnit test",
|
||||
"[TestFixture] - Marks test class",
|
||||
"[SetUp] - Runs before each test",
|
||||
"[TearDown] - Runs after each test"
|
||||
]
|
||||
},
|
||||
"PlayMode": {
|
||||
"description": "Tests that run in Play Mode with full Unity engine functionality",
|
||||
"useCases": [
|
||||
"MonoBehaviour lifecycle testing",
|
||||
"Scene and GameObject testing",
|
||||
"Physics and collision testing",
|
||||
"Coroutine and async operation testing"
|
||||
],
|
||||
"features": [
|
||||
"Full Unity engine available",
|
||||
"Scene loading supported",
|
||||
"Physics simulation active",
|
||||
"Coroutines can be used"
|
||||
],
|
||||
"attributes": [
|
||||
"[UnityTest] - Coroutine-based test (returns IEnumerator)",
|
||||
"[UnitySetUp] - Async setup method",
|
||||
"[UnityTearDown] - Async teardown method",
|
||||
"[Test] - Standard synchronous test (also works in PlayMode)"
|
||||
]
|
||||
}
|
||||
},
|
||||
"bestPractices": [
|
||||
{
|
||||
"category": "Test Independence",
|
||||
"practice": "Each test should be independent and not rely on other tests",
|
||||
"rationale": "Tests may run in any order and should not affect each other",
|
||||
"example": "Use [SetUp] to initialize test state, [TearDown] to clean up"
|
||||
},
|
||||
{
|
||||
"category": "Test Naming",
|
||||
"practice": "Use descriptive test names that explain what is being tested",
|
||||
"rationale": "Clear names make test failures easier to diagnose",
|
||||
"example": "TestPlayerTakesDamageWhenHitByEnemy() instead of TestDamage()"
|
||||
},
|
||||
{
|
||||
"category": "Arrange-Act-Assert",
|
||||
"practice": "Structure tests with clear Arrange, Act, Assert sections",
|
||||
"rationale": "Makes test logic clear and maintainable",
|
||||
"example": "// Arrange\nvar player = CreatePlayer();\n// Act\nplayer.TakeDamage(10);\n// Assert\nAssert.AreEqual(90, player.Health);"
|
||||
},
|
||||
{
|
||||
"category": "PlayMode Performance",
|
||||
"practice": "Use EditMode tests when possible for faster execution",
|
||||
"rationale": "PlayMode tests are slower due to Unity engine initialization",
|
||||
"example": "Test pure C# logic in EditMode, reserve PlayMode for Unity-specific features"
|
||||
},
|
||||
{
|
||||
"category": "Async Testing",
|
||||
"practice": "Use [UnityTest] with IEnumerator for async operations",
|
||||
"rationale": "Properly handles Unity's frame-based execution",
|
||||
"example": "[UnityTest] public IEnumerator TestAsync() { yield return new WaitForSeconds(1f); }"
|
||||
},
|
||||
{
|
||||
"category": "Scene Management",
|
||||
"practice": "Load minimal test scenes for PlayMode tests",
|
||||
"rationale": "Reduces test execution time and potential side effects",
|
||||
"example": "Create dedicated empty test scenes with only required objects"
|
||||
},
|
||||
{
|
||||
"category": "Test Categorization",
|
||||
"practice": "Use [Category] attribute to group related tests",
|
||||
"rationale": "Enables selective test execution",
|
||||
"example": "[Test, Category(\"Combat\")] public void TestPlayerAttack() { }"
|
||||
},
|
||||
{
|
||||
"category": "Floating-Point Comparison",
|
||||
"practice": "Use tolerance when comparing floating-point values",
|
||||
"rationale": "Floating-point arithmetic is imprecise",
|
||||
"example": "Assert.AreEqual(expected, actual, 0.001f);"
|
||||
}
|
||||
],
|
||||
"unitySpecificPatterns": {
|
||||
"coroutineTesting": {
|
||||
"description": "Testing coroutines requires [UnityTest] attribute",
|
||||
"example": "[UnityTest]\npublic IEnumerator TestCoroutine()\n{\n var go = new GameObject();\n var component = go.AddComponent<MyComponent>();\n component.StartCoroutine(component.MyCoroutine());\n yield return new WaitForSeconds(1f);\n Assert.IsTrue(component.IsComplete);\n}"
|
||||
},
|
||||
"sceneTesting": {
|
||||
"description": "Loading scenes in tests requires async operations",
|
||||
"example": "[UnitySetUp]\npublic IEnumerator SetUp()\n{\n yield return SceneManager.LoadSceneAsync(\"TestScene\", LoadSceneMode.Single);\n}"
|
||||
},
|
||||
"prefabTesting": {
|
||||
"description": "Testing prefabs requires instantiation",
|
||||
"example": "[Test]\npublic void TestPrefab()\n{\n var prefab = Resources.Load<GameObject>(\"Prefabs/Player\");\n var instance = Object.Instantiate(prefab);\n Assert.IsNotNull(instance.GetComponent<PlayerController>());\n Object.DestroyImmediate(instance);\n}"
|
||||
},
|
||||
"physicsTesting": {
|
||||
"description": "Physics tests need time for simulation",
|
||||
"example": "[UnityTest]\npublic IEnumerator TestPhysics()\n{\n var go = GameObject.CreatePrimitive(PrimitiveType.Sphere);\n var rb = go.AddComponent<Rigidbody>();\n rb.AddForce(Vector3.up * 10f);\n yield return new WaitForFixedUpdate();\n Assert.Greater(rb.velocity.y, 0f);\n}"
|
||||
}
|
||||
}
|
||||
}
|
||||
279
skills/unity-test-runner/scripts/find-unity-editor.js
Normal file
279
skills/unity-test-runner/scripts/find-unity-editor.js
Normal file
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Unity Editor Path Finder
|
||||
*
|
||||
* Cross-platform script to automatically detect Unity Editor installation paths.
|
||||
* Supports Windows, macOS, and Linux.
|
||||
*
|
||||
* Usage:
|
||||
* node find-unity-editor.js [--version <version>] [--json]
|
||||
*
|
||||
* Options:
|
||||
* --version <version> Find specific Unity version (e.g., 2021.3.15f1)
|
||||
* --json Output results as JSON
|
||||
*
|
||||
* Output (JSON):
|
||||
* {
|
||||
* "found": true,
|
||||
* "editorPath": "/path/to/Unity",
|
||||
* "version": "2021.3.15f1",
|
||||
* "platform": "win32|darwin|linux",
|
||||
* "allVersions": [...]
|
||||
* }
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const requestedVersion = args.includes('--version')
|
||||
? args[args.indexOf('--version') + 1]
|
||||
: null;
|
||||
const jsonOutput = args.includes('--json');
|
||||
|
||||
/**
|
||||
* Get default Unity installation paths for current platform
|
||||
*/
|
||||
function getDefaultUnityPaths() {
|
||||
const platform = process.platform;
|
||||
const home = process.env.HOME || process.env.USERPROFILE;
|
||||
|
||||
switch (platform) {
|
||||
case 'win32':
|
||||
return [
|
||||
'C:\\Program Files\\Unity\\Hub\\Editor',
|
||||
'C:\\Program Files\\Unity',
|
||||
path.join(home, 'AppData', 'Local', 'Unity', 'Hub', 'Editor')
|
||||
];
|
||||
|
||||
case 'darwin':
|
||||
return [
|
||||
'/Applications/Unity/Hub/Editor',
|
||||
'/Applications/Unity',
|
||||
path.join(home, 'Applications', 'Unity', 'Hub', 'Editor')
|
||||
];
|
||||
|
||||
case 'linux':
|
||||
return [
|
||||
path.join(home, 'Unity', 'Hub', 'Editor'),
|
||||
'/opt/unity',
|
||||
'/usr/share/unity'
|
||||
];
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Unity executable name for current platform
|
||||
*/
|
||||
function getUnityExecutableName() {
|
||||
const platform = process.platform;
|
||||
|
||||
switch (platform) {
|
||||
case 'win32':
|
||||
return 'Unity.exe';
|
||||
case 'darwin':
|
||||
return 'Unity.app/Contents/MacOS/Unity';
|
||||
case 'linux':
|
||||
return 'Unity';
|
||||
default:
|
||||
return 'Unity';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Unity executable path from version directory
|
||||
*/
|
||||
function getUnityExecutablePath(versionPath) {
|
||||
const platform = process.platform;
|
||||
|
||||
if (platform === 'win32') {
|
||||
return path.join(versionPath, 'Editor', 'Unity.exe');
|
||||
} else if (platform === 'darwin') {
|
||||
return path.join(versionPath, 'Unity.app', 'Contents', 'MacOS', 'Unity');
|
||||
} else {
|
||||
return path.join(versionPath, 'Editor', 'Unity');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path contains a valid Unity installation
|
||||
*/
|
||||
function isValidUnityInstallation(versionPath) {
|
||||
return fs.existsSync(getUnityExecutablePath(versionPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Unity version string for sorting
|
||||
* Format: 2021.3.15f1 -> {year: 2021, major: 3, minor: 15, build: 'f', patch: 1}
|
||||
*/
|
||||
function parseUnityVersion(versionStr) {
|
||||
const match = versionStr.match(/(\d+)\.(\d+)\.(\d+)([a-z])(\d+)/);
|
||||
if (!match) return null;
|
||||
|
||||
return {
|
||||
year: parseInt(match[1]),
|
||||
major: parseInt(match[2]),
|
||||
minor: parseInt(match[3]),
|
||||
build: match[4],
|
||||
patch: parseInt(match[5]),
|
||||
full: versionStr
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two Unity versions
|
||||
*/
|
||||
function compareVersions(a, b) {
|
||||
const vA = parseUnityVersion(a);
|
||||
const vB = parseUnityVersion(b);
|
||||
|
||||
if (!vA || !vB) return 0;
|
||||
|
||||
if (vA.year !== vB.year) return vB.year - vA.year;
|
||||
if (vA.major !== vB.major) return vB.major - vA.major;
|
||||
if (vA.minor !== vB.minor) return vB.minor - vA.minor;
|
||||
if (vA.build !== vB.build) return vB.build.localeCompare(vA.build);
|
||||
return vB.patch - vA.patch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan directory for Unity installations
|
||||
*/
|
||||
function scanForUnityVersions(basePath) {
|
||||
if (!fs.existsSync(basePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(basePath, { withFileTypes: true });
|
||||
const versions = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const versionPath = path.join(basePath, entry.name);
|
||||
|
||||
// Check if this looks like a Unity version (e.g., 2021.3.15f1)
|
||||
if (/^\d{4}\.\d+\.\d+[a-z]\d+$/.test(entry.name) && isValidUnityInstallation(versionPath)) {
|
||||
versions.push({
|
||||
version: entry.name,
|
||||
path: versionPath,
|
||||
executablePath: getUnityExecutablePath(versionPath)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return versions;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all Unity installations
|
||||
*/
|
||||
function findAllUnityInstallations() {
|
||||
const searchPaths = getDefaultUnityPaths();
|
||||
const allVersions = [];
|
||||
|
||||
for (const searchPath of searchPaths) {
|
||||
const versions = scanForUnityVersions(searchPath);
|
||||
allVersions.push(...versions);
|
||||
}
|
||||
|
||||
// Remove duplicates based on version string
|
||||
const uniqueVersions = allVersions.filter((v, index, self) =>
|
||||
index === self.findIndex(t => t.version === v.version)
|
||||
);
|
||||
|
||||
// Sort by version (newest first)
|
||||
uniqueVersions.sort((a, b) => compareVersions(a.version, b.version));
|
||||
|
||||
return uniqueVersions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Unity Editor
|
||||
*/
|
||||
function findUnityEditor() {
|
||||
const allVersions = findAllUnityInstallations();
|
||||
|
||||
if (allVersions.length === 0) {
|
||||
return {
|
||||
found: false,
|
||||
error: 'No Unity installations found',
|
||||
platform: process.platform,
|
||||
searchedPaths: getDefaultUnityPaths()
|
||||
};
|
||||
}
|
||||
|
||||
// If specific version requested, find it
|
||||
if (requestedVersion) {
|
||||
const found = allVersions.find(v => v.version === requestedVersion);
|
||||
|
||||
if (found) {
|
||||
return {
|
||||
found: true,
|
||||
editorPath: found.executablePath,
|
||||
version: found.version,
|
||||
platform: process.platform,
|
||||
allVersions: allVersions.map(v => v.version)
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
found: false,
|
||||
error: `Unity version ${requestedVersion} not found`,
|
||||
platform: process.platform,
|
||||
availableVersions: allVersions.map(v => v.version)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Return latest version
|
||||
const latest = allVersions[0];
|
||||
return {
|
||||
found: true,
|
||||
editorPath: latest.executablePath,
|
||||
version: latest.version,
|
||||
platform: process.platform,
|
||||
allVersions: allVersions.map(v => v.version)
|
||||
};
|
||||
}
|
||||
|
||||
// Main execution
|
||||
try {
|
||||
const result = findUnityEditor();
|
||||
|
||||
if (jsonOutput) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
if (result.found) {
|
||||
console.log(`✓ Unity ${result.version} found`);
|
||||
console.log(` Path: ${result.editorPath}`);
|
||||
console.log(` Platform: ${result.platform}`);
|
||||
|
||||
if (result.allVersions && result.allVersions.length > 1) {
|
||||
console.log(`\n Other versions available: ${result.allVersions.slice(1).join(', ')}`);
|
||||
}
|
||||
} else {
|
||||
console.error(`✗ ${result.error}`);
|
||||
|
||||
if (result.availableVersions && result.availableVersions.length > 0) {
|
||||
console.error(`\n Available versions: ${result.availableVersions.join(', ')}`);
|
||||
} else if (result.searchedPaths) {
|
||||
console.error(`\n Searched paths:`);
|
||||
result.searchedPaths.forEach(p => console.error(` - ${p}`));
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
284
skills/unity-test-runner/scripts/parse-test-results.js
Normal file
284
skills/unity-test-runner/scripts/parse-test-results.js
Normal file
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Unity Test Results Parser
|
||||
*
|
||||
* Parses Unity Test Framework NUnit XML output and extracts test statistics and failure details.
|
||||
*
|
||||
* Usage:
|
||||
* node parse-test-results.js <path-to-results.xml> [--json]
|
||||
*
|
||||
* Options:
|
||||
* --json Output results as JSON
|
||||
*
|
||||
* Output (JSON):
|
||||
* {
|
||||
* "summary": {
|
||||
* "total": 10,
|
||||
* "passed": 7,
|
||||
* "failed": 2,
|
||||
* "skipped": 1,
|
||||
* "duration": 1.234
|
||||
* },
|
||||
* "failures": [
|
||||
* {
|
||||
* "name": "TestName",
|
||||
* "fullName": "Namespace.Class.TestName",
|
||||
* "message": "Failure message",
|
||||
* "stackTrace": "Stack trace",
|
||||
* "file": "Assets/Tests/TestFile.cs",
|
||||
* "line": 42
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
||||
console.log('Usage: node parse-test-results.js <path-to-results.xml> [--json]');
|
||||
process.exit(args.length === 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
const resultsPath = args[0];
|
||||
const jsonOutput = args.includes('--json');
|
||||
|
||||
/**
|
||||
* Extract text content from XML tag
|
||||
*
|
||||
* Note: This uses regex-based parsing instead of a full XML parser library.
|
||||
* While regex-based XML parsing is generally not recommended, it's sufficient
|
||||
* for Unity Test Framework's consistent NUnit XML output format. The patterns
|
||||
* are designed to be non-greedy and handle common variations (attributes, whitespace).
|
||||
*
|
||||
* For production use with arbitrary XML, consider using fast-xml-parser or xml2js.
|
||||
*/
|
||||
function extractTagContent(xml, tagName) {
|
||||
// Non-greedy matching: [\s\S]*? ensures minimal capture between tags
|
||||
// [^>]* allows for attributes without capturing them (stops at first >)
|
||||
const regex = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i');
|
||||
const match = xml.match(regex);
|
||||
return match ? match[1].trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract attribute value from XML tag
|
||||
*
|
||||
* Handles attributes with double quotes. Unity NUnit XML consistently uses
|
||||
* double quotes for attributes, so this simple pattern is reliable.
|
||||
*/
|
||||
function extractAttribute(tag, attrName) {
|
||||
// Escape special regex characters in attribute name
|
||||
const escapedAttrName = attrName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`${escapedAttrName}="([^"]*)"`, 'i');
|
||||
const match = tag.match(regex);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file path and line number from stack trace
|
||||
*/
|
||||
function extractFileInfo(stackTrace) {
|
||||
// Pattern: at Namespace.Class.Method () [0x00000] in /path/to/file.cs:42
|
||||
// Pattern: at Namespace.Class.Method () in Assets/Tests/TestFile.cs:line 42
|
||||
const patterns = [
|
||||
/in (.+\.cs):(\d+)/i,
|
||||
/in (.+\.cs):line (\d+)/i,
|
||||
/\[0x[0-9a-f]+\] in (.+\.cs):(\d+)/i
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = stackTrace.match(pattern);
|
||||
if (match) {
|
||||
return {
|
||||
file: match[1],
|
||||
line: parseInt(match[2])
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
file: null,
|
||||
line: null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse test-case element
|
||||
*
|
||||
* Extracts test metadata from <test-case> XML element using attribute matching.
|
||||
* All attributes are optional - defaults are provided for missing values.
|
||||
*/
|
||||
function parseTestCase(testCaseXml) {
|
||||
// Extract attributes with fallback defaults
|
||||
const nameMatch = testCaseXml.match(/name="([^"]*)"/);
|
||||
const fullNameMatch = testCaseXml.match(/fullname="([^"]*)"/);
|
||||
const resultMatch = testCaseXml.match(/result="([^"]*)"/);
|
||||
const durationMatch = testCaseXml.match(/duration="([^"]*)"/);
|
||||
|
||||
const testCase = {
|
||||
name: nameMatch ? nameMatch[1] : 'Unknown',
|
||||
fullName: fullNameMatch ? fullNameMatch[1] : 'Unknown',
|
||||
result: resultMatch ? resultMatch[1] : 'Unknown',
|
||||
duration: durationMatch ? parseFloat(durationMatch[1]) || 0 : 0
|
||||
};
|
||||
|
||||
// Extract failure information if test failed
|
||||
if (testCase.result === 'Failed') {
|
||||
const failureXml = extractTagContent(testCaseXml, 'failure');
|
||||
|
||||
if (failureXml) {
|
||||
testCase.message = extractTagContent(failureXml, 'message');
|
||||
testCase.stackTrace = extractTagContent(failureXml, 'stack-trace');
|
||||
|
||||
// Extract file and line from stack trace
|
||||
if (testCase.stackTrace) {
|
||||
const fileInfo = extractFileInfo(testCase.stackTrace);
|
||||
testCase.file = fileInfo.file;
|
||||
testCase.line = fileInfo.line;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return testCase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Unity Test Framework XML results
|
||||
*
|
||||
* Parses NUnit XML format produced by Unity Test Framework.
|
||||
* Expects standard Unity test output structure with <test-run> root element.
|
||||
*
|
||||
* @param {string} xmlContent - Raw XML content from test results file
|
||||
* @returns {Object} Parsed test results with summary, failures, and all tests
|
||||
* @throws {Error} If XML structure is invalid or test-run element is missing
|
||||
*/
|
||||
function parseTestResults(xmlContent) {
|
||||
// Validate XML has test-run root element
|
||||
const testRunMatch = xmlContent.match(/<test-run[^>]*>/i);
|
||||
if (!testRunMatch) {
|
||||
throw new Error('Invalid Unity Test Framework XML: <test-run> element not found. ' +
|
||||
'Ensure the file is a valid NUnit XML results file from Unity.');
|
||||
}
|
||||
|
||||
const testRunTag = testRunMatch[0];
|
||||
|
||||
const summary = {
|
||||
total: parseInt(extractAttribute(testRunTag, 'total') || '0'),
|
||||
passed: parseInt(extractAttribute(testRunTag, 'passed') || '0'),
|
||||
failed: parseInt(extractAttribute(testRunTag, 'failed') || '0'),
|
||||
skipped: parseInt(extractAttribute(testRunTag, 'skipped') || '0') +
|
||||
parseInt(extractAttribute(testRunTag, 'inconclusive') || '0'),
|
||||
duration: parseFloat(extractAttribute(testRunTag, 'duration') || '0')
|
||||
};
|
||||
|
||||
// Extract all test cases using non-greedy matching
|
||||
// Pattern matches <test-case ...>...</test-case> with minimal capture
|
||||
// [^>]* stops at first >, [\s\S]*? captures minimal content between tags
|
||||
const testCaseRegex = /<test-case[^>]*>[\s\S]*?<\/test-case>/gi;
|
||||
const testCaseMatches = xmlContent.match(testCaseRegex) || [];
|
||||
|
||||
const allTests = [];
|
||||
const failures = [];
|
||||
|
||||
for (const testCaseXml of testCaseMatches) {
|
||||
const testCase = parseTestCase(testCaseXml);
|
||||
allTests.push(testCase);
|
||||
|
||||
if (testCase.result === 'Failed') {
|
||||
failures.push(testCase);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
summary,
|
||||
failures,
|
||||
allTests
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format output for console
|
||||
*/
|
||||
function formatConsoleOutput(results) {
|
||||
const { summary, failures } = results;
|
||||
|
||||
console.log('\n=== Unity Test Results ===\n');
|
||||
|
||||
// Summary
|
||||
console.log(`Total Tests: ${summary.total}`);
|
||||
console.log(`✓ Passed: ${summary.passed}`);
|
||||
|
||||
if (summary.failed > 0) {
|
||||
console.log(`✗ Failed: ${summary.failed}`);
|
||||
}
|
||||
|
||||
if (summary.skipped > 0) {
|
||||
console.log(`⊘ Skipped: ${summary.skipped}`);
|
||||
}
|
||||
|
||||
console.log(`Duration: ${summary.duration.toFixed(3)}s\n`);
|
||||
|
||||
// Failures
|
||||
if (failures.length > 0) {
|
||||
console.log('=== Failed Tests ===\n');
|
||||
|
||||
failures.forEach((failure, index) => {
|
||||
console.log(`${index + 1}. ${failure.fullName}`);
|
||||
|
||||
if (failure.message) {
|
||||
console.log(` Message: ${failure.message}`);
|
||||
}
|
||||
|
||||
if (failure.file && failure.line) {
|
||||
console.log(` Location: ${failure.file}:${failure.line}`);
|
||||
}
|
||||
|
||||
if (failure.stackTrace) {
|
||||
console.log(` Stack Trace:`);
|
||||
const lines = failure.stackTrace.split('\n').slice(0, 3);
|
||||
lines.forEach(line => console.log(` ${line.trim()}`));
|
||||
|
||||
if (failure.stackTrace.split('\n').length > 3) {
|
||||
console.log(` ... (truncated)`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
});
|
||||
} else {
|
||||
console.log('✓ All tests passed!\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Main execution
|
||||
try {
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(resultsPath)) {
|
||||
console.error(`Error: Test results file not found: ${resultsPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Read and parse XML
|
||||
const xmlContent = fs.readFileSync(resultsPath, 'utf-8');
|
||||
const results = parseTestResults(xmlContent);
|
||||
|
||||
// Output results
|
||||
if (jsonOutput) {
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
} else {
|
||||
formatConsoleOutput(results);
|
||||
}
|
||||
|
||||
// Exit with error code if tests failed
|
||||
if (results.summary.failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error parsing test results: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user