Optimize code : minify test files

This commit is contained in:
spider-yamet
2026-01-26 14:37:07 -08:00
parent b7e4408a0f
commit da71dce860
8 changed files with 196 additions and 1237 deletions
@@ -1,44 +0,0 @@
# Quick Start - Browser Tests (No npm required!)
## Run Tests in 3 Steps
1. **Start a local server:**
```bash
cd main/xiaozhi-server/test
python -m http.server 8007
```
2. **Open in browser:**
```
http://localhost:8007/test-runner.html
```
3. **Click "▶ Run All Tests"**
That's it! No npm, no package.json, no dependencies needed.
## What Gets Tested?
- ✅ Microphone availability detection (3 tests)
- ✅ HTTP non-localhost detection (5 tests)
- ✅ Live2D action execution (5 tests)
- ✅ Error handling and edge cases
**Total: 13 unit tests** (8 recorder tests + 5 tools tests)
## Test Files
- `js/core/audio/recorder.test.browser.js` - Audio/recorder tests
- `js/core/mcp/tools.test.browser.js` - MCP tools and Live2D tests
## Troubleshooting
**Tests don't run?**
- Make sure you're using a local server (not `file://`)
- Check browser console for errors
- Ensure all `.js` files are accessible
**Some tests fail?**
- Check the error message in the test results
- Verify mocks are set up correctly
- Check browser console for detailed errors
@@ -1,44 +0,0 @@
# 快速开始 - 浏览器测试(无需 npm!)
## 3 步运行测试
1. **启动本地服务器:**
```bash
cd main/xiaozhi-server/test
python -m http.server 8007
```
2. **在浏览器中打开:**
```
http://localhost:8007/test-runner.html
```
3. **点击 "▶ Run All Tests"(运行所有测试)**
就这么简单!无需 npm,无需 package.json,无需任何依赖。
## 测试内容
- ✅ 麦克风可用性检测(3 个测试)
- ✅ HTTP 非本地访问检测(5 个测试)
- ✅ Live2D 动作执行(5 个测试)
- ✅ 错误处理和边界情况
**总计:13 个单元测试**(8 个录音器测试 + 5 个工具测试)
## 测试文件
- `js/core/audio/recorder.test.browser.js` - 音频/录音器测试
- `js/core/mcp/tools.test.browser.js` - MCP 工具和 Live2D 测试
## 故障排除
**测试无法运行?**
- 确保使用本地服务器(不要使用 `file://`
- 检查浏览器控制台是否有错误
- 确保所有 `.js` 文件都可以访问
**部分测试失败?**
- 查看测试结果中的错误信息
- 验证模拟设置是否正确
- 检查浏览器控制台获取详细错误信息
-111
View File
@@ -1,111 +0,0 @@
# Unit Tests Guide
This directory contains unit tests for the xiaozhi test page modules.
## Summary
- **Total Tests:** 13 unit tests
- **Test Files:** 2 browser-compatible test files
- **Test Runner:** Browser-based (no npm required)
- **Coverage:** Microphone detection, HTTP detection, Live2D actions, error handling
## Test Files
- `js/core/audio/recorder.test.browser.js` - Browser-compatible tests for microphone availability detection and HTTP non-localhost detection
- `js/core/mcp/tools.test.browser.js` - Browser-compatible tests for MCP tools and Live2D action execution
**Note:** The `.browser.js` versions work without any npm dependencies. They use a simple test framework built into `test-runner.html`.
## Running Tests
### Browser-based Test Runner (No npm required!)
1. Start a local server:
```bash
cd main/xiaozhi-server/test
python -m http.server 8007
```
2. Open `http://localhost:8007/test-runner.html` in your browser
3. Click "▶ Run All Tests" button
That's it! No npm, no package.json, no dependencies needed.
## Test Coverage
### `recorder.test.browser.js`
-`checkMicrophoneAvailability()` - Returns true when microphone is available
-`checkMicrophoneAvailability()` - Returns false when microphone is not available
-`checkMicrophoneAvailability()` - Returns false when browser doesn't support getUserMedia
-`isHttpNonLocalhost()` - Returns true for HTTP non-localhost access
-`isHttpNonLocalhost()` - Returns false for localhost
-`isHttpNonLocalhost()` - Returns false for 127.0.0.1
-`isHttpNonLocalhost()` - Returns false for private IP addresses
-`isHttpNonLocalhost()` - Returns false for HTTPS protocol
### `tools.test.browser.js`
-`executeMcpTool('live2d.smile')` - Executes FlickUp action
-`executeMcpTool('live2d.wave')` - Executes Tap action
-`executeMcpTool('live2d.action')` - Executes custom action
-`executeMcpTool()` - Handles missing Live2D manager gracefully
-`executeMcpTool()` - Handles unknown tools gracefully
## Writing New Tests
When adding new functionality, create a `.browser.js` test file that follows these patterns:
```javascript
// your-module.test.browser.js
import { yourFunction } from './your-module.js';
describe('Your Feature', () => {
beforeEach(() => {
// Setup mocks and reset state
vi.clearAllMocks();
});
test('should do something', () => {
// Arrange
const input = 'test';
// Act
const result = yourFunction(input);
// Assert
expect(result).toBe('expected');
});
});
```
## Mocking Guidelines
- Use `vi.fn()` for function mocks
- Use `vi.fn().mockResolvedValue(value)` for async mocks that resolve
- Use `vi.fn().mockRejectedValue(error)` for async mocks that reject
- Use `vi.clearAllMocks()` in `beforeEach` to reset state
- Mock browser APIs (`navigator`, `window.location`, `localStorage`, `fetch`)
- Mock DOM elements when needed (`document.getElementById`, etc.)
## Available Test Functions
The browser test framework provides:
- `describe(name, fn)` - Define a test suite
- `test(name, fn)` - Define a test case
- `beforeEach(fn)` - Run before each test
- `afterEach(fn)` - Run after each test
- `expect(actual)` - Assertion object with:
- `.toBe(expected)` - Strict equality
- `.toHaveBeenCalled()` - Function was called
- `.toHaveBeenCalledWith(...args)` - Function was called with specific args
- `.toContain(substring)` - String contains substring
- `vi.fn(impl?)` - Create a mock function
- `vi.clearAllMocks()` - Clear all mocks
## Notes
- Tests use ES modules (`import`/`export`)
- Tests run directly in the browser (no Node.js needed)
- No npm dependencies required - everything is self-contained
- The test runner (`test-runner.html`) includes a simple test framework
- Tests are automatically loaded when you click "Run All Tests"
-111
View File
@@ -1,111 +0,0 @@
# 单元测试指南
本目录包含 xiaozhi 测试页面模块的单元测试。
## 摘要
- **测试总数:** 13 个单元测试
- **测试文件:** 2 个浏览器兼容的测试文件
- **测试运行器:** 基于浏览器(无需 npm)
- **测试覆盖:** 麦克风检测、HTTP 检测、Live2D 动作、错误处理
## 测试文件
- `js/core/audio/recorder.test.browser.js` - 浏览器兼容的麦克风可用性检测和 HTTP 非本地访问检测测试
- `js/core/mcp/tools.test.browser.js` - 浏览器兼容的 MCP 工具和 Live2D 动作执行测试
**注意:** `.browser.js` 版本无需任何 npm 依赖。它们使用内置在 `test-runner.html` 中的简单测试框架。
## 运行测试
### 基于浏览器的测试运行器(无需 npm!)
1. 启动本地服务器:
```bash
cd main/xiaozhi-server/test
python -m http.server 8007
```
2. 在浏览器中打开 `http://localhost:8007/test-runner.html`
3. 点击 "▶ Run All Tests"(运行所有测试)按钮
就这么简单!无需 npm,无需 package.json,无需任何依赖。
## 测试覆盖
### `recorder.test.browser.js`
-`checkMicrophoneAvailability()` - 当麦克风可用时返回 true
-`checkMicrophoneAvailability()` - 当麦克风不可用时返回 false
-`checkMicrophoneAvailability()` - 当浏览器不支持 getUserMedia 时返回 false
-`isHttpNonLocalhost()` - 对于 HTTP 非本地访问返回 true
-`isHttpNonLocalhost()` - 对于 localhost 返回 false
-`isHttpNonLocalhost()` - 对于 127.0.0.1 返回 false
-`isHttpNonLocalhost()` - 对于私有 IP 地址返回 false
-`isHttpNonLocalhost()` - 对于 HTTPS 协议返回 false
### `tools.test.browser.js`
-`executeMcpTool('live2d.smile')` - 执行 FlickUp 动作
-`executeMcpTool('live2d.wave')` - 执行 Tap 动作
-`executeMcpTool('live2d.action')` - 执行自定义动作
-`executeMcpTool()` - 优雅处理缺失的 Live2D 管理器
-`executeMcpTool()` - 优雅处理未知工具
## 编写新测试
添加新功能时,创建一个遵循以下模式的 `.browser.js` 测试文件:
```javascript
// your-module.test.browser.js
import { yourFunction } from './your-module.js';
describe('您的功能', () => {
beforeEach(() => {
// 设置模拟并重置状态
vi.clearAllMocks();
});
test('应该执行某些操作', () => {
// 准备
const input = 'test';
// 执行
const result = yourFunction(input);
// 断言
expect(result).toBe('expected');
});
});
```
## 模拟指南
- 使用 `vi.fn()` 创建函数模拟
- 使用 `vi.fn().mockResolvedValue(value)` 创建解析的异步模拟
- 使用 `vi.fn().mockRejectedValue(error)` 创建拒绝的异步模拟
-`beforeEach` 中使用 `vi.clearAllMocks()` 重置状态
- 模拟浏览器 API`navigator``window.location``localStorage``fetch`
- 需要时模拟 DOM 元素(`document.getElementById` 等)
## 可用的测试函数
浏览器测试框架提供:
- `describe(name, fn)` - 定义测试套件
- `test(name, fn)` - 定义测试用例
- `beforeEach(fn)` - 在每个测试之前运行
- `afterEach(fn)` - 在每个测试之后运行
- `expect(actual)` - 断言对象,包含:
- `.toBe(expected)` - 严格相等
- `.toHaveBeenCalled()` - 函数已被调用
- `.toHaveBeenCalledWith(...args)` - 函数已使用特定参数调用
- `.toContain(substring)` - 字符串包含子字符串
- `vi.fn(impl?)` - 创建模拟函数
- `vi.clearAllMocks()` - 清除所有模拟
## 注意事项
- 测试使用 ES 模块(`import`/`export`
- 测试直接在浏览器中运行(无需 Node.js)
- 无需 npm 依赖 - 所有内容都是自包含的
- 测试运行器(`test-runner.html`)包含一个简单的测试框架
- 点击 "Run All Tests"(运行所有测试)时自动加载测试
@@ -1,154 +1,72 @@
/**
* Audio recording module tests - Browser compatible version
* Test microphone availability detection functionality
*
* This version works without Vitest - uses the simple test framework from test-runner.html
*/
import { checkMicrophoneAvailability, isHttpNonLocalhost } from './recorder.js';
describe('Microphone Availability Detection', () => {
beforeEach(() => {
// Reset mocks before each test
vi.clearAllMocks();
});
/**
* Test checkMicrophoneAvailability function - success case
*/
test('should return true when microphone is available', async () => {
// Mock navigator.mediaDevices.getUserMedia to return a successful stream
const mockTrack = {
stop: vi.fn()
};
const mockStream = {
getTracks: () => [mockTrack]
};
navigator.mediaDevices.getUserMedia = vi.fn().mockResolvedValue(mockStream);
const result = await checkMicrophoneAvailability();
expect(result).toBe(true);
expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith({
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 16000,
channelCount: 1
}
});
expect(mockTrack.stop).toHaveBeenCalled();
});
/**
* Test checkMicrophoneAvailability function - failure case
*/
test('should return false when microphone is not available', async () => {
// Mock getUserMedia to throw an error
const mockError = new Error('Permission denied');
navigator.mediaDevices.getUserMedia = vi.fn().mockRejectedValue(mockError);
const result = await checkMicrophoneAvailability();
expect(result).toBe(false);
expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalled();
});
/**
* Test checkMicrophoneAvailability function - browser not supported
*/
test('should return false when browser does not support getUserMedia', async () => {
// Mock navigator.mediaDevices.getUserMedia to be undefined
const originalGetUserMedia = navigator.mediaDevices.getUserMedia;
navigator.mediaDevices.getUserMedia = undefined;
const result = await checkMicrophoneAvailability();
expect(result).toBe(false);
// Restore
navigator.mediaDevices.getUserMedia = originalGetUserMedia;
});
/**
* Test isHttpNonLocalhost function - HTTP non-localhost
* Note: window.location properties are read-only in browsers, so we test the logic indirectly
*/
test('should return true for HTTP non-localhost access', () => {
// Since window.location is read-only, we'll test by checking the actual implementation
// This test verifies the function works correctly with the current location
// In a real browser environment, this would test against actual location
const result = isHttpNonLocalhost();
// Just verify the function runs without error
expect(typeof result).toBe('boolean');
});
/**
* Test isHttpNonLocalhost function - localhost should return false
* Note: window.location properties are read-only in browsers
*/
test('should return false for localhost', () => {
// Test the logic by checking if current location is localhost
const result = isHttpNonLocalhost();
// If we're on localhost, result should be false
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
expect(result).toBe(false);
} else {
// Otherwise just verify function returns boolean
expect(typeof result).toBe('boolean');
}
});
/**
* Test isHttpNonLocalhost function - 127.0.0.1 should return false
* Note: window.location properties are read-only in browsers
*/
test('should return false for 127.0.0.1', () => {
// Test the logic by checking if current location is 127.0.0.1
const result = isHttpNonLocalhost();
// If we're on 127.0.0.1, result should be false
if (window.location.hostname === '127.0.0.1') {
expect(result).toBe(false);
} else {
// Otherwise just verify function returns boolean
expect(typeof result).toBe('boolean');
}
});
/**
* Test isHttpNonLocalhost function - private IP should return false
* Note: window.location properties are read-only in browsers
*/
test('should return false for private IP addresses', () => {
// Test the logic by checking if current location is a private IP
const result = isHttpNonLocalhost();
const hostname = window.location.hostname;
const isPrivateIP = hostname.startsWith('192.168.') ||
hostname.startsWith('10.') ||
hostname.startsWith('172.');
if (isPrivateIP && window.location.protocol === 'http:') {
expect(result).toBe(false);
} else {
// Otherwise just verify function returns boolean
expect(typeof result).toBe('boolean');
}
});
/**
* Test isHttpNonLocalhost function - HTTPS should return false
* Note: window.location properties are read-only in browsers
*/
test('should return false for HTTPS protocol', () => {
// Test the logic by checking if current protocol is HTTPS
const result = isHttpNonLocalhost();
// If we're on HTTPS, result should be false
if (window.location.protocol === 'https:') {
expect(result).toBe(false);
} else {
// Otherwise just verify function returns boolean
expect(typeof result).toBe('boolean');
}
});
});
import { checkMicrophoneAvailability, isHttpNonLocalhost } from './recorder.js';
describe('Microphone Availability Detection', () => {
beforeEach(() => vi.clearAllMocks());
test('should return true when microphone is available', async () => {
const mockTrack = { stop: vi.fn() };
const mockStream = { getTracks: () => [mockTrack] };
navigator.mediaDevices.getUserMedia = vi.fn().mockResolvedValue(mockStream);
const result = await checkMicrophoneAvailability();
expect(result).toBe(true);
expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 16000, channelCount: 1 } });
expect(mockTrack.stop).toHaveBeenCalled();
});
test('should return false when microphone is not available', async () => {
navigator.mediaDevices.getUserMedia = vi.fn().mockRejectedValue(new Error('Permission denied'));
const result = await checkMicrophoneAvailability();
expect(result).toBe(false);
expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalled();
});
test('should return false when browser does not support getUserMedia', async () => {
const originalGetUserMedia = navigator.mediaDevices.getUserMedia;
navigator.mediaDevices.getUserMedia = undefined;
const result = await checkMicrophoneAvailability();
expect(result).toBe(false);
navigator.mediaDevices.getUserMedia = originalGetUserMedia;
});
test('should return true for HTTP non-localhost access', () => {
expect(typeof isHttpNonLocalhost()).toBe('boolean');
});
test('should return false for localhost', () => {
const result = isHttpNonLocalhost();
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
expect(result).toBe(false);
} else {
expect(typeof result).toBe('boolean');
}
});
test('should return false for 127.0.0.1', () => {
const result = isHttpNonLocalhost();
if (window.location.hostname === '127.0.0.1') {
expect(result).toBe(false);
} else {
expect(typeof result).toBe('boolean');
}
});
test('should return false for private IP addresses', () => {
const result = isHttpNonLocalhost();
const hostname = window.location.hostname;
const isPrivateIP = hostname.startsWith('192.168.') || hostname.startsWith('10.') || hostname.startsWith('172.');
if (isPrivateIP && window.location.protocol === 'http:') {
expect(result).toBe(false);
} else {
expect(typeof result).toBe('boolean');
}
});
test('should return false for HTTPS protocol', () => {
const result = isHttpNonLocalhost();
if (window.location.protocol === 'https:') {
expect(result).toBe(false);
} else {
expect(typeof result).toBe('boolean');
}
});
});
@@ -40,7 +40,7 @@ export async function initMcpTools() {
renderMcpTools();
// Only setup event listeners if DOM elements exist
if (document.getElementById('toggleMcpTools')) {
setupMcpEventListeners();
setupMcpEventListeners();
}
}
@@ -1,155 +1,64 @@
/**
* MCP工具模块测试 - Browser compatible version
* 测试Live2D动作工具执行功能
*
* This version works without Vitest - uses the simple test framework from test-runner.html
*/
import { executeMcpTool, initMcpTools, setWebSocket } from './tools.js';
describe('Live2D Action Tools', () => {
let mockLive2DManager;
let originalChatApp;
beforeEach(() => {
// Reset mocks before each test
vi.clearAllMocks();
// Save original chatApp
originalChatApp = window.chatApp;
// Mock Live2D manager
mockLive2DManager = {
motion: vi.fn()
};
// Setup window.chatApp
window.chatApp = {
live2dManager: mockLive2DManager
};
// Mock localStorage
localStorage.getItem = vi.fn(() => null);
// Mock fetch for default-mcp-tools.json
globalThis.fetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve([
{
name: 'live2d.smile',
description: 'Make the virtual human smile',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'live2d.wave',
description: 'Make the virtual human wave',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'live2d.action',
description: 'Trigger a specified action',
inputSchema: {
type: 'object',
properties: {
action: { type: 'string' }
},
required: ['action']
}
}
])
})
);
// Mock DOM elements - ensure all required elements exist
const mockContainer = {
innerHTML: '',
appendChild: vi.fn(),
textContent: ''
};
document.getElementById = vi.fn((id) => {
// Return mock elements for all IDs that tools.js might access
if (id === 'mcpToolsContainer' || id === 'mcpPropertiesContainer') {
return mockContainer;
}
if (id === 'mcpToolsCount') {
return { textContent: '' };
}
// Return null for other elements (they're checked with if statements)
return null;
});
});
afterEach(() => {
// Clean up
window.chatApp = originalChatApp;
});
/**
* 测试 executeMcpTool - smile 动作
*/
test('should execute Live2D smile action', async () => {
await initMcpTools();
const result = executeMcpTool('live2d.smile', {});
expect(result.success).toBe(true);
expect(result.action).toBe('FlickUp');
expect(result.tool).toBe('live2d.smile');
expect(mockLive2DManager.motion).toHaveBeenCalledWith('FlickUp');
});
/**
* 测试 executeMcpTool - wave 动作
*/
test('should execute Live2D wave action', async () => {
await initMcpTools();
const result = executeMcpTool('live2d.wave', {});
expect(result.success).toBe(true);
expect(result.action).toBe('Tap');
expect(result.tool).toBe('live2d.wave');
expect(mockLive2DManager.motion).toHaveBeenCalledWith('Tap');
});
/**
* 测试 executeMcpTool - 通用动作工具
*/
test('should handle generic action tool', async () => {
await initMcpTools();
const result = executeMcpTool('live2d.action', { action: 'FlickDown' });
expect(result.success).toBe(true);
expect(result.action).toBe('FlickDown');
expect(result.tool).toBe('live2d.action');
expect(mockLive2DManager.motion).toHaveBeenCalledWith('FlickDown');
});
/**
* 测试 executeMcpTool - Live2D管理器未初始化
*/
test('should handle missing Live2D manager gracefully', async () => {
await initMcpTools();
window.chatApp = null;
const result = executeMcpTool('live2d.smile', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Live2D管理器未初始化');
});
/**
* 测试 executeMcpTool - 未知的工具
*/
test('should handle unknown tool gracefully', async () => {
await initMcpTools();
const result = executeMcpTool('unknown.tool', {});
expect(result.success).toBe(false);
expect(result.error).toContain('未知工具');
});
});
import { executeMcpTool, initMcpTools } from './tools.js';
describe('Live2D Action Tools', () => {
let mockLive2DManager, originalChatApp;
beforeEach(() => {
vi.clearAllMocks();
originalChatApp = window.chatApp;
mockLive2DManager = { motion: vi.fn() };
window.chatApp = { live2dManager: mockLive2DManager };
localStorage.getItem = vi.fn(() => null);
globalThis.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve([{ name: 'live2d.smile', description: 'Make the virtual human smile', inputSchema: { type: 'object', properties: {} } }, { name: 'live2d.wave', description: 'Make the virtual human wave', inputSchema: { type: 'object', properties: {} } }, { name: 'live2d.action', description: 'Trigger a specified action', inputSchema: { type: 'object', properties: { action: { type: 'string' } }, required: ['action'] } }]) }));
const mockContainer = { innerHTML: '', appendChild: vi.fn(), textContent: '' };
document.getElementById = vi.fn((id) => {
if (id === 'mcpToolsContainer' || id === 'mcpPropertiesContainer') return mockContainer;
if (id === 'mcpToolsCount') return { textContent: '' };
return null;
});
});
afterEach(() => { window.chatApp = originalChatApp; });
test('should execute Live2D smile action', async () => {
await initMcpTools();
const result = executeMcpTool('live2d.smile', {});
expect(result.success).toBe(true);
expect(result.action).toBe('FlickUp');
expect(result.tool).toBe('live2d.smile');
expect(mockLive2DManager.motion).toHaveBeenCalledWith('FlickUp');
});
test('should execute Live2D wave action', async () => {
await initMcpTools();
const result = executeMcpTool('live2d.wave', {});
expect(result.success).toBe(true);
expect(result.action).toBe('Tap');
expect(result.tool).toBe('live2d.wave');
expect(mockLive2DManager.motion).toHaveBeenCalledWith('Tap');
});
test('should handle generic action tool', async () => {
await initMcpTools();
const result = executeMcpTool('live2d.action', { action: 'FlickDown' });
expect(result.success).toBe(true);
expect(result.action).toBe('FlickDown');
expect(result.tool).toBe('live2d.action');
expect(mockLive2DManager.motion).toHaveBeenCalledWith('FlickDown');
});
test('should handle missing Live2D manager gracefully', async () => {
await initMcpTools();
window.chatApp = null;
const result = executeMcpTool('live2d.smile', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Live2D管理器未初始化');
});
test('should handle unknown tool gracefully', async () => {
await initMcpTools();
const result = executeMcpTool('unknown.tool', {});
expect(result.success).toBe(false);
expect(result.error).toContain('未知工具');
});
});
+59 -617
View File
@@ -1,617 +1,59 @@
<!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>
<!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">
const mockElement={innerHTML:'',textContent:'',appendChild:()=>{},addEventListener:()=>{},classList:{add:()=>{},remove:()=>{},toggle:()=>{},contains:()=>false},style:{}};
const originalGetElementById=document.getElementById.bind(document);
document.getElementById=function(id){const realElement=originalGetElementById(id);return realElement||mockElement;};
if(!navigator.mediaDevices){navigator.mediaDevices={getUserMedia:()=>Promise.reject(new Error('Not implemented'))};}
const testResults={suites:[],total:0,passed:0,failed:0};
globalThis.describe=function(name,fn){testResults.suites.push({name,tests:[],fn});fn();};
globalThis.test=function(name,fn){const suite=testResults.suites[testResults.suites.length-1];if(suite){suite.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');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),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)throw new Error(`Expected function to have been called with ${JSON.stringify(args)}, but was called with: ${actual.mock.calls.map(c=>JSON.stringify(c)).join(', ')}`);},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);if(mockData.rejectedValue!==undefined)return Promise.reject(mockData.rejectedValue);if(mockData.resolvedValue!==undefined)return Promise.resolve(mockData.resolvedValue);return impl?impl(...args):undefined;};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:()=>{}};
globalThis.beforeEach=function(fn){const suite=testResults.suites[testResults.suites.length-1];if(suite)suite.beforeEach=fn;};
globalThis.afterEach=function(fn){const suite=testResults.suites[testResults.suites.length-1];if(suite)suite.afterEach=fn;};
async function runTests(){const resultsDiv=document.querySelector('#results'),loadingDiv=document.querySelector('#loading'),summaryDiv=document.querySelector('#summary');if(!resultsDiv||!loadingDiv||!summaryDiv){alert('Error: Test runner UI elements not found. Please refresh the page.');return;}
testResults.suites=[];testResults.total=0;testResults.passed=0;testResults.failed=0;loadingDiv.style.display='block';resultsDiv.innerHTML='';summaryDiv.textContent='';
try{await import('./js/core/audio/recorder.test.browser.js');await import('./js/core/mcp/tools.test.browser.js');
for(const suite of testResults.suites){for(const testCase of suite.tests){const startTime=performance.now();try{document.getElementById=()=>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){}}}}}
renderResults();}catch(error){const errorDiv=document.querySelector('#results');if(errorDiv)errorDiv.innerHTML=`<div class="test-error">Error loading tests: ${error.message}</div>`;}finally{const loadingDivFinal=document.querySelector('#loading');if(loadingDivFinal)loadingDivFinal.style.display='none';}}
function renderResults(){const resultsDiv=document.querySelector('#results'),summaryDiv=document.querySelector('#summary');if(!resultsDiv||!summaryDiv)return;let html='';for(const suite of testResults.suites){html+=`<div class="test-suite"><div class="test-suite-header">${suite.name}</div>`;for(const testCase of suite.tests){html+=`<div class="test-case"><div class="test-name"><span class="test-status ${testCase.status}"></span><span>${testCase.name}</span><span class="test-duration">${testCase.duration.toFixed(2)}ms</span></div>`;if(testCase.error)html+=`<div class="test-error">${testCase.error}</div>`;html+=`</div>`;}html+=`</div>`;}
resultsDiv.innerHTML=html;const statTotal=document.querySelector('#stat-total'),statPassed=document.querySelector('#stat-passed'),statFailed=document.querySelector('#stat-failed');if(statTotal)statTotal.textContent=testResults.total;if(statPassed)statPassed.textContent=testResults.passed;if(statFailed)statFailed.textContent=testResults.failed;
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'),summaryDiv=document.querySelector('#summary'),statTotal=document.querySelector('#stat-total'),statPassed=document.querySelector('#stat-passed'),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';}
window.runTests=runTests;window.clearResults=clearResults;window._originalGetElementById=originalGetElementById;
</script>
</body>
</html>