Files
xiaozhi-esp32-server/main/xiaozhi-server/test/test-runner.html
T
spider-yamet b7e4408a0f 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
2026-01-26 10:18:40 -08:00

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>