mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-22 23:23:55 +08:00
- 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
618 lines
21 KiB
HTML
618 lines
21 KiB
HTML
<!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>
|