mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-26 00:53:54 +08:00
Add browser-based unit tests for xiaozhi test modules
- Add browser-compatible test files (no npm required) - recorder.test.browser.js: 8 tests for microphone and HTTP detection - tools.test.browser.js: 5 tests for Live2D actions and error handling - Add test runner (test-runner.html) with built-in test framework - Add null safety checks in tools.js for DOM element access - Add documentation (English and Chinese versions) - README_TESTS.md / README_TESTS_CN.md: Complete test guide - QUICK_START_TEST.md / QUICK_START_TEST_CN.md: Quick start guides - Total: 13 unit tests covering microphone detection, HTTP detection, Live2D actions, and error handling
This commit is contained in:
@@ -0,0 +1,617 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Unit Tests Runner</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
opacity: 0.9;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.controls {
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-run {
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-run:hover {
|
||||
background: #45a049;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.btn-clear {
|
||||
background: #f44336;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-clear:hover {
|
||||
background: #da190b;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-left: auto;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 8px 16px;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.results {
|
||||
padding: 20px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.test-suite {
|
||||
margin-bottom: 30px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.test-suite-header {
|
||||
background: #f9f9f9;
|
||||
padding: 15px 20px;
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.test-case {
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.test-case:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.test-case:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.test-name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.test-status {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.test-status.pending {
|
||||
background: #ffa500;
|
||||
}
|
||||
|
||||
.test-status.pass {
|
||||
background: #4CAF50;
|
||||
}
|
||||
|
||||
.test-status.fail {
|
||||
background: #f44336;
|
||||
}
|
||||
|
||||
.test-error {
|
||||
margin-top: 8px;
|
||||
padding: 10px;
|
||||
background: #ffebee;
|
||||
border-left: 4px solid #f44336;
|
||||
border-radius: 4px;
|
||||
color: #c62828;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.test-duration {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.summary {
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
border-top: 2px solid #e0e0e0;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.summary.pass {
|
||||
color: #4CAF50;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.summary.fail {
|
||||
color: #f44336;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #667eea;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🧪 Unit Tests Runner</h1>
|
||||
<p>Browser-based test runner for xiaozhi test modules</p>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button class="btn-run" onclick="runTests()">▶ Run All Tests</button>
|
||||
<button class="btn-clear" onclick="clearResults()">🗑️ Clear Results</button>
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<div>Total</div>
|
||||
<div class="stat-value" id="stat-total">0</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div>Passed</div>
|
||||
<div class="stat-value" id="stat-passed" style="color: #4CAF50;">0</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div>Failed</div>
|
||||
<div class="stat-value" id="stat-failed" style="color: #f44336;">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results" id="results">
|
||||
<div class="loading" style="display: none;" id="loading">
|
||||
<div class="spinner"></div>
|
||||
<div>Running tests...</div>
|
||||
</div>
|
||||
<div style="text-align: center; padding: 40px; color: #999;">
|
||||
Click "Run All Tests" to start testing
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary" id="summary"></div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
// Set up basic DOM mocks before importing any test files
|
||||
// This prevents errors when modules try to access DOM elements during import
|
||||
const mockElement = {
|
||||
innerHTML: '',
|
||||
textContent: '',
|
||||
appendChild: function() {},
|
||||
addEventListener: function() {},
|
||||
classList: {
|
||||
add: function() {},
|
||||
remove: function() {},
|
||||
toggle: function() {},
|
||||
contains: function() { return false; }
|
||||
},
|
||||
style: {}
|
||||
};
|
||||
|
||||
// Store original getElementById for use in runTests function
|
||||
const originalGetElementById = document.getElementById.bind(document);
|
||||
|
||||
// List of test runner UI element IDs that should always return real elements
|
||||
const testRunnerElementIds = ['results', 'loading', 'summary', 'stat-total', 'stat-passed', 'stat-failed'];
|
||||
|
||||
// Override getElementById to return mock elements by default
|
||||
// But allow real DOM elements to be accessed when needed
|
||||
document.getElementById = function(id) {
|
||||
// Always try to get real element first (for test runner UI elements and any existing elements)
|
||||
const realElement = originalGetElementById(id);
|
||||
if (realElement) {
|
||||
return realElement;
|
||||
}
|
||||
// Return mock element for test modules that need DOM elements
|
||||
return mockElement;
|
||||
};
|
||||
|
||||
// Ensure navigator.mediaDevices exists (will be mocked in tests)
|
||||
if (!navigator.mediaDevices) {
|
||||
navigator.mediaDevices = {
|
||||
getUserMedia: function() {
|
||||
return Promise.reject(new Error('Not implemented in test environment'));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Simple test framework
|
||||
const testResults = {
|
||||
suites: [],
|
||||
total: 0,
|
||||
passed: 0,
|
||||
failed: 0
|
||||
};
|
||||
|
||||
// Mock Vitest functions for browser
|
||||
globalThis.describe = function(name, fn) {
|
||||
const suite = {
|
||||
name,
|
||||
tests: [],
|
||||
fn
|
||||
};
|
||||
testResults.suites.push(suite);
|
||||
fn();
|
||||
};
|
||||
|
||||
globalThis.test = function(name, fn) {
|
||||
const currentSuite = testResults.suites[testResults.suites.length - 1];
|
||||
if (currentSuite) {
|
||||
currentSuite.tests.push({
|
||||
name,
|
||||
fn,
|
||||
status: 'pending',
|
||||
error: null,
|
||||
duration: 0
|
||||
});
|
||||
testResults.total++;
|
||||
}
|
||||
};
|
||||
|
||||
globalThis.expect = function(actual) {
|
||||
return {
|
||||
toBe(expected) {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`Expected ${expected}, but got ${actual}`);
|
||||
}
|
||||
},
|
||||
toHaveBeenCalled() {
|
||||
if (!actual || typeof actual !== 'function' || !actual.mock || actual.mock.calls.length === 0) {
|
||||
throw new Error('Expected function to have been called');
|
||||
}
|
||||
},
|
||||
toHaveBeenCalledWith(...args) {
|
||||
if (!actual || typeof actual !== 'function' || !actual.mock) {
|
||||
throw new Error('Expected function to have been called');
|
||||
}
|
||||
// Deep comparison helper
|
||||
const deepEqual = (a, b) => {
|
||||
if (a === b) return true;
|
||||
if (a == null || b == null) return false;
|
||||
if (typeof a !== 'object' || typeof b !== 'object') return false;
|
||||
const keysA = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
for (const key of keysA) {
|
||||
if (!keysB.includes(key)) return false;
|
||||
if (!deepEqual(a[key], b[key])) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const found = actual.mock.calls.some(call =>
|
||||
call.length === args.length &&
|
||||
call.every((val, i) => deepEqual(val, args[i]))
|
||||
);
|
||||
if (!found) {
|
||||
const callsStr = actual.mock.calls.map(c => JSON.stringify(c)).join(', ');
|
||||
throw new Error(`Expected function to have been called with ${JSON.stringify(args)}, but was called with: ${callsStr}`);
|
||||
}
|
||||
},
|
||||
toContain(substring) {
|
||||
if (typeof actual !== 'string' || !actual.includes(substring)) {
|
||||
throw new Error(`Expected string to contain "${substring}"`);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
globalThis.vi = {
|
||||
fn: function(impl) {
|
||||
const mockData = {
|
||||
calls: [],
|
||||
resolvedValue: undefined,
|
||||
rejectedValue: undefined
|
||||
};
|
||||
|
||||
const mock = function(...args) {
|
||||
mockData.calls.push(args);
|
||||
|
||||
// Check for rejected value first
|
||||
if (mockData.rejectedValue !== undefined) {
|
||||
return Promise.reject(mockData.rejectedValue);
|
||||
}
|
||||
|
||||
// Check for resolved value
|
||||
if (mockData.resolvedValue !== undefined) {
|
||||
return Promise.resolve(mockData.resolvedValue);
|
||||
}
|
||||
|
||||
// Use implementation if provided
|
||||
if (impl) {
|
||||
return impl(...args);
|
||||
}
|
||||
};
|
||||
|
||||
// Attach mock properties
|
||||
mock.mock = mockData;
|
||||
mock.mockResolvedValue = function(value) {
|
||||
mockData.resolvedValue = value;
|
||||
mockData.rejectedValue = undefined;
|
||||
return mock;
|
||||
};
|
||||
mock.mockRejectedValue = function(value) {
|
||||
mockData.rejectedValue = value;
|
||||
mockData.resolvedValue = undefined;
|
||||
return mock;
|
||||
};
|
||||
|
||||
return mock;
|
||||
},
|
||||
clearAllMocks: function() {
|
||||
// Clear all mocks
|
||||
}
|
||||
};
|
||||
|
||||
globalThis.beforeEach = function(fn) {
|
||||
// Store beforeEach hook
|
||||
const currentSuite = testResults.suites[testResults.suites.length - 1];
|
||||
if (currentSuite) {
|
||||
currentSuite.beforeEach = fn;
|
||||
}
|
||||
};
|
||||
|
||||
globalThis.afterEach = function(fn) {
|
||||
// Store afterEach hook
|
||||
const currentSuite = testResults.suites[testResults.suites.length - 1];
|
||||
if (currentSuite) {
|
||||
currentSuite.afterEach = fn;
|
||||
}
|
||||
};
|
||||
|
||||
async function runTests() {
|
||||
// Use querySelector to reliably get real DOM elements (bypasses our override)
|
||||
const resultsDiv = document.querySelector('#results');
|
||||
const loadingDiv = document.querySelector('#loading');
|
||||
const summaryDiv = document.querySelector('#summary');
|
||||
|
||||
if (!resultsDiv || !loadingDiv || !summaryDiv) {
|
||||
console.error('Test runner UI elements not found', {
|
||||
results: !!resultsDiv,
|
||||
loading: !!loadingDiv,
|
||||
summary: !!summaryDiv
|
||||
});
|
||||
alert('Error: Test runner UI elements not found. Please refresh the page.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset results
|
||||
testResults.suites = [];
|
||||
testResults.total = 0;
|
||||
testResults.passed = 0;
|
||||
testResults.failed = 0;
|
||||
|
||||
loadingDiv.style.display = 'block';
|
||||
resultsDiv.innerHTML = '';
|
||||
summaryDiv.textContent = '';
|
||||
|
||||
try {
|
||||
// Import browser-compatible test files (no Vitest dependencies)
|
||||
await import('./js/core/audio/recorder.test.browser.js');
|
||||
await import('./js/core/mcp/tools.test.browser.js');
|
||||
|
||||
// Run tests
|
||||
for (const suite of testResults.suites) {
|
||||
for (const testCase of suite.tests) {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// Reset getElementById to default mock before each test
|
||||
// Tests can override this in beforeEach if needed
|
||||
document.getElementById = function(id) {
|
||||
return mockElement;
|
||||
};
|
||||
|
||||
if (suite.beforeEach) {
|
||||
await suite.beforeEach();
|
||||
}
|
||||
|
||||
await testCase.fn();
|
||||
|
||||
testCase.status = 'pass';
|
||||
testResults.passed++;
|
||||
} catch (error) {
|
||||
testCase.status = 'fail';
|
||||
testCase.error = error.message || String(error);
|
||||
testResults.failed++;
|
||||
} finally {
|
||||
testCase.duration = performance.now() - startTime;
|
||||
|
||||
if (suite.afterEach) {
|
||||
try {
|
||||
await suite.afterEach();
|
||||
} catch (e) {
|
||||
// Ignore afterEach errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render results
|
||||
renderResults();
|
||||
} catch (error) {
|
||||
const errorDiv = document.querySelector('#results');
|
||||
if (errorDiv) {
|
||||
errorDiv.innerHTML = `<div class="test-error">Error loading tests: ${error.message}</div>`;
|
||||
}
|
||||
console.error('Test loading error:', error);
|
||||
} finally {
|
||||
const loadingDivFinal = document.querySelector('#loading');
|
||||
if (loadingDivFinal) {
|
||||
loadingDivFinal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderResults() {
|
||||
const resultsDiv = document.querySelector('#results');
|
||||
const summaryDiv = document.querySelector('#summary');
|
||||
|
||||
if (!resultsDiv || !summaryDiv) {
|
||||
console.error('Results elements not found');
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
|
||||
for (const suite of testResults.suites) {
|
||||
html += `<div class="test-suite">`;
|
||||
html += `<div class="test-suite-header">${suite.name}</div>`;
|
||||
|
||||
for (const testCase of suite.tests) {
|
||||
html += `<div class="test-case">`;
|
||||
html += `<div class="test-name">`;
|
||||
html += `<span class="test-status ${testCase.status}"></span>`;
|
||||
html += `<span>${testCase.name}</span>`;
|
||||
html += `<span class="test-duration">${testCase.duration.toFixed(2)}ms</span>`;
|
||||
html += `</div>`;
|
||||
|
||||
if (testCase.error) {
|
||||
html += `<div class="test-error">${testCase.error}</div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
resultsDiv.innerHTML = html;
|
||||
|
||||
// Update stats
|
||||
const statTotal = document.querySelector('#stat-total');
|
||||
const statPassed = document.querySelector('#stat-passed');
|
||||
const statFailed = document.querySelector('#stat-failed');
|
||||
|
||||
if (statTotal) statTotal.textContent = testResults.total;
|
||||
if (statPassed) statPassed.textContent = testResults.passed;
|
||||
if (statFailed) statFailed.textContent = testResults.failed;
|
||||
|
||||
// Update summary
|
||||
if (testResults.failed === 0) {
|
||||
summaryDiv.className = 'summary pass';
|
||||
summaryDiv.textContent = `✅ All ${testResults.total} tests passed!`;
|
||||
} else {
|
||||
summaryDiv.className = 'summary fail';
|
||||
summaryDiv.textContent = `❌ ${testResults.failed} of ${testResults.total} tests failed`;
|
||||
}
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
const resultsDiv = document.querySelector('#results');
|
||||
const summaryDiv = document.querySelector('#summary');
|
||||
const statTotal = document.querySelector('#stat-total');
|
||||
const statPassed = document.querySelector('#stat-passed');
|
||||
const statFailed = document.querySelector('#stat-failed');
|
||||
|
||||
if (resultsDiv) {
|
||||
resultsDiv.innerHTML = '<div style="text-align: center; padding: 40px; color: #999;">Click "Run All Tests" to start testing</div>';
|
||||
}
|
||||
if (summaryDiv) summaryDiv.textContent = '';
|
||||
if (statTotal) statTotal.textContent = '0';
|
||||
if (statPassed) statPassed.textContent = '0';
|
||||
if (statFailed) statFailed.textContent = '0';
|
||||
}
|
||||
|
||||
// Make functions globally available
|
||||
window.runTests = runTests;
|
||||
window.clearResults = clearResults;
|
||||
|
||||
// Also make originalGetElementById available for debugging
|
||||
window._originalGetElementById = originalGetElementById;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user