Initial commit
This commit is contained in:
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