mirror of
https://github.com/Minidoracat/mcp-feedback-enhanced.git
synced 2025-07-27 10:42:25 +08:00
✨ 新增增強版 MCP 測試系統,包含多場景測試、報告生成及狀態管理功能,並重構 Web UI 以支援單一活躍會話模式。更新相關文檔及樣式,提升使用體驗。
This commit is contained in:
parent
740c0dbbb8
commit
d5494943dd
3
.gitignore
vendored
3
.gitignore
vendored
@ -18,4 +18,5 @@ venv*/
|
||||
|
||||
.cursor/rules/
|
||||
uv.lock
|
||||
.mcp_feedback_settings.json
|
||||
.mcp_feedback_settings.json
|
||||
test_reports/
|
184
debug_websocket.html
Normal file
184
debug_websocket.html
Normal file
@ -0,0 +1,184 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WebSocket 診斷工具</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
background: #1a1a1a;
|
||||
color: #ffffff;
|
||||
}
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.status {
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.success { background: #2d5a2d; }
|
||||
.error { background: #5a2d2d; }
|
||||
.info { background: #2d4a5a; }
|
||||
.warning { background: #5a5a2d; }
|
||||
.log {
|
||||
background: #2a2a2a;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin: 10px 0;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
button {
|
||||
background: #4a90e2;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
margin: 5px;
|
||||
}
|
||||
button:hover {
|
||||
background: #357abd;
|
||||
}
|
||||
input {
|
||||
padding: 8px;
|
||||
margin: 5px;
|
||||
border: 1px solid #555;
|
||||
background: #333;
|
||||
color: white;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🔧 WebSocket 診斷工具</h1>
|
||||
|
||||
<div id="status" class="status info">
|
||||
準備開始診斷...
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>WebSocket URL:</label>
|
||||
<input type="text" id="wsUrl" value="ws://127.0.0.1:8767/ws" style="width: 300px;">
|
||||
<button onclick="testConnection()">🔗 測試連接</button>
|
||||
<button onclick="clearLog()">🗑️ 清除日誌</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>發送消息:</label>
|
||||
<input type="text" id="messageInput" placeholder='{"type": "get_status"}' style="width: 300px;">
|
||||
<button onclick="sendMessage()">📤 發送</button>
|
||||
</div>
|
||||
|
||||
<div id="log" class="log">等待操作...</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let websocket = null;
|
||||
let logElement = document.getElementById('log');
|
||||
let statusElement = document.getElementById('status');
|
||||
|
||||
function log(message, type = 'info') {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
logElement.textContent += `[${timestamp}] ${message}\n`;
|
||||
logElement.scrollTop = logElement.scrollHeight;
|
||||
|
||||
// 更新狀態
|
||||
statusElement.textContent = message;
|
||||
statusElement.className = `status ${type}`;
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
logElement.textContent = '';
|
||||
log('日誌已清除');
|
||||
}
|
||||
|
||||
function testConnection() {
|
||||
const url = document.getElementById('wsUrl').value;
|
||||
|
||||
if (websocket) {
|
||||
log('關閉現有連接...', 'warning');
|
||||
websocket.close();
|
||||
websocket = null;
|
||||
}
|
||||
|
||||
log(`嘗試連接到: ${url}`, 'info');
|
||||
|
||||
try {
|
||||
websocket = new WebSocket(url);
|
||||
|
||||
websocket.onopen = function(event) {
|
||||
log('✅ WebSocket 連接成功!', 'success');
|
||||
};
|
||||
|
||||
websocket.onmessage = function(event) {
|
||||
log(`📨 收到消息: ${event.data}`, 'success');
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
log(`📋 解析後的數據: ${JSON.stringify(data, null, 2)}`, 'info');
|
||||
} catch (e) {
|
||||
log(`⚠️ JSON 解析失敗: ${e.message}`, 'warning');
|
||||
}
|
||||
};
|
||||
|
||||
websocket.onclose = function(event) {
|
||||
log(`🔌 連接已關閉 - Code: ${event.code}, Reason: ${event.reason}`, 'warning');
|
||||
websocket = null;
|
||||
};
|
||||
|
||||
websocket.onerror = function(error) {
|
||||
log(`❌ WebSocket 錯誤: ${error}`, 'error');
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
log(`❌ 連接失敗: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function sendMessage() {
|
||||
const messageInput = document.getElementById('messageInput');
|
||||
const message = messageInput.value.trim();
|
||||
|
||||
if (!message) {
|
||||
log('⚠️ 請輸入要發送的消息', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!websocket || websocket.readyState !== WebSocket.OPEN) {
|
||||
log('❌ WebSocket 未連接', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
websocket.send(message);
|
||||
log(`📤 已發送: ${message}`, 'info');
|
||||
messageInput.value = '';
|
||||
} catch (error) {
|
||||
log(`❌ 發送失敗: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 頁面加載時自動測試
|
||||
window.onload = function() {
|
||||
log('🚀 WebSocket 診斷工具已載入');
|
||||
log('💡 點擊 "測試連接" 開始診斷');
|
||||
};
|
||||
|
||||
// Enter 鍵發送消息
|
||||
document.getElementById('messageInput').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@ -26,6 +26,7 @@ dependencies = [
|
||||
"uvicorn>=0.30.0",
|
||||
"jinja2>=3.1.0",
|
||||
"websockets>=13.0.0",
|
||||
"aiohttp>=3.8.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
@ -30,6 +30,12 @@ def main():
|
||||
test_parser = subparsers.add_parser('test', help='執行測試')
|
||||
test_parser.add_argument('--web', action='store_true', help='測試 Web UI (自動持續運行)')
|
||||
test_parser.add_argument('--gui', action='store_true', help='測試 Qt GUI (快速測試)')
|
||||
test_parser.add_argument('--enhanced', action='store_true', help='執行增強 MCP 測試 (推薦)')
|
||||
test_parser.add_argument('--scenario', help='運行特定的測試場景')
|
||||
test_parser.add_argument('--tags', help='根據標籤運行測試場景 (逗號分隔)')
|
||||
test_parser.add_argument('--list-scenarios', action='store_true', help='列出所有可用的測試場景')
|
||||
test_parser.add_argument('--report-format', choices=['html', 'json', 'markdown'], help='報告格式')
|
||||
test_parser.add_argument('--timeout', type=int, help='測試超時時間 (秒)')
|
||||
|
||||
# 版本命令
|
||||
version_parser = subparsers.add_parser('version', help='顯示版本資訊')
|
||||
@ -58,8 +64,54 @@ def run_tests(args):
|
||||
"""執行測試"""
|
||||
# 啟用調試模式以顯示測試過程
|
||||
os.environ["MCP_DEBUG"] = "true"
|
||||
|
||||
if args.web:
|
||||
|
||||
if args.enhanced or args.scenario or args.tags or args.list_scenarios:
|
||||
# 使用新的增強測試系統
|
||||
print("🚀 執行增強 MCP 測試系統...")
|
||||
import asyncio
|
||||
from .test_mcp_enhanced import MCPTestRunner, TestConfig
|
||||
|
||||
# 創建配置
|
||||
config = TestConfig.from_env()
|
||||
if args.timeout:
|
||||
config.test_timeout = args.timeout
|
||||
if args.report_format:
|
||||
config.report_format = args.report_format
|
||||
|
||||
runner = MCPTestRunner(config)
|
||||
|
||||
async def run_enhanced_tests():
|
||||
try:
|
||||
if args.list_scenarios:
|
||||
# 列出測試場景
|
||||
tags = args.tags.split(',') if args.tags else None
|
||||
runner.list_scenarios(tags)
|
||||
return True
|
||||
|
||||
success = False
|
||||
|
||||
if args.scenario:
|
||||
# 運行特定場景
|
||||
success = await runner.run_single_scenario(args.scenario)
|
||||
elif args.tags:
|
||||
# 根據標籤運行
|
||||
tags = [tag.strip() for tag in args.tags.split(',')]
|
||||
success = await runner.run_scenarios_by_tags(tags)
|
||||
else:
|
||||
# 運行所有場景
|
||||
success = await runner.run_all_scenarios()
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 增強測試執行失敗: {e}")
|
||||
return False
|
||||
|
||||
success = asyncio.run(run_enhanced_tests())
|
||||
if not success:
|
||||
sys.exit(1)
|
||||
|
||||
elif args.web:
|
||||
print("🧪 執行 Web UI 測試...")
|
||||
from .test_web_ui import test_web_ui, interactive_demo
|
||||
success, session_info = test_web_ui()
|
||||
@ -77,39 +129,33 @@ def run_tests(args):
|
||||
if not test_qt_gui():
|
||||
sys.exit(1)
|
||||
else:
|
||||
# 執行所有測試
|
||||
print("🧪 執行完整測試套件...")
|
||||
success = True
|
||||
session_info = None
|
||||
|
||||
try:
|
||||
from .test_web_ui import (
|
||||
test_environment_detection,
|
||||
test_new_parameters,
|
||||
test_mcp_integration,
|
||||
test_web_ui,
|
||||
interactive_demo
|
||||
)
|
||||
|
||||
if not test_environment_detection():
|
||||
success = False
|
||||
if not test_new_parameters():
|
||||
success = False
|
||||
if not test_mcp_integration():
|
||||
success = False
|
||||
|
||||
web_success, session_info = test_web_ui()
|
||||
if not web_success:
|
||||
success = False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 測試執行失敗: {e}")
|
||||
success = False
|
||||
|
||||
# 默認執行增強測試系統的快速測試
|
||||
print("🧪 執行快速測試套件 (使用增強測試系統)...")
|
||||
print("💡 提示:使用 --enhanced 參數可執行完整測試")
|
||||
|
||||
import asyncio
|
||||
from .test_mcp_enhanced import MCPTestRunner, TestConfig
|
||||
|
||||
config = TestConfig.from_env()
|
||||
config.test_timeout = 60 # 快速測試使用較短超時
|
||||
|
||||
runner = MCPTestRunner(config)
|
||||
|
||||
async def run_quick_tests():
|
||||
try:
|
||||
# 運行快速測試標籤
|
||||
success = await runner.run_scenarios_by_tags(["quick"])
|
||||
return success
|
||||
except Exception as e:
|
||||
print(f"❌ 快速測試執行失敗: {e}")
|
||||
return False
|
||||
|
||||
success = asyncio.run(run_quick_tests())
|
||||
if not success:
|
||||
sys.exit(1)
|
||||
|
||||
print("🎉 所有測試通過!")
|
||||
|
||||
print("🎉 快速測試通過!")
|
||||
print("💡 使用 'test --enhanced' 執行完整測試套件")
|
||||
|
||||
def show_version():
|
||||
"""顯示版本資訊"""
|
||||
|
286
src/mcp_feedback_enhanced/test_mcp_enhanced.py
Normal file
286
src/mcp_feedback_enhanced/test_mcp_enhanced.py
Normal file
@ -0,0 +1,286 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
MCP 增強測試系統
|
||||
================
|
||||
|
||||
完整的 MCP 測試框架,模擬真實的 Cursor IDE 調用場景。
|
||||
|
||||
主要功能:
|
||||
- 真實 MCP 調用模擬
|
||||
- 完整的回饋循環測試
|
||||
- 多場景測試覆蓋
|
||||
- 詳細的測試報告
|
||||
|
||||
使用方法:
|
||||
python -m mcp_feedback_enhanced.test_mcp_enhanced
|
||||
python -m mcp_feedback_enhanced.test_mcp_enhanced --scenario basic_workflow
|
||||
python -m mcp_feedback_enhanced.test_mcp_enhanced --tags quick
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import argparse
|
||||
import sys
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from pathlib import Path
|
||||
|
||||
# 添加專案根目錄到 Python 路徑
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
|
||||
from .testing import TestScenarios, TestReporter, TestConfig, DEFAULT_CONFIG
|
||||
from .debug import debug_log
|
||||
|
||||
|
||||
class MCPTestRunner:
|
||||
"""MCP 測試運行器"""
|
||||
|
||||
def __init__(self, config: Optional[TestConfig] = None):
|
||||
self.config = config or DEFAULT_CONFIG
|
||||
self.scenarios = TestScenarios(self.config)
|
||||
self.reporter = TestReporter(self.config)
|
||||
|
||||
async def run_single_scenario(self, scenario_name: str) -> bool:
|
||||
"""運行單個測試場景"""
|
||||
debug_log(f"🎯 運行單個測試場景: {scenario_name}")
|
||||
|
||||
result = await self.scenarios.run_scenario(scenario_name)
|
||||
|
||||
# 生成報告
|
||||
test_results = {
|
||||
"success": result.get("success", False),
|
||||
"total_scenarios": 1,
|
||||
"passed_scenarios": 1 if result.get("success", False) else 0,
|
||||
"failed_scenarios": 0 if result.get("success", False) else 1,
|
||||
"results": [result]
|
||||
}
|
||||
|
||||
report = self.reporter.generate_report(test_results)
|
||||
self.reporter.print_summary(report)
|
||||
|
||||
# 保存報告
|
||||
if self.config.report_output_dir:
|
||||
report_path = self.reporter.save_report(report)
|
||||
debug_log(f"📄 詳細報告已保存: {report_path}")
|
||||
|
||||
return result.get("success", False)
|
||||
|
||||
async def run_scenarios_by_tags(self, tags: List[str]) -> bool:
|
||||
"""根據標籤運行測試場景"""
|
||||
debug_log(f"🏷️ 運行標籤測試: {', '.join(tags)}")
|
||||
|
||||
results = await self.scenarios.run_all_scenarios(tags)
|
||||
|
||||
# 生成報告
|
||||
report = self.reporter.generate_report(results)
|
||||
self.reporter.print_summary(report)
|
||||
|
||||
# 保存報告
|
||||
if self.config.report_output_dir:
|
||||
report_path = self.reporter.save_report(report)
|
||||
debug_log(f"📄 詳細報告已保存: {report_path}")
|
||||
|
||||
return results.get("success", False)
|
||||
|
||||
async def run_all_scenarios(self) -> bool:
|
||||
"""運行所有測試場景"""
|
||||
debug_log("🚀 運行所有測試場景")
|
||||
|
||||
results = await self.scenarios.run_all_scenarios()
|
||||
|
||||
# 生成報告
|
||||
report = self.reporter.generate_report(results)
|
||||
self.reporter.print_summary(report)
|
||||
|
||||
# 保存報告
|
||||
if self.config.report_output_dir:
|
||||
report_path = self.reporter.save_report(report)
|
||||
debug_log(f"📄 詳細報告已保存: {report_path}")
|
||||
|
||||
return results.get("success", False)
|
||||
|
||||
def list_scenarios(self, tags: Optional[List[str]] = None):
|
||||
"""列出可用的測試場景"""
|
||||
scenarios = self.scenarios.list_scenarios(tags)
|
||||
|
||||
print("\n📋 可用的測試場景:")
|
||||
print("=" * 50)
|
||||
|
||||
for scenario in scenarios:
|
||||
tags_str = f" [{', '.join(scenario.tags)}]" if scenario.tags else ""
|
||||
print(f"🧪 {scenario.name}{tags_str}")
|
||||
print(f" {scenario.description}")
|
||||
print(f" 超時: {scenario.timeout}s")
|
||||
print()
|
||||
|
||||
print(f"總計: {len(scenarios)} 個測試場景")
|
||||
|
||||
|
||||
def create_config_from_args(args) -> TestConfig:
|
||||
"""從命令行參數創建配置"""
|
||||
config = TestConfig.from_env()
|
||||
|
||||
# 覆蓋命令行參數
|
||||
if args.timeout:
|
||||
config.test_timeout = args.timeout
|
||||
|
||||
if args.verbose is not None:
|
||||
config.test_verbose = args.verbose
|
||||
|
||||
if args.debug:
|
||||
config.test_debug = True
|
||||
os.environ["MCP_DEBUG"] = "true"
|
||||
|
||||
if args.report_format:
|
||||
config.report_format = args.report_format
|
||||
|
||||
if args.report_dir:
|
||||
config.report_output_dir = args.report_dir
|
||||
|
||||
if args.project_dir:
|
||||
config.test_project_dir = args.project_dir
|
||||
|
||||
return config
|
||||
|
||||
|
||||
async def main():
|
||||
"""主函數"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="MCP 增強測試系統",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
示例用法:
|
||||
%(prog)s # 運行所有測試
|
||||
%(prog)s --scenario basic_workflow # 運行特定場景
|
||||
%(prog)s --tags quick # 運行快速測試
|
||||
%(prog)s --tags basic,integration # 運行多個標籤
|
||||
%(prog)s --list # 列出所有場景
|
||||
%(prog)s --debug --verbose # 調試模式
|
||||
"""
|
||||
)
|
||||
|
||||
# 測試選項
|
||||
parser.add_argument(
|
||||
'--scenario',
|
||||
help='運行特定的測試場景'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--tags',
|
||||
help='根據標籤運行測試場景 (逗號分隔)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--list',
|
||||
action='store_true',
|
||||
help='列出所有可用的測試場景'
|
||||
)
|
||||
|
||||
# 配置選項
|
||||
parser.add_argument(
|
||||
'--timeout',
|
||||
type=int,
|
||||
help='測試超時時間 (秒)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--verbose',
|
||||
action='store_true',
|
||||
help='詳細輸出'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--debug',
|
||||
action='store_true',
|
||||
help='調試模式'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--project-dir',
|
||||
help='測試項目目錄'
|
||||
)
|
||||
|
||||
# 報告選項
|
||||
parser.add_argument(
|
||||
'--report-format',
|
||||
choices=['html', 'json', 'markdown'],
|
||||
help='報告格式'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--report-dir',
|
||||
help='報告輸出目錄'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 創建配置
|
||||
config = create_config_from_args(args)
|
||||
|
||||
# 創建測試運行器
|
||||
runner = MCPTestRunner(config)
|
||||
|
||||
try:
|
||||
if args.list:
|
||||
# 列出測試場景
|
||||
tags = args.tags.split(',') if args.tags else None
|
||||
runner.list_scenarios(tags)
|
||||
return
|
||||
|
||||
success = False
|
||||
|
||||
if args.scenario:
|
||||
# 運行特定場景
|
||||
success = await runner.run_single_scenario(args.scenario)
|
||||
elif args.tags:
|
||||
# 根據標籤運行
|
||||
tags = [tag.strip() for tag in args.tags.split(',')]
|
||||
success = await runner.run_scenarios_by_tags(tags)
|
||||
else:
|
||||
# 運行所有場景
|
||||
success = await runner.run_all_scenarios()
|
||||
|
||||
if success:
|
||||
debug_log("🎉 所有測試通過!")
|
||||
sys.exit(0)
|
||||
else:
|
||||
debug_log("❌ 部分測試失敗")
|
||||
sys.exit(1)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
debug_log("\n⚠️ 測試被用戶中斷")
|
||||
sys.exit(130)
|
||||
except Exception as e:
|
||||
debug_log(f"❌ 測試執行失敗: {e}")
|
||||
if config.test_debug:
|
||||
import traceback
|
||||
debug_log(f"詳細錯誤: {traceback.format_exc()}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run_quick_test():
|
||||
"""快速測試入口"""
|
||||
os.environ["MCP_DEBUG"] = "true"
|
||||
|
||||
# 設置快速測試配置
|
||||
config = TestConfig.from_env()
|
||||
config.test_timeout = 60
|
||||
config.report_format = "markdown"
|
||||
|
||||
async def quick_test():
|
||||
runner = MCPTestRunner(config)
|
||||
return await runner.run_scenarios_by_tags(["quick"])
|
||||
|
||||
return asyncio.run(quick_test())
|
||||
|
||||
|
||||
def run_basic_workflow_test():
|
||||
"""基礎工作流程測試入口"""
|
||||
os.environ["MCP_DEBUG"] = "true"
|
||||
|
||||
config = TestConfig.from_env()
|
||||
config.test_timeout = 180
|
||||
|
||||
async def workflow_test():
|
||||
runner = MCPTestRunner(config)
|
||||
return await runner.run_single_scenario("basic_workflow")
|
||||
|
||||
return asyncio.run(workflow_test())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
@ -140,7 +140,7 @@ def test_web_ui(keep_running=False):
|
||||
session_info = {
|
||||
'manager': manager,
|
||||
'session_id': session_id,
|
||||
'url': f"http://{manager.host}:{manager.port}/session/{session_id}"
|
||||
'url': f"http://{manager.host}:{manager.port}" # 使用根路徑
|
||||
}
|
||||
debug_log(f"✅ 測試會話創建成功 (ID: {session_id[:8]}...)")
|
||||
debug_log(f"🔗 測試 URL: {session_info['url']}")
|
||||
@ -299,16 +299,16 @@ def interactive_demo(session_info):
|
||||
"""Run interactive demo with the Web UI"""
|
||||
debug_log(f"\n🌐 Web UI 互動測試模式")
|
||||
debug_log("=" * 50)
|
||||
debug_log(f"服務器地址: http://{session_info['manager'].host}:{session_info['manager'].port}")
|
||||
debug_log(f"測試會話: {session_info['url']}")
|
||||
debug_log(f"服務器地址: {session_info['url']}") # 簡化輸出,只顯示服務器地址
|
||||
debug_log("\n📖 操作指南:")
|
||||
debug_log(" 1. 在瀏覽器中開啟上面的測試 URL")
|
||||
debug_log(" 1. 在瀏覽器中開啟上面的服務器地址")
|
||||
debug_log(" 2. 嘗試以下功能:")
|
||||
debug_log(" - 點擊 '顯示命令區塊' 按鈕")
|
||||
debug_log(" - 輸入命令如 'echo Hello World' 並執行")
|
||||
debug_log(" - 在回饋區域輸入文字")
|
||||
debug_log(" - 使用 Ctrl+Enter 提交回饋")
|
||||
debug_log(" 3. 測試 WebSocket 即時通訊功能")
|
||||
debug_log(" 4. 測試頁面持久性(提交反饋後頁面不關閉)")
|
||||
debug_log("\n⌨️ 控制選項:")
|
||||
debug_log(" - 按 Enter 繼續運行")
|
||||
debug_log(" - 輸入 'q' 或 'quit' 停止服務器")
|
||||
|
37
src/mcp_feedback_enhanced/testing/__init__.py
Normal file
37
src/mcp_feedback_enhanced/testing/__init__.py
Normal file
@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
MCP 測試框架
|
||||
============
|
||||
|
||||
完整的 MCP 測試系統,模擬真實的 Cursor IDE 調用場景。
|
||||
|
||||
主要功能:
|
||||
- MCP 客戶端模擬器
|
||||
- 完整的回饋循環測試
|
||||
- 多場景測試覆蓋
|
||||
- 詳細的測試報告
|
||||
|
||||
作者: Augment Agent
|
||||
創建時間: 2025-01-05
|
||||
"""
|
||||
|
||||
from .mcp_client import MCPTestClient
|
||||
from .scenarios import TestScenarios
|
||||
from .validators import TestValidators
|
||||
from .reporter import TestReporter
|
||||
from .utils import TestUtils
|
||||
from .config import TestConfig, DEFAULT_CONFIG
|
||||
|
||||
__all__ = [
|
||||
'MCPTestClient',
|
||||
'TestScenarios',
|
||||
'TestValidators',
|
||||
'TestReporter',
|
||||
'TestUtils',
|
||||
'TestConfig',
|
||||
'DEFAULT_CONFIG'
|
||||
]
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "Augment Agent"
|
133
src/mcp_feedback_enhanced/testing/config.py
Normal file
133
src/mcp_feedback_enhanced/testing/config.py
Normal file
@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
測試配置管理
|
||||
============
|
||||
|
||||
管理 MCP 測試框架的配置參數和設定。
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestConfig:
|
||||
"""測試配置類"""
|
||||
|
||||
# 服務器配置
|
||||
server_host: str = "127.0.0.1"
|
||||
server_port: int = 8765
|
||||
server_timeout: int = 30
|
||||
|
||||
# MCP 客戶端配置
|
||||
mcp_timeout: int = 60
|
||||
mcp_retry_count: int = 3
|
||||
mcp_retry_delay: float = 1.0
|
||||
|
||||
# WebSocket 配置
|
||||
websocket_timeout: int = 10
|
||||
websocket_ping_interval: int = 5
|
||||
websocket_ping_timeout: int = 3
|
||||
|
||||
# 測試配置
|
||||
test_timeout: int = 120
|
||||
test_parallel: bool = False
|
||||
test_verbose: bool = True
|
||||
test_debug: bool = False
|
||||
|
||||
# 報告配置
|
||||
report_format: str = "html" # html, json, markdown
|
||||
report_output_dir: str = "test_reports"
|
||||
report_include_logs: bool = True
|
||||
report_include_performance: bool = True
|
||||
|
||||
# 測試數據配置
|
||||
test_project_dir: Optional[str] = None
|
||||
test_summary: str = "MCP 測試框架 - 模擬 Cursor IDE 調用"
|
||||
test_feedback_text: str = "這是一個測試回饋,用於驗證 MCP 系統功能。"
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> 'TestConfig':
|
||||
"""從環境變數創建配置"""
|
||||
config = cls()
|
||||
|
||||
# 從環境變數讀取配置
|
||||
config.server_host = os.getenv('MCP_TEST_HOST', config.server_host)
|
||||
config.server_port = int(os.getenv('MCP_TEST_PORT', str(config.server_port)))
|
||||
config.server_timeout = int(os.getenv('MCP_TEST_SERVER_TIMEOUT', str(config.server_timeout)))
|
||||
|
||||
config.mcp_timeout = int(os.getenv('MCP_TEST_TIMEOUT', str(config.mcp_timeout)))
|
||||
config.mcp_retry_count = int(os.getenv('MCP_TEST_RETRY_COUNT', str(config.mcp_retry_count)))
|
||||
|
||||
config.test_timeout = int(os.getenv('MCP_TEST_CASE_TIMEOUT', str(config.test_timeout)))
|
||||
config.test_parallel = os.getenv('MCP_TEST_PARALLEL', '').lower() in ('true', '1', 'yes')
|
||||
config.test_verbose = os.getenv('MCP_TEST_VERBOSE', '').lower() not in ('false', '0', 'no')
|
||||
config.test_debug = os.getenv('MCP_DEBUG', '').lower() in ('true', '1', 'yes')
|
||||
|
||||
config.report_format = os.getenv('MCP_TEST_REPORT_FORMAT', config.report_format)
|
||||
config.report_output_dir = os.getenv('MCP_TEST_REPORT_DIR', config.report_output_dir)
|
||||
|
||||
config.test_project_dir = os.getenv('MCP_TEST_PROJECT_DIR', config.test_project_dir)
|
||||
|
||||
return config
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'TestConfig':
|
||||
"""從字典創建配置"""
|
||||
config = cls()
|
||||
|
||||
for key, value in data.items():
|
||||
if hasattr(config, key):
|
||||
setattr(config, key, value)
|
||||
|
||||
return config
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""轉換為字典"""
|
||||
return {
|
||||
'server_host': self.server_host,
|
||||
'server_port': self.server_port,
|
||||
'server_timeout': self.server_timeout,
|
||||
'mcp_timeout': self.mcp_timeout,
|
||||
'mcp_retry_count': self.mcp_retry_count,
|
||||
'mcp_retry_delay': self.mcp_retry_delay,
|
||||
'websocket_timeout': self.websocket_timeout,
|
||||
'websocket_ping_interval': self.websocket_ping_interval,
|
||||
'websocket_ping_timeout': self.websocket_ping_timeout,
|
||||
'test_timeout': self.test_timeout,
|
||||
'test_parallel': self.test_parallel,
|
||||
'test_verbose': self.test_verbose,
|
||||
'test_debug': self.test_debug,
|
||||
'report_format': self.report_format,
|
||||
'report_output_dir': self.report_output_dir,
|
||||
'report_include_logs': self.report_include_logs,
|
||||
'report_include_performance': self.report_include_performance,
|
||||
'test_project_dir': self.test_project_dir,
|
||||
'test_summary': self.test_summary,
|
||||
'test_feedback_text': self.test_feedback_text
|
||||
}
|
||||
|
||||
def get_server_url(self) -> str:
|
||||
"""獲取服務器 URL"""
|
||||
return f"http://{self.server_host}:{self.server_port}"
|
||||
|
||||
def get_websocket_url(self) -> str:
|
||||
"""獲取 WebSocket URL"""
|
||||
return f"ws://{self.server_host}:{self.server_port}/ws"
|
||||
|
||||
def get_report_output_path(self) -> Path:
|
||||
"""獲取報告輸出路徑"""
|
||||
return Path(self.report_output_dir)
|
||||
|
||||
def ensure_report_dir(self) -> Path:
|
||||
"""確保報告目錄存在"""
|
||||
report_dir = self.get_report_output_path()
|
||||
report_dir.mkdir(parents=True, exist_ok=True)
|
||||
return report_dir
|
||||
|
||||
|
||||
# 默認配置實例
|
||||
DEFAULT_CONFIG = TestConfig.from_env()
|
527
src/mcp_feedback_enhanced/testing/mcp_client.py
Normal file
527
src/mcp_feedback_enhanced/testing/mcp_client.py
Normal file
@ -0,0 +1,527 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
MCP 客戶端模擬器
|
||||
================
|
||||
|
||||
模擬 Cursor IDE 作為 MCP 客戶端的完整調用流程,實現標準的 JSON-RPC 2.0 通信協議。
|
||||
|
||||
主要功能:
|
||||
- MCP 協議握手和初始化
|
||||
- 工具發現和能力協商
|
||||
- 工具調用和結果處理
|
||||
- 錯誤處理和重連機制
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
import time
|
||||
import subprocess
|
||||
import signal
|
||||
import os
|
||||
from typing import Dict, Any, Optional, List, Callable, Awaitable
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .config import TestConfig, DEFAULT_CONFIG
|
||||
from .utils import TestUtils, PerformanceMonitor, AsyncEventWaiter
|
||||
from ..debug import debug_log
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPMessage:
|
||||
"""MCP 消息類"""
|
||||
jsonrpc: str = "2.0"
|
||||
id: Optional[str] = None
|
||||
method: Optional[str] = None
|
||||
params: Optional[Dict[str, Any]] = None
|
||||
result: Optional[Any] = None
|
||||
error: Optional[Dict[str, Any]] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""轉換為字典"""
|
||||
data = {"jsonrpc": self.jsonrpc}
|
||||
|
||||
if self.id is not None:
|
||||
data["id"] = self.id
|
||||
if self.method is not None:
|
||||
data["method"] = self.method
|
||||
if self.params is not None:
|
||||
data["params"] = self.params
|
||||
if self.result is not None:
|
||||
data["result"] = self.result
|
||||
if self.error is not None:
|
||||
data["error"] = self.error
|
||||
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'MCPMessage':
|
||||
"""從字典創建"""
|
||||
return cls(
|
||||
jsonrpc=data.get("jsonrpc", "2.0"),
|
||||
id=data.get("id"),
|
||||
method=data.get("method"),
|
||||
params=data.get("params"),
|
||||
result=data.get("result"),
|
||||
error=data.get("error")
|
||||
)
|
||||
|
||||
def is_request(self) -> bool:
|
||||
"""是否為請求消息"""
|
||||
return self.method is not None
|
||||
|
||||
def is_response(self) -> bool:
|
||||
"""是否為響應消息"""
|
||||
return self.result is not None or self.error is not None
|
||||
|
||||
def is_notification(self) -> bool:
|
||||
"""是否為通知消息"""
|
||||
return self.method is not None and self.id is None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPClientState:
|
||||
"""MCP 客戶端狀態"""
|
||||
connected: bool = False
|
||||
initialized: bool = False
|
||||
tools_discovered: bool = False
|
||||
available_tools: List[Dict[str, Any]] = field(default_factory=list)
|
||||
server_capabilities: Dict[str, Any] = field(default_factory=dict)
|
||||
client_info: Dict[str, Any] = field(default_factory=dict)
|
||||
server_info: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class MCPTestClient:
|
||||
"""MCP 測試客戶端"""
|
||||
|
||||
def __init__(self, config: Optional[TestConfig] = None):
|
||||
self.config = config or DEFAULT_CONFIG
|
||||
self.state = MCPClientState()
|
||||
self.process: Optional[subprocess.Popen] = None
|
||||
self.event_waiter = AsyncEventWaiter()
|
||||
self.performance_monitor = PerformanceMonitor()
|
||||
self.message_id_counter = 0
|
||||
self.pending_requests: Dict[str, asyncio.Future] = {}
|
||||
self.message_handlers: Dict[str, Callable] = {}
|
||||
|
||||
# 設置默認消息處理器
|
||||
self._setup_default_handlers()
|
||||
|
||||
def _setup_default_handlers(self):
|
||||
"""設置默認消息處理器"""
|
||||
self.message_handlers.update({
|
||||
'initialize': self._handle_initialize_response,
|
||||
'tools/list': self._handle_tools_list_response,
|
||||
'tools/call': self._handle_tools_call_response,
|
||||
})
|
||||
|
||||
def _generate_message_id(self) -> str:
|
||||
"""生成消息 ID"""
|
||||
self.message_id_counter += 1
|
||||
return f"msg_{self.message_id_counter}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
async def start_server(self) -> bool:
|
||||
"""啟動 MCP 服務器"""
|
||||
try:
|
||||
debug_log("🚀 啟動 MCP 服務器...")
|
||||
self.performance_monitor.start()
|
||||
|
||||
# 構建啟動命令
|
||||
cmd = [
|
||||
"python", "-m", "src.mcp_feedback_enhanced", "server"
|
||||
]
|
||||
|
||||
# 設置環境變數
|
||||
env = os.environ.copy()
|
||||
env.update({
|
||||
"MCP_DEBUG": "true" if self.config.test_debug else "false",
|
||||
"PYTHONPATH": str(Path(__file__).parent.parent.parent.parent)
|
||||
})
|
||||
|
||||
# 啟動進程
|
||||
self.process = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
env=env,
|
||||
bufsize=0
|
||||
)
|
||||
|
||||
debug_log(f"✅ MCP 服務器進程已啟動 (PID: {self.process.pid})")
|
||||
|
||||
# 等待服務器初始化
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 檢查進程是否仍在運行
|
||||
if self.process.poll() is not None:
|
||||
stderr_output = self.process.stderr.read() if self.process.stderr else ""
|
||||
raise RuntimeError(f"MCP 服務器啟動失敗: {stderr_output}")
|
||||
|
||||
self.state.connected = True
|
||||
self.performance_monitor.checkpoint("server_started")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
debug_log(f"❌ 啟動 MCP 服務器失敗: {e}")
|
||||
await self.cleanup()
|
||||
return False
|
||||
|
||||
async def stop_server(self):
|
||||
"""停止 MCP 服務器"""
|
||||
if self.process:
|
||||
try:
|
||||
debug_log("🛑 停止 MCP 服務器...")
|
||||
|
||||
# 嘗試優雅關閉
|
||||
self.process.terminate()
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.create_task(self._wait_for_process()),
|
||||
timeout=5.0
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
debug_log("⚠️ 優雅關閉超時,強制終止進程")
|
||||
self.process.kill()
|
||||
await self._wait_for_process()
|
||||
|
||||
debug_log("✅ MCP 服務器已停止")
|
||||
|
||||
except Exception as e:
|
||||
debug_log(f"⚠️ 停止 MCP 服務器時發生錯誤: {e}")
|
||||
finally:
|
||||
self.process = None
|
||||
self.state.connected = False
|
||||
|
||||
async def _wait_for_process(self):
|
||||
"""等待進程結束"""
|
||||
if self.process:
|
||||
while self.process.poll() is None:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
async def send_message(self, message: MCPMessage) -> Optional[MCPMessage]:
|
||||
"""發送 MCP 消息"""
|
||||
if not self.process or not self.state.connected:
|
||||
raise RuntimeError("MCP 服務器未連接")
|
||||
|
||||
try:
|
||||
# 序列化消息
|
||||
message_data = json.dumps(message.to_dict()) + "\n"
|
||||
|
||||
debug_log(f"📤 發送 MCP 消息: {message.method or 'response'}")
|
||||
if self.config.test_debug:
|
||||
debug_log(f" 內容: {message_data.strip()}")
|
||||
|
||||
# 發送消息
|
||||
self.process.stdin.write(message_data)
|
||||
self.process.stdin.flush()
|
||||
|
||||
# 如果是請求,等待響應
|
||||
if message.is_request() and message.id:
|
||||
future = asyncio.Future()
|
||||
self.pending_requests[message.id] = future
|
||||
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
future,
|
||||
timeout=self.config.mcp_timeout
|
||||
)
|
||||
return response
|
||||
except asyncio.TimeoutError:
|
||||
self.pending_requests.pop(message.id, None)
|
||||
raise TimeoutError(f"MCP 請求超時: {message.method}")
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
debug_log(f"❌ 發送 MCP 消息失敗: {e}")
|
||||
raise
|
||||
|
||||
async def read_messages(self):
|
||||
"""讀取 MCP 消息"""
|
||||
if not self.process:
|
||||
return
|
||||
|
||||
try:
|
||||
while self.process and self.process.poll() is None:
|
||||
# 讀取一行
|
||||
line = await asyncio.create_task(self._read_line())
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
# 解析 JSON
|
||||
data = json.loads(line.strip())
|
||||
message = MCPMessage.from_dict(data)
|
||||
|
||||
debug_log(f"📨 收到 MCP 消息: {message.method or 'response'}")
|
||||
if self.config.test_debug:
|
||||
debug_log(f" 內容: {line.strip()}")
|
||||
|
||||
# 處理消息
|
||||
await self._handle_message(message)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
debug_log(f"⚠️ JSON 解析失敗: {e}, 原始數據: {line}")
|
||||
except Exception as e:
|
||||
debug_log(f"❌ 處理消息失敗: {e}")
|
||||
|
||||
except Exception as e:
|
||||
debug_log(f"❌ 讀取 MCP 消息失敗: {e}")
|
||||
|
||||
async def _read_line(self) -> str:
|
||||
"""異步讀取一行"""
|
||||
if not self.process or not self.process.stdout:
|
||||
return ""
|
||||
|
||||
# 使用線程池執行阻塞的讀取操作
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, self.process.stdout.readline)
|
||||
|
||||
async def _handle_message(self, message: MCPMessage):
|
||||
"""處理收到的消息"""
|
||||
if message.is_response() and message.id:
|
||||
# 處理響應
|
||||
future = self.pending_requests.pop(message.id, None)
|
||||
if future and not future.done():
|
||||
future.set_result(message)
|
||||
|
||||
elif message.is_request():
|
||||
# 處理請求(通常是服務器發起的)
|
||||
debug_log(f"收到服務器請求: {message.method}")
|
||||
|
||||
# 調用特定的消息處理器
|
||||
if message.method in self.message_handlers:
|
||||
await self.message_handlers[message.method](message)
|
||||
|
||||
async def _handle_initialize_response(self, message: MCPMessage):
|
||||
"""處理初始化響應"""
|
||||
if message.result:
|
||||
self.state.server_info = message.result.get('serverInfo', {})
|
||||
self.state.server_capabilities = message.result.get('capabilities', {})
|
||||
self.state.initialized = True
|
||||
debug_log("✅ MCP 初始化完成")
|
||||
|
||||
async def _handle_tools_list_response(self, message: MCPMessage):
|
||||
"""處理工具列表響應"""
|
||||
if message.result and 'tools' in message.result:
|
||||
self.state.available_tools = message.result['tools']
|
||||
self.state.tools_discovered = True
|
||||
debug_log(f"✅ 發現 {len(self.state.available_tools)} 個工具")
|
||||
|
||||
async def _handle_tools_call_response(self, message: MCPMessage):
|
||||
"""處理工具調用響應"""
|
||||
if message.result:
|
||||
debug_log("✅ 工具調用完成")
|
||||
elif message.error:
|
||||
debug_log(f"❌ 工具調用失敗: {message.error}")
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
"""初始化 MCP 連接"""
|
||||
try:
|
||||
debug_log("🔄 初始化 MCP 連接...")
|
||||
|
||||
message = MCPMessage(
|
||||
id=self._generate_message_id(),
|
||||
method="initialize",
|
||||
params={
|
||||
"protocolVersion": "2024-11-05",
|
||||
"clientInfo": {
|
||||
"name": "mcp-test-client",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"capabilities": {
|
||||
"roots": {
|
||||
"listChanged": True
|
||||
},
|
||||
"sampling": {}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
response = await self.send_message(message)
|
||||
|
||||
if response and response.result:
|
||||
self.performance_monitor.checkpoint("initialized")
|
||||
return True
|
||||
else:
|
||||
debug_log(f"❌ 初始化失敗: {response.error if response else '無響應'}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
debug_log(f"❌ 初始化異常: {e}")
|
||||
return False
|
||||
|
||||
async def list_tools(self) -> List[Dict[str, Any]]:
|
||||
"""獲取可用工具列表"""
|
||||
try:
|
||||
debug_log("🔍 獲取工具列表...")
|
||||
|
||||
message = MCPMessage(
|
||||
id=self._generate_message_id(),
|
||||
method="tools/list",
|
||||
params={}
|
||||
)
|
||||
|
||||
response = await self.send_message(message)
|
||||
|
||||
if response and response.result and 'tools' in response.result:
|
||||
tools = response.result['tools']
|
||||
debug_log(f"✅ 獲取到 {len(tools)} 個工具")
|
||||
self.performance_monitor.checkpoint("tools_listed", {"tools_count": len(tools)})
|
||||
return tools
|
||||
else:
|
||||
debug_log(f"❌ 獲取工具列表失敗: {response.error if response else '無響應'}")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
debug_log(f"❌ 獲取工具列表異常: {e}")
|
||||
return []
|
||||
|
||||
async def call_interactive_feedback(self, project_directory: str, summary: str,
|
||||
timeout: int = 60) -> Dict[str, Any]:
|
||||
"""調用互動回饋工具"""
|
||||
try:
|
||||
debug_log("🎯 調用互動回饋工具...")
|
||||
|
||||
message = MCPMessage(
|
||||
id=self._generate_message_id(),
|
||||
method="tools/call",
|
||||
params={
|
||||
"name": "interactive_feedback",
|
||||
"arguments": {
|
||||
"project_directory": project_directory,
|
||||
"summary": summary,
|
||||
"timeout": timeout
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# 設置較長的超時時間,因為需要等待用戶互動
|
||||
old_timeout = self.config.mcp_timeout
|
||||
self.config.mcp_timeout = timeout + 30 # 額外 30 秒緩衝
|
||||
|
||||
try:
|
||||
response = await self.send_message(message)
|
||||
|
||||
if response and response.result:
|
||||
result = response.result
|
||||
debug_log("✅ 互動回饋工具調用成功")
|
||||
self.performance_monitor.checkpoint("interactive_feedback_completed")
|
||||
return result
|
||||
else:
|
||||
error_msg = response.error if response else "無響應"
|
||||
debug_log(f"❌ 互動回饋工具調用失敗: {error_msg}")
|
||||
return {"error": str(error_msg)}
|
||||
|
||||
finally:
|
||||
self.config.mcp_timeout = old_timeout
|
||||
|
||||
except Exception as e:
|
||||
debug_log(f"❌ 互動回饋工具調用異常: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
async def full_workflow_test(self, project_directory: Optional[str] = None,
|
||||
summary: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""執行完整的工作流程測試"""
|
||||
try:
|
||||
debug_log("🚀 開始完整工作流程測試...")
|
||||
self.performance_monitor.start()
|
||||
|
||||
# 使用配置中的默認值
|
||||
project_dir = project_directory or self.config.test_project_dir or str(Path.cwd())
|
||||
test_summary = summary or self.config.test_summary
|
||||
|
||||
results = {
|
||||
"success": False,
|
||||
"steps": {},
|
||||
"performance": {},
|
||||
"errors": []
|
||||
}
|
||||
|
||||
# 步驟 1: 啟動服務器
|
||||
if not await self.start_server():
|
||||
results["errors"].append("服務器啟動失敗")
|
||||
return results
|
||||
results["steps"]["server_started"] = True
|
||||
|
||||
# 啟動消息讀取任務
|
||||
read_task = asyncio.create_task(self.read_messages())
|
||||
|
||||
try:
|
||||
# 步驟 2: 初始化連接
|
||||
if not await self.initialize():
|
||||
results["errors"].append("MCP 初始化失敗")
|
||||
return results
|
||||
results["steps"]["initialized"] = True
|
||||
|
||||
# 步驟 3: 獲取工具列表
|
||||
tools = await self.list_tools()
|
||||
if not tools:
|
||||
results["errors"].append("獲取工具列表失敗")
|
||||
return results
|
||||
results["steps"]["tools_discovered"] = True
|
||||
results["tools_count"] = len(tools)
|
||||
|
||||
# 檢查是否有 interactive_feedback 工具
|
||||
has_interactive_tool = any(
|
||||
tool.get("name") == "interactive_feedback"
|
||||
for tool in tools
|
||||
)
|
||||
if not has_interactive_tool:
|
||||
results["errors"].append("未找到 interactive_feedback 工具")
|
||||
return results
|
||||
|
||||
# 步驟 4: 調用互動回饋工具
|
||||
feedback_result = await self.call_interactive_feedback(
|
||||
project_dir, test_summary, self.config.test_timeout
|
||||
)
|
||||
|
||||
if "error" in feedback_result:
|
||||
results["errors"].append(f"互動回饋調用失敗: {feedback_result['error']}")
|
||||
return results
|
||||
|
||||
results["steps"]["interactive_feedback_called"] = True
|
||||
results["feedback_result"] = feedback_result
|
||||
results["success"] = True
|
||||
|
||||
debug_log("🎉 完整工作流程測試成功完成")
|
||||
|
||||
finally:
|
||||
read_task.cancel()
|
||||
try:
|
||||
await read_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
debug_log(f"❌ 完整工作流程測試異常: {e}")
|
||||
results["errors"].append(f"測試異常: {str(e)}")
|
||||
return results
|
||||
|
||||
finally:
|
||||
# 獲取性能數據
|
||||
self.performance_monitor.stop()
|
||||
results["performance"] = self.performance_monitor.get_summary()
|
||||
|
||||
# 清理資源
|
||||
await self.cleanup()
|
||||
|
||||
async def cleanup(self):
|
||||
"""清理資源"""
|
||||
await self.stop_server()
|
||||
|
||||
# 取消所有待處理的請求
|
||||
for future in self.pending_requests.values():
|
||||
if not future.done():
|
||||
future.cancel()
|
||||
self.pending_requests.clear()
|
||||
|
||||
self.performance_monitor.stop()
|
||||
debug_log("🧹 MCP 客戶端資源已清理")
|
447
src/mcp_feedback_enhanced/testing/reporter.py
Normal file
447
src/mcp_feedback_enhanced/testing/reporter.py
Normal file
@ -0,0 +1,447 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
測試報告生成器
|
||||
==============
|
||||
|
||||
生成詳細的 MCP 測試報告,支持多種格式輸出。
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Any, List, Optional
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, asdict
|
||||
|
||||
from .config import TestConfig, DEFAULT_CONFIG
|
||||
from .utils import TestUtils
|
||||
from .validators import TestValidators, ValidationResult
|
||||
from ..debug import debug_log
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestReport:
|
||||
"""測試報告數據結構"""
|
||||
timestamp: str
|
||||
duration: float
|
||||
total_scenarios: int
|
||||
passed_scenarios: int
|
||||
failed_scenarios: int
|
||||
success_rate: float
|
||||
scenarios: List[Dict[str, Any]]
|
||||
validation_summary: Dict[str, Any]
|
||||
performance_summary: Dict[str, Any]
|
||||
system_info: Dict[str, Any]
|
||||
config: Dict[str, Any]
|
||||
errors: List[str]
|
||||
warnings: List[str]
|
||||
|
||||
|
||||
class TestReporter:
|
||||
"""測試報告生成器"""
|
||||
|
||||
def __init__(self, config: Optional[TestConfig] = None):
|
||||
self.config = config or DEFAULT_CONFIG
|
||||
self.validators = TestValidators(config)
|
||||
|
||||
def generate_report(self, test_results: Dict[str, Any]) -> TestReport:
|
||||
"""生成測試報告"""
|
||||
start_time = time.time()
|
||||
|
||||
# 提取基本信息
|
||||
scenarios = test_results.get("results", [])
|
||||
total_scenarios = test_results.get("total_scenarios", len(scenarios))
|
||||
passed_scenarios = test_results.get("passed_scenarios", 0)
|
||||
failed_scenarios = test_results.get("failed_scenarios", 0)
|
||||
|
||||
# 計算成功率
|
||||
success_rate = passed_scenarios / total_scenarios if total_scenarios > 0 else 0
|
||||
|
||||
# 驗證測試結果
|
||||
validation_results = {}
|
||||
for i, scenario in enumerate(scenarios):
|
||||
validation_results[f"scenario_{i}"] = self.validators.result_validator.validate_test_result(scenario)
|
||||
|
||||
validation_summary = self.validators.get_validation_summary(validation_results)
|
||||
|
||||
# 生成性能摘要
|
||||
performance_summary = self._generate_performance_summary(scenarios)
|
||||
|
||||
# 收集錯誤和警告
|
||||
all_errors = []
|
||||
all_warnings = []
|
||||
|
||||
for scenario in scenarios:
|
||||
all_errors.extend(scenario.get("errors", []))
|
||||
|
||||
# 計算總持續時間
|
||||
total_duration = 0
|
||||
for scenario in scenarios:
|
||||
perf = scenario.get("performance", {})
|
||||
duration = perf.get("total_duration", 0) or perf.get("total_time", 0)
|
||||
total_duration += duration
|
||||
|
||||
# 創建報告
|
||||
report = TestReport(
|
||||
timestamp=datetime.now().isoformat(),
|
||||
duration=total_duration,
|
||||
total_scenarios=total_scenarios,
|
||||
passed_scenarios=passed_scenarios,
|
||||
failed_scenarios=failed_scenarios,
|
||||
success_rate=success_rate,
|
||||
scenarios=scenarios,
|
||||
validation_summary=validation_summary,
|
||||
performance_summary=performance_summary,
|
||||
system_info=TestUtils.get_system_info(),
|
||||
config=self.config.to_dict(),
|
||||
errors=all_errors,
|
||||
warnings=all_warnings
|
||||
)
|
||||
|
||||
debug_log(f"📊 測試報告生成完成 (耗時: {time.time() - start_time:.2f}s)")
|
||||
return report
|
||||
|
||||
def _generate_performance_summary(self, scenarios: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""生成性能摘要"""
|
||||
total_duration = 0
|
||||
min_duration = float('inf')
|
||||
max_duration = 0
|
||||
durations = []
|
||||
|
||||
memory_usage = []
|
||||
|
||||
for scenario in scenarios:
|
||||
perf = scenario.get("performance", {})
|
||||
|
||||
# 處理持續時間
|
||||
duration = perf.get("total_duration", 0) or perf.get("total_time", 0)
|
||||
if duration > 0:
|
||||
total_duration += duration
|
||||
min_duration = min(min_duration, duration)
|
||||
max_duration = max(max_duration, duration)
|
||||
durations.append(duration)
|
||||
|
||||
# 處理內存使用
|
||||
memory_diff = perf.get("memory_diff", {})
|
||||
if memory_diff:
|
||||
memory_usage.append(memory_diff)
|
||||
|
||||
# 計算平均值
|
||||
avg_duration = total_duration / len(durations) if durations else 0
|
||||
|
||||
# 計算中位數
|
||||
if durations:
|
||||
sorted_durations = sorted(durations)
|
||||
n = len(sorted_durations)
|
||||
median_duration = (
|
||||
sorted_durations[n // 2] if n % 2 == 1
|
||||
else (sorted_durations[n // 2 - 1] + sorted_durations[n // 2]) / 2
|
||||
)
|
||||
else:
|
||||
median_duration = 0
|
||||
|
||||
return {
|
||||
"total_duration": total_duration,
|
||||
"total_duration_formatted": TestUtils.format_duration(total_duration),
|
||||
"avg_duration": avg_duration,
|
||||
"avg_duration_formatted": TestUtils.format_duration(avg_duration),
|
||||
"median_duration": median_duration,
|
||||
"median_duration_formatted": TestUtils.format_duration(median_duration),
|
||||
"min_duration": min_duration if min_duration != float('inf') else 0,
|
||||
"min_duration_formatted": TestUtils.format_duration(min_duration if min_duration != float('inf') else 0),
|
||||
"max_duration": max_duration,
|
||||
"max_duration_formatted": TestUtils.format_duration(max_duration),
|
||||
"scenarios_with_performance": len(durations),
|
||||
"memory_usage_samples": len(memory_usage)
|
||||
}
|
||||
|
||||
def save_report(self, report: TestReport, output_path: Optional[Path] = None) -> Path:
|
||||
"""保存測試報告"""
|
||||
if output_path is None:
|
||||
output_dir = self.config.ensure_report_dir()
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"mcp_test_report_{timestamp}.{self.config.report_format}"
|
||||
output_path = output_dir / filename
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if self.config.report_format.lower() == "json":
|
||||
self._save_json_report(report, output_path)
|
||||
elif self.config.report_format.lower() == "html":
|
||||
self._save_html_report(report, output_path)
|
||||
elif self.config.report_format.lower() == "markdown":
|
||||
self._save_markdown_report(report, output_path)
|
||||
else:
|
||||
raise ValueError(f"不支持的報告格式: {self.config.report_format}")
|
||||
|
||||
debug_log(f"📄 測試報告已保存: {output_path}")
|
||||
return output_path
|
||||
|
||||
def _save_json_report(self, report: TestReport, output_path: Path):
|
||||
"""保存 JSON 格式報告"""
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(asdict(report), f, indent=2, ensure_ascii=False, default=str)
|
||||
|
||||
def _save_html_report(self, report: TestReport, output_path: Path):
|
||||
"""保存 HTML 格式報告"""
|
||||
html_content = self._generate_html_report(report)
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
f.write(html_content)
|
||||
|
||||
def _save_markdown_report(self, report: TestReport, output_path: Path):
|
||||
"""保存 Markdown 格式報告"""
|
||||
markdown_content = self._generate_markdown_report(report)
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
f.write(markdown_content)
|
||||
|
||||
def _generate_html_report(self, report: TestReport) -> str:
|
||||
"""生成 HTML 報告"""
|
||||
# 狀態圖標
|
||||
status_icon = "✅" if report.success_rate == 1.0 else "❌" if report.success_rate == 0 else "⚠️"
|
||||
|
||||
# 性能圖表數據(簡化版)
|
||||
scenario_names = [s.get("scenario_name", f"Scenario {i}") for i, s in enumerate(report.scenarios)]
|
||||
scenario_durations = []
|
||||
for s in report.scenarios:
|
||||
perf = s.get("performance", {})
|
||||
duration = perf.get("total_duration", 0) or perf.get("total_time", 0)
|
||||
scenario_durations.append(duration)
|
||||
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MCP 測試報告</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; margin: 20px; background: #f5f5f5; }}
|
||||
.container {{ max-width: 1200px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }}
|
||||
.header {{ text-align: center; margin-bottom: 30px; }}
|
||||
.status {{ font-size: 24px; margin: 10px 0; }}
|
||||
.success {{ color: #28a745; }}
|
||||
.warning {{ color: #ffc107; }}
|
||||
.error {{ color: #dc3545; }}
|
||||
.summary {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin: 20px 0; }}
|
||||
.card {{ background: #f8f9fa; padding: 15px; border-radius: 6px; border-left: 4px solid #007bff; }}
|
||||
.card h3 {{ margin: 0 0 10px 0; color: #333; }}
|
||||
.card .value {{ font-size: 24px; font-weight: bold; color: #007bff; }}
|
||||
.scenarios {{ margin: 20px 0; }}
|
||||
.scenario {{ background: #f8f9fa; margin: 10px 0; padding: 15px; border-radius: 6px; border-left: 4px solid #28a745; }}
|
||||
.scenario.failed {{ border-left-color: #dc3545; }}
|
||||
.scenario h4 {{ margin: 0 0 10px 0; }}
|
||||
.scenario-details {{ display: grid; grid-template-columns: 1fr 1fr; gap: 10px; font-size: 14px; }}
|
||||
.errors {{ background: #f8d7da; color: #721c24; padding: 10px; border-radius: 4px; margin: 10px 0; }}
|
||||
.performance {{ margin: 20px 0; }}
|
||||
.footer {{ text-align: center; margin-top: 30px; color: #666; font-size: 12px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🧪 MCP 測試報告</h1>
|
||||
<div class="status {'success' if report.success_rate == 1.0 else 'warning' if report.success_rate > 0 else 'error'}">
|
||||
{status_icon} 測試完成
|
||||
</div>
|
||||
<p>生成時間: {report.timestamp}</p>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="card">
|
||||
<h3>總測試數</h3>
|
||||
<div class="value">{report.total_scenarios}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>通過測試</h3>
|
||||
<div class="value" style="color: #28a745;">{report.passed_scenarios}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>失敗測試</h3>
|
||||
<div class="value" style="color: #dc3545;">{report.failed_scenarios}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>成功率</h3>
|
||||
<div class="value">{report.success_rate:.1%}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>總耗時</h3>
|
||||
<div class="value">{report.performance_summary.get('total_duration_formatted', 'N/A')}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>平均耗時</h3>
|
||||
<div class="value">{report.performance_summary.get('avg_duration_formatted', 'N/A')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scenarios">
|
||||
<h2>📋 測試場景詳情</h2>
|
||||
"""
|
||||
|
||||
for i, scenario in enumerate(report.scenarios):
|
||||
success = scenario.get("success", False)
|
||||
scenario_name = scenario.get("scenario_name", f"Scenario {i+1}")
|
||||
scenario_desc = scenario.get("scenario_description", "無描述")
|
||||
|
||||
perf = scenario.get("performance", {})
|
||||
duration = perf.get("total_duration", 0) or perf.get("total_time", 0)
|
||||
duration_str = TestUtils.format_duration(duration) if duration > 0 else "N/A"
|
||||
|
||||
steps = scenario.get("steps", {})
|
||||
completed_steps = sum(1 for v in steps.values() if v)
|
||||
total_steps = len(steps)
|
||||
|
||||
errors = scenario.get("errors", [])
|
||||
|
||||
html += f"""
|
||||
<div class="scenario {'failed' if not success else ''}">
|
||||
<h4>{'✅' if success else '❌'} {scenario_name}</h4>
|
||||
<p>{scenario_desc}</p>
|
||||
<div class="scenario-details">
|
||||
<div><strong>狀態:</strong> {'通過' if success else '失敗'}</div>
|
||||
<div><strong>耗時:</strong> {duration_str}</div>
|
||||
<div><strong>完成步驟:</strong> {completed_steps}/{total_steps}</div>
|
||||
<div><strong>錯誤數:</strong> {len(errors)}</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
if errors:
|
||||
html += '<div class="errors"><strong>錯誤信息:</strong><ul>'
|
||||
for error in errors:
|
||||
html += f'<li>{error}</li>'
|
||||
html += '</ul></div>'
|
||||
|
||||
html += '</div>'
|
||||
|
||||
html += f"""
|
||||
</div>
|
||||
|
||||
<div class="performance">
|
||||
<h2>📊 性能統計</h2>
|
||||
<div class="summary">
|
||||
<div class="card">
|
||||
<h3>最快測試</h3>
|
||||
<div class="value">{report.performance_summary.get('min_duration_formatted', 'N/A')}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>最慢測試</h3>
|
||||
<div class="value">{report.performance_summary.get('max_duration_formatted', 'N/A')}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>中位數</h3>
|
||||
<div class="value">{report.performance_summary.get('median_duration_formatted', 'N/A')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>MCP Feedback Enhanced 測試框架 | 生成時間: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return html
|
||||
|
||||
def _generate_markdown_report(self, report: TestReport) -> str:
|
||||
"""生成 Markdown 報告"""
|
||||
status_icon = "✅" if report.success_rate == 1.0 else "❌" if report.success_rate == 0 else "⚠️"
|
||||
|
||||
md = f"""# 🧪 MCP 測試報告
|
||||
|
||||
{status_icon} **測試狀態**: {'全部通過' if report.success_rate == 1.0 else '部分失敗' if report.success_rate > 0 else '全部失敗'}
|
||||
|
||||
**生成時間**: {report.timestamp}
|
||||
|
||||
## 📊 測試摘要
|
||||
|
||||
| 指標 | 數值 |
|
||||
|------|------|
|
||||
| 總測試數 | {report.total_scenarios} |
|
||||
| 通過測試 | {report.passed_scenarios} |
|
||||
| 失敗測試 | {report.failed_scenarios} |
|
||||
| 成功率 | {report.success_rate:.1%} |
|
||||
| 總耗時 | {report.performance_summary.get('total_duration_formatted', 'N/A')} |
|
||||
| 平均耗時 | {report.performance_summary.get('avg_duration_formatted', 'N/A')} |
|
||||
|
||||
## 📋 測試場景詳情
|
||||
|
||||
"""
|
||||
|
||||
for i, scenario in enumerate(report.scenarios):
|
||||
success = scenario.get("success", False)
|
||||
scenario_name = scenario.get("scenario_name", f"Scenario {i+1}")
|
||||
scenario_desc = scenario.get("scenario_description", "無描述")
|
||||
|
||||
perf = scenario.get("performance", {})
|
||||
duration = perf.get("total_duration", 0) or perf.get("total_time", 0)
|
||||
duration_str = TestUtils.format_duration(duration) if duration > 0 else "N/A"
|
||||
|
||||
steps = scenario.get("steps", {})
|
||||
completed_steps = sum(1 for v in steps.values() if v)
|
||||
total_steps = len(steps)
|
||||
|
||||
errors = scenario.get("errors", [])
|
||||
|
||||
md += f"""### {'✅' if success else '❌'} {scenario_name}
|
||||
|
||||
**描述**: {scenario_desc}
|
||||
|
||||
- **狀態**: {'通過' if success else '失敗'}
|
||||
- **耗時**: {duration_str}
|
||||
- **完成步驟**: {completed_steps}/{total_steps}
|
||||
- **錯誤數**: {len(errors)}
|
||||
|
||||
"""
|
||||
|
||||
if errors:
|
||||
md += "**錯誤信息**:\n"
|
||||
for error in errors:
|
||||
md += f"- {error}\n"
|
||||
md += "\n"
|
||||
|
||||
md += f"""## 📊 性能統計
|
||||
|
||||
| 指標 | 數值 |
|
||||
|------|------|
|
||||
| 最快測試 | {report.performance_summary.get('min_duration_formatted', 'N/A')} |
|
||||
| 最慢測試 | {report.performance_summary.get('max_duration_formatted', 'N/A')} |
|
||||
| 中位數 | {report.performance_summary.get('median_duration_formatted', 'N/A')} |
|
||||
|
||||
## 🔧 系統信息
|
||||
|
||||
| 項目 | 值 |
|
||||
|------|---|
|
||||
| CPU 核心數 | {report.system_info.get('cpu_count', 'N/A')} |
|
||||
| 總內存 | {report.system_info.get('memory_total', 'N/A')} |
|
||||
| 可用內存 | {report.system_info.get('memory_available', 'N/A')} |
|
||||
|
||||
---
|
||||
|
||||
*報告由 MCP Feedback Enhanced 測試框架生成 | {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*
|
||||
"""
|
||||
|
||||
return md
|
||||
|
||||
def print_summary(self, report: TestReport):
|
||||
"""打印測試摘要到控制台"""
|
||||
status_icon = "✅" if report.success_rate == 1.0 else "❌" if report.success_rate == 0 else "⚠️"
|
||||
|
||||
print("\n" + "="*60)
|
||||
print(f"🧪 MCP 測試報告摘要 {status_icon}")
|
||||
print("="*60)
|
||||
print(f"📊 總測試數: {report.total_scenarios}")
|
||||
print(f"✅ 通過測試: {report.passed_scenarios}")
|
||||
print(f"❌ 失敗測試: {report.failed_scenarios}")
|
||||
print(f"📈 成功率: {report.success_rate:.1%}")
|
||||
print(f"⏱️ 總耗時: {report.performance_summary.get('total_duration_formatted', 'N/A')}")
|
||||
print(f"⚡ 平均耗時: {report.performance_summary.get('avg_duration_formatted', 'N/A')}")
|
||||
|
||||
if report.errors:
|
||||
print(f"\n❌ 發現 {len(report.errors)} 個錯誤:")
|
||||
for error in report.errors[:5]: # 只顯示前5個錯誤
|
||||
print(f" • {error}")
|
||||
if len(report.errors) > 5:
|
||||
print(f" ... 還有 {len(report.errors) - 5} 個錯誤")
|
||||
|
||||
print("="*60)
|
469
src/mcp_feedback_enhanced/testing/scenarios.py
Normal file
469
src/mcp_feedback_enhanced/testing/scenarios.py
Normal file
@ -0,0 +1,469 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
測試場景定義
|
||||
============
|
||||
|
||||
定義各種 MCP 測試場景,包括正常流程、錯誤處理、性能測試等。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import random
|
||||
from typing import Dict, Any, List, Optional, Callable, Awaitable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from .mcp_client import MCPTestClient
|
||||
from .config import TestConfig, DEFAULT_CONFIG
|
||||
from .utils import TestUtils, PerformanceMonitor, performance_context
|
||||
from ..debug import debug_log
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestScenario:
|
||||
"""測試場景類"""
|
||||
name: str
|
||||
description: str
|
||||
timeout: int = 120
|
||||
retry_count: int = 1
|
||||
parallel: bool = False
|
||||
tags: List[str] = field(default_factory=list)
|
||||
setup: Optional[Callable] = None
|
||||
teardown: Optional[Callable] = None
|
||||
|
||||
async def run(self, client: MCPTestClient) -> Dict[str, Any]:
|
||||
"""運行測試場景"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class BasicWorkflowScenario(TestScenario):
|
||||
"""基礎工作流程測試場景"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
name="basic_workflow",
|
||||
description="測試基本的 MCP 工作流程:初始化 -> 工具發現 -> 工具調用",
|
||||
timeout=180,
|
||||
tags=["basic", "workflow", "integration"]
|
||||
)
|
||||
|
||||
async def run(self, client: MCPTestClient) -> Dict[str, Any]:
|
||||
"""運行基礎工作流程測試"""
|
||||
async with performance_context("basic_workflow") as monitor:
|
||||
result = await client.full_workflow_test()
|
||||
|
||||
# 添加額外的驗證
|
||||
if result["success"]:
|
||||
# 檢查必要的步驟是否完成
|
||||
required_steps = ["server_started", "initialized", "tools_discovered", "interactive_feedback_called"]
|
||||
missing_steps = [step for step in required_steps if not result["steps"].get(step, False)]
|
||||
|
||||
if missing_steps:
|
||||
result["success"] = False
|
||||
result["errors"].append(f"缺少必要步驟: {missing_steps}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class QuickConnectionScenario(TestScenario):
|
||||
"""快速連接測試場景"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
name="quick_connection",
|
||||
description="測試 MCP 服務器的快速啟動和連接",
|
||||
timeout=30,
|
||||
tags=["quick", "connection", "startup"]
|
||||
)
|
||||
|
||||
async def run(self, client: MCPTestClient) -> Dict[str, Any]:
|
||||
"""運行快速連接測試"""
|
||||
result = {
|
||||
"success": False,
|
||||
"steps": {},
|
||||
"performance": {},
|
||||
"errors": []
|
||||
}
|
||||
|
||||
try:
|
||||
start_time = time.time()
|
||||
|
||||
# 啟動服務器
|
||||
if not await client.start_server():
|
||||
result["errors"].append("服務器啟動失敗")
|
||||
return result
|
||||
result["steps"]["server_started"] = True
|
||||
|
||||
# 啟動消息讀取
|
||||
read_task = asyncio.create_task(client.read_messages())
|
||||
|
||||
try:
|
||||
# 初始化連接
|
||||
if not await client.initialize():
|
||||
result["errors"].append("初始化失敗")
|
||||
return result
|
||||
result["steps"]["initialized"] = True
|
||||
|
||||
# 獲取工具列表
|
||||
tools = await client.list_tools()
|
||||
if not tools:
|
||||
result["errors"].append("工具列表為空")
|
||||
return result
|
||||
result["steps"]["tools_discovered"] = True
|
||||
|
||||
end_time = time.time()
|
||||
result["performance"]["total_time"] = end_time - start_time
|
||||
result["performance"]["tools_count"] = len(tools)
|
||||
result["success"] = True
|
||||
|
||||
finally:
|
||||
read_task.cancel()
|
||||
try:
|
||||
await read_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
result["errors"].append(f"測試異常: {str(e)}")
|
||||
|
||||
finally:
|
||||
await client.cleanup()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class TimeoutHandlingScenario(TestScenario):
|
||||
"""超時處理測試場景"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
name="timeout_handling",
|
||||
description="測試超時情況下的處理機制",
|
||||
timeout=60,
|
||||
tags=["timeout", "error_handling", "resilience"]
|
||||
)
|
||||
|
||||
async def run(self, client: MCPTestClient) -> Dict[str, Any]:
|
||||
"""運行超時處理測試"""
|
||||
result = {
|
||||
"success": False,
|
||||
"steps": {},
|
||||
"performance": {},
|
||||
"errors": []
|
||||
}
|
||||
|
||||
try:
|
||||
# 設置很短的超時時間來觸發超時
|
||||
original_timeout = client.config.mcp_timeout
|
||||
client.config.mcp_timeout = 5 # 5 秒超時
|
||||
|
||||
# 啟動服務器
|
||||
if not await client.start_server():
|
||||
result["errors"].append("服務器啟動失敗")
|
||||
return result
|
||||
result["steps"]["server_started"] = True
|
||||
|
||||
# 啟動消息讀取
|
||||
read_task = asyncio.create_task(client.read_messages())
|
||||
|
||||
try:
|
||||
# 初始化連接
|
||||
if not await client.initialize():
|
||||
result["errors"].append("初始化失敗")
|
||||
return result
|
||||
result["steps"]["initialized"] = True
|
||||
|
||||
# 嘗試調用互動回饋工具(應該超時)
|
||||
feedback_result = await client.call_interactive_feedback(
|
||||
str(Path.cwd()),
|
||||
"超時測試 - 這個調用應該會超時",
|
||||
timeout=10 # 10 秒超時,但 MCP 客戶端設置為 5 秒
|
||||
)
|
||||
|
||||
# 檢查是否正確處理了超時
|
||||
if "error" in feedback_result:
|
||||
result["steps"]["timeout_handled"] = True
|
||||
result["success"] = True
|
||||
debug_log("✅ 超時處理測試成功")
|
||||
else:
|
||||
result["errors"].append("未正確處理超時情況")
|
||||
|
||||
finally:
|
||||
read_task.cancel()
|
||||
try:
|
||||
await read_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# 恢復原始超時設置
|
||||
client.config.mcp_timeout = original_timeout
|
||||
|
||||
except Exception as e:
|
||||
result["errors"].append(f"測試異常: {str(e)}")
|
||||
|
||||
finally:
|
||||
await client.cleanup()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class ConcurrentCallsScenario(TestScenario):
|
||||
"""並發調用測試場景"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
name="concurrent_calls",
|
||||
description="測試並發 MCP 調用的處理能力",
|
||||
timeout=300,
|
||||
parallel=True,
|
||||
tags=["concurrent", "performance", "stress"]
|
||||
)
|
||||
|
||||
async def run(self, client: MCPTestClient) -> Dict[str, Any]:
|
||||
"""運行並發調用測試"""
|
||||
result = {
|
||||
"success": False,
|
||||
"steps": {},
|
||||
"performance": {},
|
||||
"errors": []
|
||||
}
|
||||
|
||||
try:
|
||||
# 啟動服務器
|
||||
if not await client.start_server():
|
||||
result["errors"].append("服務器啟動失敗")
|
||||
return result
|
||||
result["steps"]["server_started"] = True
|
||||
|
||||
# 啟動消息讀取
|
||||
read_task = asyncio.create_task(client.read_messages())
|
||||
|
||||
try:
|
||||
# 初始化連接
|
||||
if not await client.initialize():
|
||||
result["errors"].append("初始化失敗")
|
||||
return result
|
||||
result["steps"]["initialized"] = True
|
||||
|
||||
# 並發獲取工具列表
|
||||
concurrent_count = 5
|
||||
tasks = []
|
||||
|
||||
for i in range(concurrent_count):
|
||||
task = asyncio.create_task(client.list_tools())
|
||||
tasks.append(task)
|
||||
|
||||
start_time = time.time()
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
end_time = time.time()
|
||||
|
||||
# 分析結果
|
||||
successful_calls = 0
|
||||
failed_calls = 0
|
||||
|
||||
for i, res in enumerate(results):
|
||||
if isinstance(res, Exception):
|
||||
failed_calls += 1
|
||||
debug_log(f"並發調用 {i+1} 失敗: {res}")
|
||||
elif isinstance(res, list) and len(res) > 0:
|
||||
successful_calls += 1
|
||||
else:
|
||||
failed_calls += 1
|
||||
|
||||
result["performance"]["concurrent_count"] = concurrent_count
|
||||
result["performance"]["successful_calls"] = successful_calls
|
||||
result["performance"]["failed_calls"] = failed_calls
|
||||
result["performance"]["total_time"] = end_time - start_time
|
||||
result["performance"]["avg_time_per_call"] = (end_time - start_time) / concurrent_count
|
||||
|
||||
# 判斷成功條件:至少 80% 的調用成功
|
||||
success_rate = successful_calls / concurrent_count
|
||||
if success_rate >= 0.8:
|
||||
result["success"] = True
|
||||
result["steps"]["concurrent_calls_handled"] = True
|
||||
debug_log(f"✅ 並發調用測試成功 (成功率: {success_rate:.1%})")
|
||||
else:
|
||||
result["errors"].append(f"並發調用成功率過低: {success_rate:.1%}")
|
||||
|
||||
finally:
|
||||
read_task.cancel()
|
||||
try:
|
||||
await read_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
result["errors"].append(f"測試異常: {str(e)}")
|
||||
|
||||
finally:
|
||||
await client.cleanup()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class MockTestScenario(TestScenario):
|
||||
"""模擬測試場景(用於演示)"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
name="mock_test",
|
||||
description="模擬測試場景,用於演示測試框架功能",
|
||||
timeout=10,
|
||||
tags=["mock", "demo", "quick"]
|
||||
)
|
||||
|
||||
async def run(self, client: MCPTestClient) -> Dict[str, Any]:
|
||||
"""運行模擬測試"""
|
||||
result = {
|
||||
"success": True,
|
||||
"steps": {
|
||||
"mock_step_1": True,
|
||||
"mock_step_2": True,
|
||||
"mock_step_3": True
|
||||
},
|
||||
"performance": {
|
||||
"total_duration": 0.5,
|
||||
"total_time": 0.5
|
||||
},
|
||||
"errors": []
|
||||
}
|
||||
|
||||
# 模擬一些處理時間
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
debug_log("✅ 模擬測試完成")
|
||||
return result
|
||||
|
||||
|
||||
class TestScenarios:
|
||||
"""測試場景管理器"""
|
||||
|
||||
def __init__(self, config: Optional[TestConfig] = None):
|
||||
self.config = config or DEFAULT_CONFIG
|
||||
self.scenarios: Dict[str, TestScenario] = {}
|
||||
self._register_default_scenarios()
|
||||
|
||||
def _register_default_scenarios(self):
|
||||
"""註冊默認測試場景"""
|
||||
scenarios = [
|
||||
MockTestScenario(), # 添加模擬測試場景
|
||||
BasicWorkflowScenario(),
|
||||
QuickConnectionScenario(),
|
||||
TimeoutHandlingScenario(),
|
||||
ConcurrentCallsScenario(),
|
||||
]
|
||||
|
||||
for scenario in scenarios:
|
||||
self.scenarios[scenario.name] = scenario
|
||||
|
||||
def register_scenario(self, scenario: TestScenario):
|
||||
"""註冊自定義測試場景"""
|
||||
self.scenarios[scenario.name] = scenario
|
||||
|
||||
def get_scenario(self, name: str) -> Optional[TestScenario]:
|
||||
"""獲取測試場景"""
|
||||
return self.scenarios.get(name)
|
||||
|
||||
def list_scenarios(self, tags: Optional[List[str]] = None) -> List[TestScenario]:
|
||||
"""列出測試場景"""
|
||||
scenarios = list(self.scenarios.values())
|
||||
|
||||
if tags:
|
||||
scenarios = [
|
||||
scenario for scenario in scenarios
|
||||
if any(tag in scenario.tags for tag in tags)
|
||||
]
|
||||
|
||||
return scenarios
|
||||
|
||||
async def run_scenario(self, scenario_name: str) -> Dict[str, Any]:
|
||||
"""運行單個測試場景"""
|
||||
scenario = self.get_scenario(scenario_name)
|
||||
if not scenario:
|
||||
return {
|
||||
"success": False,
|
||||
"errors": [f"未找到測試場景: {scenario_name}"]
|
||||
}
|
||||
|
||||
debug_log(f"🧪 運行測試場景: {scenario.name}")
|
||||
debug_log(f" 描述: {scenario.description}")
|
||||
|
||||
client = MCPTestClient(self.config)
|
||||
|
||||
try:
|
||||
# 執行設置
|
||||
if scenario.setup:
|
||||
await scenario.setup()
|
||||
|
||||
# 運行測試
|
||||
result = await TestUtils.timeout_wrapper(
|
||||
scenario.run(client),
|
||||
scenario.timeout,
|
||||
f"測試場景 '{scenario.name}' 超時"
|
||||
)
|
||||
|
||||
result["scenario_name"] = scenario.name
|
||||
result["scenario_description"] = scenario.description
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
debug_log(f"❌ 測試場景 '{scenario.name}' 執行失敗: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"scenario_name": scenario.name,
|
||||
"scenario_description": scenario.description,
|
||||
"errors": [f"執行異常: {str(e)}"]
|
||||
}
|
||||
|
||||
finally:
|
||||
# 執行清理
|
||||
if scenario.teardown:
|
||||
try:
|
||||
await scenario.teardown()
|
||||
except Exception as e:
|
||||
debug_log(f"⚠️ 測試場景 '{scenario.name}' 清理失敗: {e}")
|
||||
|
||||
async def run_all_scenarios(self, tags: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
"""運行所有測試場景"""
|
||||
scenarios = self.list_scenarios(tags)
|
||||
|
||||
if not scenarios:
|
||||
return {
|
||||
"success": False,
|
||||
"total_scenarios": 0,
|
||||
"passed_scenarios": 0,
|
||||
"failed_scenarios": 0,
|
||||
"results": [],
|
||||
"errors": ["沒有找到匹配的測試場景"]
|
||||
}
|
||||
|
||||
debug_log(f"🚀 開始運行 {len(scenarios)} 個測試場景...")
|
||||
|
||||
results = []
|
||||
passed_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for scenario in scenarios:
|
||||
result = await self.run_scenario(scenario.name)
|
||||
results.append(result)
|
||||
|
||||
if result.get("success", False):
|
||||
passed_count += 1
|
||||
debug_log(f"✅ {scenario.name}: 通過")
|
||||
else:
|
||||
failed_count += 1
|
||||
debug_log(f"❌ {scenario.name}: 失敗")
|
||||
|
||||
overall_success = failed_count == 0
|
||||
|
||||
debug_log(f"📊 測試完成: {passed_count}/{len(scenarios)} 通過")
|
||||
|
||||
return {
|
||||
"success": overall_success,
|
||||
"total_scenarios": len(scenarios),
|
||||
"passed_scenarios": passed_count,
|
||||
"failed_scenarios": failed_count,
|
||||
"results": results
|
||||
}
|
266
src/mcp_feedback_enhanced/testing/utils.py
Normal file
266
src/mcp_feedback_enhanced/testing/utils.py
Normal file
@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
測試工具函數
|
||||
============
|
||||
|
||||
提供 MCP 測試框架使用的通用工具函數。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import json
|
||||
import uuid
|
||||
import socket
|
||||
import psutil
|
||||
import threading
|
||||
from typing import Dict, Any, Optional, List, Callable, Awaitable
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from ..debug import debug_log
|
||||
|
||||
|
||||
class TestUtils:
|
||||
"""測試工具類"""
|
||||
|
||||
@staticmethod
|
||||
def generate_test_id() -> str:
|
||||
"""生成測試 ID"""
|
||||
return f"test_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
@staticmethod
|
||||
def generate_session_id() -> str:
|
||||
"""生成會話 ID"""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
@staticmethod
|
||||
def get_timestamp() -> str:
|
||||
"""獲取當前時間戳"""
|
||||
return datetime.now().isoformat()
|
||||
|
||||
@staticmethod
|
||||
def format_duration(seconds: float) -> str:
|
||||
"""格式化持續時間"""
|
||||
if seconds < 1:
|
||||
return f"{seconds*1000:.1f}ms"
|
||||
elif seconds < 60:
|
||||
return f"{seconds:.2f}s"
|
||||
else:
|
||||
minutes = int(seconds // 60)
|
||||
remaining_seconds = seconds % 60
|
||||
return f"{minutes}m {remaining_seconds:.1f}s"
|
||||
|
||||
@staticmethod
|
||||
def find_free_port(start_port: int = 8765, max_attempts: int = 100) -> int:
|
||||
"""尋找可用端口"""
|
||||
for port in range(start_port, start_port + max_attempts):
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(('127.0.0.1', port))
|
||||
return port
|
||||
except OSError:
|
||||
continue
|
||||
raise RuntimeError(f"無法找到可用端口 (嘗試範圍: {start_port}-{start_port + max_attempts})")
|
||||
|
||||
@staticmethod
|
||||
def is_port_open(host: str, port: int, timeout: float = 1.0) -> bool:
|
||||
"""檢查端口是否開放"""
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(timeout)
|
||||
result = s.connect_ex((host, port))
|
||||
return result == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def wait_for_port(host: str, port: int, timeout: float = 30.0, interval: float = 0.5) -> bool:
|
||||
"""等待端口開放"""
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
if TestUtils.is_port_open(host, port):
|
||||
return True
|
||||
await asyncio.sleep(interval)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_system_info() -> Dict[str, Any]:
|
||||
"""獲取系統信息"""
|
||||
try:
|
||||
return {
|
||||
'cpu_count': psutil.cpu_count(),
|
||||
'memory_total': psutil.virtual_memory().total,
|
||||
'memory_available': psutil.virtual_memory().available,
|
||||
'disk_usage': psutil.disk_usage('/').percent if hasattr(psutil, 'disk_usage') else None,
|
||||
'platform': psutil.WINDOWS if hasattr(psutil, 'WINDOWS') else 'unknown'
|
||||
}
|
||||
except Exception as e:
|
||||
debug_log(f"獲取系統信息失敗: {e}")
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def measure_memory_usage() -> Dict[str, float]:
|
||||
"""測量內存使用情況"""
|
||||
try:
|
||||
process = psutil.Process()
|
||||
memory_info = process.memory_info()
|
||||
return {
|
||||
'rss': memory_info.rss / 1024 / 1024, # MB
|
||||
'vms': memory_info.vms / 1024 / 1024, # MB
|
||||
'percent': process.memory_percent()
|
||||
}
|
||||
except Exception as e:
|
||||
debug_log(f"測量內存使用失敗: {e}")
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
async def timeout_wrapper(coro: Awaitable, timeout: float, error_message: str = "操作超時"):
|
||||
"""為協程添加超時包裝"""
|
||||
try:
|
||||
return await asyncio.wait_for(coro, timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
raise TimeoutError(f"{error_message} (超時: {timeout}s)")
|
||||
|
||||
@staticmethod
|
||||
def safe_json_loads(data: str) -> Optional[Dict[str, Any]]:
|
||||
"""安全的 JSON 解析"""
|
||||
try:
|
||||
return json.loads(data)
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
debug_log(f"JSON 解析失敗: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def safe_json_dumps(data: Any, indent: int = 2) -> str:
|
||||
"""安全的 JSON 序列化"""
|
||||
try:
|
||||
return json.dumps(data, indent=indent, ensure_ascii=False, default=str)
|
||||
except (TypeError, ValueError) as e:
|
||||
debug_log(f"JSON 序列化失敗: {e}")
|
||||
return str(data)
|
||||
|
||||
@staticmethod
|
||||
def create_test_directory(base_dir: str = "test_temp") -> Path:
|
||||
"""創建測試目錄"""
|
||||
test_dir = Path(base_dir) / f"test_{uuid.uuid4().hex[:8]}"
|
||||
test_dir.mkdir(parents=True, exist_ok=True)
|
||||
return test_dir
|
||||
|
||||
@staticmethod
|
||||
def cleanup_test_directory(test_dir: Path):
|
||||
"""清理測試目錄"""
|
||||
try:
|
||||
if test_dir.exists() and test_dir.is_dir():
|
||||
import shutil
|
||||
shutil.rmtree(test_dir)
|
||||
except Exception as e:
|
||||
debug_log(f"清理測試目錄失敗: {e}")
|
||||
|
||||
|
||||
class PerformanceMonitor:
|
||||
"""性能監控器"""
|
||||
|
||||
def __init__(self):
|
||||
self.start_time: Optional[float] = None
|
||||
self.end_time: Optional[float] = None
|
||||
self.memory_start: Optional[Dict[str, float]] = None
|
||||
self.memory_end: Optional[Dict[str, float]] = None
|
||||
self.checkpoints: List[Dict[str, Any]] = []
|
||||
|
||||
def start(self):
|
||||
"""開始監控"""
|
||||
self.start_time = time.time()
|
||||
self.memory_start = TestUtils.measure_memory_usage()
|
||||
self.checkpoints = []
|
||||
|
||||
def checkpoint(self, name: str, data: Optional[Dict[str, Any]] = None):
|
||||
"""添加檢查點"""
|
||||
if self.start_time is None:
|
||||
return
|
||||
|
||||
checkpoint = {
|
||||
'name': name,
|
||||
'timestamp': time.time(),
|
||||
'elapsed': time.time() - self.start_time,
|
||||
'memory': TestUtils.measure_memory_usage(),
|
||||
'data': data or {}
|
||||
}
|
||||
self.checkpoints.append(checkpoint)
|
||||
|
||||
def stop(self):
|
||||
"""停止監控"""
|
||||
self.end_time = time.time()
|
||||
self.memory_end = TestUtils.measure_memory_usage()
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
"""獲取監控摘要"""
|
||||
if self.start_time is None or self.end_time is None:
|
||||
return {}
|
||||
|
||||
total_duration = self.end_time - self.start_time
|
||||
memory_diff = {}
|
||||
|
||||
if self.memory_start and self.memory_end:
|
||||
for key in self.memory_start:
|
||||
if key in self.memory_end:
|
||||
memory_diff[f"memory_{key}_diff"] = self.memory_end[key] - self.memory_start[key]
|
||||
|
||||
return {
|
||||
'total_duration': total_duration,
|
||||
'total_duration_formatted': TestUtils.format_duration(total_duration),
|
||||
'memory_start': self.memory_start,
|
||||
'memory_end': self.memory_end,
|
||||
'memory_diff': memory_diff,
|
||||
'checkpoints_count': len(self.checkpoints),
|
||||
'checkpoints': self.checkpoints
|
||||
}
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def performance_context(name: str = "test"):
|
||||
"""性能監控上下文管理器"""
|
||||
monitor = PerformanceMonitor()
|
||||
monitor.start()
|
||||
try:
|
||||
yield monitor
|
||||
finally:
|
||||
monitor.stop()
|
||||
summary = monitor.get_summary()
|
||||
debug_log(f"性能監控 [{name}]: {TestUtils.format_duration(summary.get('total_duration', 0))}")
|
||||
|
||||
|
||||
class AsyncEventWaiter:
|
||||
"""異步事件等待器"""
|
||||
|
||||
def __init__(self):
|
||||
self.events: Dict[str, asyncio.Event] = {}
|
||||
self.results: Dict[str, Any] = {}
|
||||
|
||||
def create_event(self, event_name: str):
|
||||
"""創建事件"""
|
||||
self.events[event_name] = asyncio.Event()
|
||||
|
||||
def set_event(self, event_name: str, result: Any = None):
|
||||
"""設置事件"""
|
||||
if event_name in self.events:
|
||||
self.results[event_name] = result
|
||||
self.events[event_name].set()
|
||||
|
||||
async def wait_for_event(self, event_name: str, timeout: float = 30.0) -> Any:
|
||||
"""等待事件"""
|
||||
if event_name not in self.events:
|
||||
self.create_event(event_name)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(self.events[event_name].wait(), timeout=timeout)
|
||||
return self.results.get(event_name)
|
||||
except asyncio.TimeoutError:
|
||||
raise TimeoutError(f"等待事件 '{event_name}' 超時 ({timeout}s)")
|
||||
|
||||
def clear_event(self, event_name: str):
|
||||
"""清除事件"""
|
||||
if event_name in self.events:
|
||||
self.events[event_name].clear()
|
||||
self.results.pop(event_name, None)
|
394
src/mcp_feedback_enhanced/testing/validators.py
Normal file
394
src/mcp_feedback_enhanced/testing/validators.py
Normal file
@ -0,0 +1,394 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
測試結果驗證器
|
||||
==============
|
||||
|
||||
驗證 MCP 測試結果是否符合規範和預期。
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .config import TestConfig, DEFAULT_CONFIG
|
||||
from .utils import TestUtils
|
||||
from ..debug import debug_log
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""驗證結果"""
|
||||
valid: bool
|
||||
errors: List[str]
|
||||
warnings: List[str]
|
||||
details: Dict[str, Any]
|
||||
|
||||
def add_error(self, message: str):
|
||||
"""添加錯誤"""
|
||||
self.errors.append(message)
|
||||
self.valid = False
|
||||
|
||||
def add_warning(self, message: str):
|
||||
"""添加警告"""
|
||||
self.warnings.append(message)
|
||||
|
||||
def add_detail(self, key: str, value: Any):
|
||||
"""添加詳細信息"""
|
||||
self.details[key] = value
|
||||
|
||||
|
||||
class MCPMessageValidator:
|
||||
"""MCP 消息驗證器"""
|
||||
|
||||
@staticmethod
|
||||
def validate_json_rpc(message: Dict[str, Any]) -> ValidationResult:
|
||||
"""驗證 JSON-RPC 2.0 格式"""
|
||||
result = ValidationResult(True, [], [], {})
|
||||
|
||||
# 檢查必需字段
|
||||
if "jsonrpc" not in message:
|
||||
result.add_error("缺少 'jsonrpc' 字段")
|
||||
elif message["jsonrpc"] != "2.0":
|
||||
result.add_error(f"無效的 jsonrpc 版本: {message['jsonrpc']}")
|
||||
|
||||
# 檢查消息類型
|
||||
is_request = "method" in message
|
||||
is_response = "result" in message or "error" in message
|
||||
is_notification = is_request and "id" not in message
|
||||
|
||||
if not (is_request or is_response):
|
||||
result.add_error("消息既不是請求也不是響應")
|
||||
|
||||
if is_request and is_response:
|
||||
result.add_error("消息不能同時是請求和響應")
|
||||
|
||||
# 驗證請求格式
|
||||
if is_request:
|
||||
if not isinstance(message.get("method"), str):
|
||||
result.add_error("method 字段必須是字符串")
|
||||
|
||||
if not is_notification and "id" not in message:
|
||||
result.add_error("非通知請求必須包含 id 字段")
|
||||
|
||||
# 驗證響應格式
|
||||
if is_response:
|
||||
if "id" not in message:
|
||||
result.add_error("響應必須包含 id 字段")
|
||||
|
||||
if "result" in message and "error" in message:
|
||||
result.add_error("響應不能同時包含 result 和 error")
|
||||
|
||||
if "result" not in message and "error" not in message:
|
||||
result.add_error("響應必須包含 result 或 error")
|
||||
|
||||
# 驗證錯誤格式
|
||||
if "error" in message:
|
||||
error = message["error"]
|
||||
if not isinstance(error, dict):
|
||||
result.add_error("error 字段必須是對象")
|
||||
else:
|
||||
if "code" not in error:
|
||||
result.add_error("error 對象必須包含 code 字段")
|
||||
elif not isinstance(error["code"], int):
|
||||
result.add_error("error.code 必須是整數")
|
||||
|
||||
if "message" not in error:
|
||||
result.add_error("error 對象必須包含 message 字段")
|
||||
elif not isinstance(error["message"], str):
|
||||
result.add_error("error.message 必須是字符串")
|
||||
|
||||
result.add_detail("message_type", "request" if is_request else "response")
|
||||
result.add_detail("is_notification", is_notification)
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def validate_mcp_initialize(message: Dict[str, Any]) -> ValidationResult:
|
||||
"""驗證 MCP 初始化消息"""
|
||||
result = ValidationResult(True, [], [], {})
|
||||
|
||||
# 先驗證 JSON-RPC 格式
|
||||
json_rpc_result = MCPMessageValidator.validate_json_rpc(message)
|
||||
result.errors.extend(json_rpc_result.errors)
|
||||
result.warnings.extend(json_rpc_result.warnings)
|
||||
|
||||
if not json_rpc_result.valid:
|
||||
result.valid = False
|
||||
return result
|
||||
|
||||
# 驗證初始化特定字段
|
||||
if message.get("method") == "initialize":
|
||||
params = message.get("params", {})
|
||||
|
||||
if "protocolVersion" not in params:
|
||||
result.add_error("初始化請求必須包含 protocolVersion")
|
||||
|
||||
if "clientInfo" not in params:
|
||||
result.add_error("初始化請求必須包含 clientInfo")
|
||||
else:
|
||||
client_info = params["clientInfo"]
|
||||
if not isinstance(client_info, dict):
|
||||
result.add_error("clientInfo 必須是對象")
|
||||
else:
|
||||
if "name" not in client_info:
|
||||
result.add_error("clientInfo 必須包含 name")
|
||||
if "version" not in client_info:
|
||||
result.add_error("clientInfo 必須包含 version")
|
||||
|
||||
elif "result" in message:
|
||||
# 驗證初始化響應
|
||||
result_data = message.get("result", {})
|
||||
|
||||
if "serverInfo" not in result_data:
|
||||
result.add_warning("初始化響應建議包含 serverInfo")
|
||||
|
||||
if "capabilities" not in result_data:
|
||||
result.add_warning("初始化響應建議包含 capabilities")
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def validate_tools_list(message: Dict[str, Any]) -> ValidationResult:
|
||||
"""驗證工具列表消息"""
|
||||
result = ValidationResult(True, [], [], {})
|
||||
|
||||
# 先驗證 JSON-RPC 格式
|
||||
json_rpc_result = MCPMessageValidator.validate_json_rpc(message)
|
||||
result.errors.extend(json_rpc_result.errors)
|
||||
result.warnings.extend(json_rpc_result.warnings)
|
||||
|
||||
if not json_rpc_result.valid:
|
||||
result.valid = False
|
||||
return result
|
||||
|
||||
# 驗證工具列表響應
|
||||
if "result" in message:
|
||||
result_data = message.get("result", {})
|
||||
|
||||
if "tools" not in result_data:
|
||||
result.add_error("工具列表響應必須包含 tools 字段")
|
||||
else:
|
||||
tools = result_data["tools"]
|
||||
if not isinstance(tools, list):
|
||||
result.add_error("tools 字段必須是數組")
|
||||
else:
|
||||
for i, tool in enumerate(tools):
|
||||
if not isinstance(tool, dict):
|
||||
result.add_error(f"工具 {i} 必須是對象")
|
||||
continue
|
||||
|
||||
if "name" not in tool:
|
||||
result.add_error(f"工具 {i} 必須包含 name 字段")
|
||||
|
||||
if "description" not in tool:
|
||||
result.add_warning(f"工具 {i} 建議包含 description 字段")
|
||||
|
||||
if "inputSchema" not in tool:
|
||||
result.add_warning(f"工具 {i} 建議包含 inputSchema 字段")
|
||||
|
||||
result.add_detail("tools_count", len(tools))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class TestResultValidator:
|
||||
"""測試結果驗證器"""
|
||||
|
||||
def __init__(self, config: Optional[TestConfig] = None):
|
||||
self.config = config or DEFAULT_CONFIG
|
||||
|
||||
def validate_test_result(self, test_result: Dict[str, Any]) -> ValidationResult:
|
||||
"""驗證測試結果"""
|
||||
result = ValidationResult(True, [], [], {})
|
||||
|
||||
# 檢查必需字段
|
||||
required_fields = ["success", "steps", "performance", "errors"]
|
||||
for field in required_fields:
|
||||
if field not in test_result:
|
||||
result.add_error(f"測試結果缺少必需字段: {field}")
|
||||
|
||||
# 驗證成功標誌
|
||||
if "success" in test_result:
|
||||
if not isinstance(test_result["success"], bool):
|
||||
result.add_error("success 字段必須是布爾值")
|
||||
|
||||
# 驗證步驟信息
|
||||
if "steps" in test_result:
|
||||
steps = test_result["steps"]
|
||||
if not isinstance(steps, dict):
|
||||
result.add_error("steps 字段必須是對象")
|
||||
else:
|
||||
result.add_detail("completed_steps", list(steps.keys()))
|
||||
result.add_detail("steps_count", len(steps))
|
||||
|
||||
# 驗證錯誤信息
|
||||
if "errors" in test_result:
|
||||
errors = test_result["errors"]
|
||||
if not isinstance(errors, list):
|
||||
result.add_error("errors 字段必須是數組")
|
||||
else:
|
||||
result.add_detail("error_count", len(errors))
|
||||
if len(errors) > 0 and test_result.get("success", False):
|
||||
result.add_warning("測試標記為成功但包含錯誤信息")
|
||||
|
||||
# 驗證性能數據
|
||||
if "performance" in test_result:
|
||||
performance = test_result["performance"]
|
||||
if not isinstance(performance, dict):
|
||||
result.add_error("performance 字段必須是對象")
|
||||
else:
|
||||
self._validate_performance_data(performance, result)
|
||||
|
||||
return result
|
||||
|
||||
def _validate_performance_data(self, performance: Dict[str, Any], result: ValidationResult):
|
||||
"""驗證性能數據"""
|
||||
# 檢查時間相關字段
|
||||
time_fields = ["total_duration", "total_time"]
|
||||
for field in time_fields:
|
||||
if field in performance:
|
||||
value = performance[field]
|
||||
if not isinstance(value, (int, float)):
|
||||
result.add_error(f"性能字段 {field} 必須是數字")
|
||||
elif value < 0:
|
||||
result.add_error(f"性能字段 {field} 不能為負數")
|
||||
elif value > self.config.test_timeout:
|
||||
result.add_warning(f"性能字段 {field} 超過測試超時時間")
|
||||
|
||||
# 檢查內存相關字段
|
||||
memory_fields = ["memory_start", "memory_end", "memory_diff"]
|
||||
for field in memory_fields:
|
||||
if field in performance:
|
||||
value = performance[field]
|
||||
if not isinstance(value, dict):
|
||||
result.add_warning(f"性能字段 {field} 應該是對象")
|
||||
|
||||
# 檢查檢查點數據
|
||||
if "checkpoints" in performance:
|
||||
checkpoints = performance["checkpoints"]
|
||||
if not isinstance(checkpoints, list):
|
||||
result.add_error("checkpoints 字段必須是數組")
|
||||
else:
|
||||
result.add_detail("checkpoints_count", len(checkpoints))
|
||||
|
||||
def validate_interactive_feedback_result(self, feedback_result: Dict[str, Any]) -> ValidationResult:
|
||||
"""驗證互動回饋結果"""
|
||||
result = ValidationResult(True, [], [], {})
|
||||
|
||||
# 檢查是否有錯誤
|
||||
if "error" in feedback_result:
|
||||
result.add_detail("has_error", True)
|
||||
result.add_detail("error_message", feedback_result["error"])
|
||||
return result
|
||||
|
||||
# 檢查預期字段
|
||||
expected_fields = ["command_logs", "interactive_feedback", "images"]
|
||||
for field in expected_fields:
|
||||
if field not in feedback_result:
|
||||
result.add_warning(f"互動回饋結果建議包含 {field} 字段")
|
||||
|
||||
# 驗證命令日誌
|
||||
if "command_logs" in feedback_result:
|
||||
logs = feedback_result["command_logs"]
|
||||
if not isinstance(logs, str):
|
||||
result.add_error("command_logs 字段必須是字符串")
|
||||
|
||||
# 驗證互動回饋
|
||||
if "interactive_feedback" in feedback_result:
|
||||
feedback = feedback_result["interactive_feedback"]
|
||||
if not isinstance(feedback, str):
|
||||
result.add_error("interactive_feedback 字段必須是字符串")
|
||||
elif len(feedback.strip()) == 0:
|
||||
result.add_warning("interactive_feedback 為空")
|
||||
|
||||
# 驗證圖片數據
|
||||
if "images" in feedback_result:
|
||||
images = feedback_result["images"]
|
||||
if not isinstance(images, list):
|
||||
result.add_error("images 字段必須是數組")
|
||||
else:
|
||||
result.add_detail("images_count", len(images))
|
||||
for i, image in enumerate(images):
|
||||
if not isinstance(image, dict):
|
||||
result.add_error(f"圖片 {i} 必須是對象")
|
||||
continue
|
||||
|
||||
if "data" not in image:
|
||||
result.add_error(f"圖片 {i} 必須包含 data 字段")
|
||||
|
||||
if "media_type" not in image:
|
||||
result.add_error(f"圖片 {i} 必須包含 media_type 字段")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class TestValidators:
|
||||
"""測試驗證器集合"""
|
||||
|
||||
def __init__(self, config: Optional[TestConfig] = None):
|
||||
self.config = config or DEFAULT_CONFIG
|
||||
self.message_validator = MCPMessageValidator()
|
||||
self.result_validator = TestResultValidator(config)
|
||||
|
||||
def validate_all(self, test_data: Dict[str, Any]) -> Dict[str, ValidationResult]:
|
||||
"""驗證所有測試數據"""
|
||||
results = {}
|
||||
|
||||
# 驗證測試結果
|
||||
if "test_result" in test_data:
|
||||
results["test_result"] = self.result_validator.validate_test_result(
|
||||
test_data["test_result"]
|
||||
)
|
||||
|
||||
# 驗證 MCP 消息
|
||||
if "mcp_messages" in test_data:
|
||||
message_results = []
|
||||
for i, message in enumerate(test_data["mcp_messages"]):
|
||||
msg_result = self.message_validator.validate_json_rpc(message)
|
||||
msg_result.add_detail("message_index", i)
|
||||
message_results.append(msg_result)
|
||||
results["mcp_messages"] = message_results
|
||||
|
||||
# 驗證互動回饋結果
|
||||
if "feedback_result" in test_data:
|
||||
results["feedback_result"] = self.result_validator.validate_interactive_feedback_result(
|
||||
test_data["feedback_result"]
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def get_validation_summary(self, validation_results: Dict[str, ValidationResult]) -> Dict[str, Any]:
|
||||
"""獲取驗證摘要"""
|
||||
total_errors = 0
|
||||
total_warnings = 0
|
||||
valid_count = 0
|
||||
total_count = 0
|
||||
|
||||
for key, result in validation_results.items():
|
||||
if isinstance(result, list):
|
||||
# 處理消息列表
|
||||
for msg_result in result:
|
||||
total_errors += len(msg_result.errors)
|
||||
total_warnings += len(msg_result.warnings)
|
||||
if msg_result.valid:
|
||||
valid_count += 1
|
||||
total_count += 1
|
||||
else:
|
||||
# 處理單個結果
|
||||
total_errors += len(result.errors)
|
||||
total_warnings += len(result.warnings)
|
||||
if result.valid:
|
||||
valid_count += 1
|
||||
total_count += 1
|
||||
|
||||
return {
|
||||
"total_validations": total_count,
|
||||
"valid_count": valid_count,
|
||||
"invalid_count": total_count - valid_count,
|
||||
"total_errors": total_errors,
|
||||
"total_warnings": total_warnings,
|
||||
"success_rate": valid_count / total_count if total_count > 0 else 0
|
||||
}
|
@ -33,25 +33,35 @@ from ..i18n import get_i18n_manager
|
||||
|
||||
|
||||
class WebUIManager:
|
||||
"""Web UI 管理器"""
|
||||
|
||||
"""Web UI 管理器 - 重構為單一活躍會話模式"""
|
||||
|
||||
def __init__(self, host: str = "127.0.0.1", port: int = None):
|
||||
self.host = host
|
||||
# 優先使用固定端口 8765,確保 localStorage 的一致性
|
||||
self.port = port or find_free_port(preferred_port=8765)
|
||||
self.app = FastAPI(title="MCP Feedback Enhanced")
|
||||
self.sessions: Dict[str, WebFeedbackSession] = {}
|
||||
|
||||
# 重構:使用單一活躍會話而非會話字典
|
||||
self.current_session: Optional[WebFeedbackSession] = None
|
||||
self.sessions: Dict[str, WebFeedbackSession] = {} # 保留用於向後兼容
|
||||
|
||||
# 全局標籤頁狀態管理 - 跨會話保持
|
||||
self.global_active_tabs: Dict[str, dict] = {}
|
||||
|
||||
# 會話更新通知標記
|
||||
self._pending_session_update = False
|
||||
|
||||
self.server_thread = None
|
||||
self.server_process = None
|
||||
self.i18n = get_i18n_manager()
|
||||
|
||||
|
||||
# 設置靜態文件和模板
|
||||
self._setup_static_files()
|
||||
self._setup_templates()
|
||||
|
||||
|
||||
# 設置路由
|
||||
setup_routes(self)
|
||||
|
||||
|
||||
debug_log(f"WebUIManager 初始化完成,將在 {self.host}:{self.port} 啟動")
|
||||
|
||||
def _setup_static_files(self):
|
||||
@ -73,25 +83,129 @@ class WebUIManager:
|
||||
raise RuntimeError(f"Templates directory not found: {web_templates_path}")
|
||||
|
||||
def create_session(self, project_directory: str, summary: str) -> str:
|
||||
"""創建新的回饋會話"""
|
||||
"""創建新的回饋會話 - 重構為單一活躍會話模式,保留標籤頁狀態"""
|
||||
# 保存舊會話的 WebSocket 連接以便發送更新通知
|
||||
old_websocket = None
|
||||
if self.current_session and self.current_session.websocket:
|
||||
old_websocket = self.current_session.websocket
|
||||
debug_log("保存舊會話的 WebSocket 連接以發送更新通知")
|
||||
|
||||
# 如果已有活躍會話,先保存其標籤頁狀態到全局狀態
|
||||
if self.current_session:
|
||||
debug_log("保存現有會話的標籤頁狀態並清理會話")
|
||||
# 保存標籤頁狀態到全局
|
||||
if hasattr(self.current_session, 'active_tabs'):
|
||||
self._merge_tabs_to_global(self.current_session.active_tabs)
|
||||
|
||||
# 同步清理會話資源(但保留 WebSocket 連接)
|
||||
self.current_session._cleanup_sync()
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
session = WebFeedbackSession(session_id, project_directory, summary)
|
||||
|
||||
# 將全局標籤頁狀態繼承到新會話
|
||||
session.active_tabs = self.global_active_tabs.copy()
|
||||
|
||||
# 設置為當前活躍會話
|
||||
self.current_session = session
|
||||
# 同時保存到字典中以保持向後兼容
|
||||
self.sessions[session_id] = session
|
||||
debug_log(f"創建回饋會話: {session_id}")
|
||||
|
||||
debug_log(f"創建新的活躍會話: {session_id}")
|
||||
debug_log(f"繼承 {len(session.active_tabs)} 個活躍標籤頁")
|
||||
|
||||
# 如果有舊的 WebSocket 連接,立即發送會話更新通知
|
||||
if old_websocket:
|
||||
self._old_websocket_for_update = old_websocket
|
||||
self._new_session_for_update = session
|
||||
debug_log("已保存舊 WebSocket 連接,準備發送會話更新通知")
|
||||
else:
|
||||
# 標記需要發送會話更新通知(當新 WebSocket 連接建立時)
|
||||
self._pending_session_update = True
|
||||
|
||||
return session_id
|
||||
|
||||
def get_session(self, session_id: str) -> Optional[WebFeedbackSession]:
|
||||
"""獲取回饋會話"""
|
||||
"""獲取回饋會話 - 保持向後兼容"""
|
||||
return self.sessions.get(session_id)
|
||||
|
||||
def get_current_session(self) -> Optional[WebFeedbackSession]:
|
||||
"""獲取當前活躍會話"""
|
||||
return self.current_session
|
||||
|
||||
def remove_session(self, session_id: str):
|
||||
"""移除回饋會話"""
|
||||
if session_id in self.sessions:
|
||||
session = self.sessions[session_id]
|
||||
session.cleanup()
|
||||
del self.sessions[session_id]
|
||||
|
||||
# 如果移除的是當前活躍會話,清空當前會話
|
||||
if self.current_session and self.current_session.session_id == session_id:
|
||||
self.current_session = None
|
||||
debug_log("清空當前活躍會話")
|
||||
|
||||
debug_log(f"移除回饋會話: {session_id}")
|
||||
|
||||
def clear_current_session(self):
|
||||
"""清空當前活躍會話"""
|
||||
if self.current_session:
|
||||
session_id = self.current_session.session_id
|
||||
self.current_session.cleanup()
|
||||
self.current_session = None
|
||||
|
||||
# 同時從字典中移除
|
||||
if session_id in self.sessions:
|
||||
del self.sessions[session_id]
|
||||
|
||||
debug_log("已清空當前活躍會話")
|
||||
|
||||
def _merge_tabs_to_global(self, session_tabs: dict):
|
||||
"""將會話的標籤頁狀態合併到全局狀態"""
|
||||
current_time = time.time()
|
||||
expired_threshold = 60 # 60秒過期閾值
|
||||
|
||||
# 清理過期的全局標籤頁
|
||||
self.global_active_tabs = {
|
||||
tab_id: tab_info
|
||||
for tab_id, tab_info in self.global_active_tabs.items()
|
||||
if current_time - tab_info.get('last_seen', 0) <= expired_threshold
|
||||
}
|
||||
|
||||
# 合併會話標籤頁到全局
|
||||
for tab_id, tab_info in session_tabs.items():
|
||||
if current_time - tab_info.get('last_seen', 0) <= expired_threshold:
|
||||
self.global_active_tabs[tab_id] = tab_info
|
||||
|
||||
debug_log(f"合併標籤頁狀態,全局活躍標籤頁數量: {len(self.global_active_tabs)}")
|
||||
|
||||
def get_global_active_tabs_count(self) -> int:
|
||||
"""獲取全局活躍標籤頁數量"""
|
||||
current_time = time.time()
|
||||
expired_threshold = 60
|
||||
|
||||
# 清理過期標籤頁並返回數量
|
||||
valid_tabs = {
|
||||
tab_id: tab_info
|
||||
for tab_id, tab_info in self.global_active_tabs.items()
|
||||
if current_time - tab_info.get('last_seen', 0) <= expired_threshold
|
||||
}
|
||||
|
||||
self.global_active_tabs = valid_tabs
|
||||
return len(valid_tabs)
|
||||
|
||||
async def broadcast_to_active_tabs(self, message: dict):
|
||||
"""向所有活躍標籤頁廣播消息"""
|
||||
if not self.current_session or not self.current_session.websocket:
|
||||
debug_log("沒有活躍的 WebSocket 連接,無法廣播消息")
|
||||
return
|
||||
|
||||
try:
|
||||
await self.current_session.websocket.send_json(message)
|
||||
debug_log(f"已廣播消息到活躍標籤頁: {message.get('type', 'unknown')}")
|
||||
except Exception as e:
|
||||
debug_log(f"廣播消息失敗: {e}")
|
||||
|
||||
def start_server(self):
|
||||
"""啟動 Web 伺服器"""
|
||||
def run_server_with_retry():
|
||||
@ -146,6 +260,126 @@ class WebUIManager:
|
||||
except Exception as e:
|
||||
debug_log(f"無法開啟瀏覽器: {e}")
|
||||
|
||||
async def smart_open_browser(self, url: str) -> bool:
|
||||
"""智能開啟瀏覽器 - 檢測是否已有活躍標籤頁
|
||||
|
||||
Returns:
|
||||
bool: True 表示檢測到活躍標籤頁,False 表示開啟了新視窗
|
||||
"""
|
||||
import asyncio
|
||||
import aiohttp
|
||||
|
||||
try:
|
||||
# 檢查是否有活躍標籤頁
|
||||
has_active_tabs = await self._check_active_tabs()
|
||||
|
||||
if has_active_tabs:
|
||||
debug_log("檢測到活躍標籤頁,不開啟新瀏覽器視窗")
|
||||
debug_log(f"用戶可以在現有標籤頁中查看更新:{url}")
|
||||
return True
|
||||
|
||||
# 沒有活躍標籤頁,開啟新瀏覽器視窗
|
||||
debug_log("沒有檢測到活躍標籤頁,開啟新瀏覽器視窗")
|
||||
self.open_browser(url)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
debug_log(f"智能瀏覽器開啟失敗,回退到普通開啟:{e}")
|
||||
self.open_browser(url)
|
||||
return False
|
||||
|
||||
async def notify_session_update(self, session):
|
||||
"""向活躍標籤頁發送會話更新通知"""
|
||||
try:
|
||||
# 向所有活躍的 WebSocket 連接發送會話更新通知
|
||||
await self.broadcast_to_active_tabs({
|
||||
"type": "session_updated",
|
||||
"message": "新會話已創建,正在更新頁面內容",
|
||||
"session_info": {
|
||||
"project_directory": session.project_directory,
|
||||
"summary": session.summary,
|
||||
"session_id": session.session_id
|
||||
}
|
||||
})
|
||||
debug_log("會話更新通知已發送到所有活躍標籤頁")
|
||||
except Exception as e:
|
||||
debug_log(f"發送會話更新通知失敗: {e}")
|
||||
|
||||
async def _send_immediate_session_update(self):
|
||||
"""立即發送會話更新通知(使用舊的 WebSocket 連接)"""
|
||||
try:
|
||||
# 檢查是否有保存的舊 WebSocket 連接
|
||||
if hasattr(self, '_old_websocket_for_update') and hasattr(self, '_new_session_for_update'):
|
||||
old_websocket = self._old_websocket_for_update
|
||||
new_session = self._new_session_for_update
|
||||
|
||||
# 發送會話更新通知
|
||||
await old_websocket.send_json({
|
||||
"type": "session_updated",
|
||||
"message": "新會話已創建,正在更新頁面內容",
|
||||
"session_info": {
|
||||
"project_directory": new_session.project_directory,
|
||||
"summary": new_session.summary,
|
||||
"session_id": new_session.session_id
|
||||
}
|
||||
})
|
||||
debug_log("已通過舊 WebSocket 連接發送會話更新通知")
|
||||
|
||||
# 清理臨時變數
|
||||
delattr(self, '_old_websocket_for_update')
|
||||
delattr(self, '_new_session_for_update')
|
||||
|
||||
# 延遲一小段時間讓前端處理消息,然後關閉舊連接
|
||||
await asyncio.sleep(0.1)
|
||||
try:
|
||||
await old_websocket.close()
|
||||
debug_log("已關閉舊 WebSocket 連接")
|
||||
except Exception as e:
|
||||
debug_log(f"關閉舊 WebSocket 連接失敗: {e}")
|
||||
|
||||
else:
|
||||
# 沒有舊連接,設置待更新標記
|
||||
self._pending_session_update = True
|
||||
debug_log("沒有舊 WebSocket 連接,設置待更新標記")
|
||||
|
||||
except Exception as e:
|
||||
debug_log(f"立即發送會話更新通知失敗: {e}")
|
||||
# 回退到待更新標記
|
||||
self._pending_session_update = True
|
||||
|
||||
async def _check_active_tabs(self) -> bool:
|
||||
"""檢查是否有活躍標籤頁 - 優先檢查全局狀態,回退到 API"""
|
||||
try:
|
||||
# 首先檢查全局標籤頁狀態
|
||||
global_count = self.get_global_active_tabs_count()
|
||||
if global_count > 0:
|
||||
debug_log(f"檢測到 {global_count} 個全局活躍標籤頁")
|
||||
return True
|
||||
|
||||
# 如果全局狀態沒有活躍標籤頁,嘗試通過 API 檢查
|
||||
# 等待一小段時間讓服務器完全啟動
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# 調用活躍標籤頁 API
|
||||
import aiohttp
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(f"{self.get_server_url()}/api/active-tabs", timeout=2) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
tab_count = data.get("count", 0)
|
||||
debug_log(f"API 檢測到 {tab_count} 個活躍標籤頁")
|
||||
return tab_count > 0
|
||||
else:
|
||||
debug_log(f"檢查活躍標籤頁失敗,狀態碼:{response.status}")
|
||||
return False
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
debug_log("檢查活躍標籤頁超時")
|
||||
return False
|
||||
except Exception as e:
|
||||
debug_log(f"檢查活躍標籤頁時發生錯誤:{e}")
|
||||
return False
|
||||
|
||||
def get_server_url(self) -> str:
|
||||
"""獲取伺服器 URL"""
|
||||
return f"http://{self.host}:{self.port}"
|
||||
@ -176,52 +410,56 @@ def get_web_ui_manager() -> WebUIManager:
|
||||
|
||||
async def launch_web_feedback_ui(project_directory: str, summary: str, timeout: int = 600) -> dict:
|
||||
"""
|
||||
啟動 Web 回饋介面並等待用戶回饋
|
||||
|
||||
啟動 Web 回饋介面並等待用戶回饋 - 重構為使用根路徑
|
||||
|
||||
Args:
|
||||
project_directory: 專案目錄路徑
|
||||
summary: AI 工作摘要
|
||||
timeout: 超時時間(秒)
|
||||
|
||||
|
||||
Returns:
|
||||
dict: 回饋結果,包含 logs、interactive_feedback 和 images
|
||||
"""
|
||||
manager = get_web_ui_manager()
|
||||
|
||||
# 創建會話
|
||||
|
||||
# 創建或更新當前活躍會話
|
||||
session_id = manager.create_session(project_directory, summary)
|
||||
session = manager.get_session(session_id)
|
||||
|
||||
session = manager.get_current_session()
|
||||
|
||||
if not session:
|
||||
raise RuntimeError("無法創建回饋會話")
|
||||
|
||||
|
||||
# 啟動伺服器(如果尚未啟動)
|
||||
if not manager.server_thread or not manager.server_thread.is_alive():
|
||||
manager.start_server()
|
||||
|
||||
# 構建完整 URL 並開啟瀏覽器
|
||||
feedback_url = f"{manager.get_server_url()}/session/{session_id}"
|
||||
manager.open_browser(feedback_url)
|
||||
|
||||
|
||||
# 使用根路徑 URL 並智能開啟瀏覽器
|
||||
feedback_url = manager.get_server_url() # 直接使用根路徑
|
||||
has_active_tabs = await manager.smart_open_browser(feedback_url)
|
||||
|
||||
debug_log(f"[DEBUG] 服務器地址: {feedback_url}")
|
||||
|
||||
# 如果檢測到活躍標籤頁但沒有開啟新視窗,立即發送會話更新通知
|
||||
if has_active_tabs:
|
||||
await manager._send_immediate_session_update()
|
||||
debug_log("已向活躍標籤頁發送會話更新通知")
|
||||
|
||||
try:
|
||||
# 等待用戶回饋,傳遞 timeout 參數
|
||||
result = await session.wait_for_feedback(timeout)
|
||||
debug_log(f"收到用戶回饋,會話: {session_id}")
|
||||
debug_log(f"收到用戶回饋")
|
||||
return result
|
||||
except TimeoutError:
|
||||
debug_log(f"會話 {session_id} 超時")
|
||||
debug_log(f"會話超時")
|
||||
# 資源已在 wait_for_feedback 中清理,這裡只需要記錄和重新拋出
|
||||
raise
|
||||
except Exception as e:
|
||||
debug_log(f"會話 {session_id} 發生錯誤: {e}")
|
||||
debug_log(f"會話發生錯誤: {e}")
|
||||
raise
|
||||
finally:
|
||||
# 清理會話(無論成功還是失敗)
|
||||
manager.remove_session(session_id)
|
||||
# 如果沒有其他活躍會話,停止服務器
|
||||
if len(manager.sessions) == 0:
|
||||
debug_log("沒有活躍會話,停止 Web UI 服務器")
|
||||
stop_web_ui()
|
||||
# 注意:不再自動清理會話和停止服務器,保持持久性
|
||||
# 會話將保持活躍狀態,等待下次 MCP 調用
|
||||
debug_log("會話保持活躍狀態,等待下次 MCP 調用")
|
||||
|
||||
|
||||
def stop_web_ui():
|
||||
|
@ -11,6 +11,7 @@ import asyncio
|
||||
import base64
|
||||
import subprocess
|
||||
import threading
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
@ -18,6 +19,16 @@ from fastapi import WebSocket
|
||||
|
||||
from ...debug import web_debug_log as debug_log
|
||||
|
||||
|
||||
class SessionStatus(Enum):
|
||||
"""會話狀態枚舉"""
|
||||
WAITING = "waiting" # 等待中
|
||||
ACTIVE = "active" # 活躍中
|
||||
FEEDBACK_SUBMITTED = "feedback_submitted" # 已提交反饋
|
||||
COMPLETED = "completed" # 已完成
|
||||
TIMEOUT = "timeout" # 超時
|
||||
ERROR = "error" # 錯誤
|
||||
|
||||
# 常數定義
|
||||
MAX_IMAGE_SIZE = 1 * 1024 * 1024 # 1MB 圖片大小限制
|
||||
SUPPORTED_IMAGE_TYPES = {'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/bmp', 'image/webp'}
|
||||
@ -39,10 +50,42 @@ class WebFeedbackSession:
|
||||
self.process: Optional[subprocess.Popen] = None
|
||||
self.command_logs = []
|
||||
self._cleanup_done = False # 防止重複清理
|
||||
|
||||
|
||||
# 新增:會話狀態管理
|
||||
self.status = SessionStatus.WAITING
|
||||
self.status_message = "等待用戶回饋"
|
||||
self.created_at = asyncio.get_event_loop().time()
|
||||
self.last_activity = self.created_at
|
||||
|
||||
# 確保臨時目錄存在
|
||||
TEMP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def update_status(self, status: SessionStatus, message: str = None):
|
||||
"""更新會話狀態"""
|
||||
self.status = status
|
||||
if message:
|
||||
self.status_message = message
|
||||
self.last_activity = asyncio.get_event_loop().time()
|
||||
debug_log(f"會話 {self.session_id} 狀態更新: {status.value} - {self.status_message}")
|
||||
|
||||
def get_status_info(self) -> dict:
|
||||
"""獲取會話狀態信息"""
|
||||
return {
|
||||
"status": self.status.value,
|
||||
"message": self.status_message,
|
||||
"feedback_completed": self.feedback_completed.is_set(),
|
||||
"has_websocket": self.websocket is not None,
|
||||
"created_at": self.created_at,
|
||||
"last_activity": self.last_activity,
|
||||
"project_directory": self.project_directory,
|
||||
"summary": self.summary,
|
||||
"session_id": self.session_id
|
||||
}
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""檢查會話是否活躍"""
|
||||
return self.status in [SessionStatus.WAITING, SessionStatus.ACTIVE, SessionStatus.FEEDBACK_SUBMITTED]
|
||||
|
||||
async def wait_for_feedback(self, timeout: int = 600) -> dict:
|
||||
"""
|
||||
等待用戶回饋,包含圖片,支援超時自動清理
|
||||
@ -102,13 +145,24 @@ class WebFeedbackSession:
|
||||
# 先設置設定,再處理圖片(因為處理圖片時需要用到設定)
|
||||
self.settings = settings or {}
|
||||
self.images = self._process_images(images)
|
||||
|
||||
# 更新狀態為已提交反饋
|
||||
self.update_status(SessionStatus.FEEDBACK_SUBMITTED, "已送出反饋,等待下次 MCP 調用")
|
||||
|
||||
self.feedback_completed.set()
|
||||
|
||||
# 發送反饋已收到的消息給前端
|
||||
if self.websocket:
|
||||
try:
|
||||
await self.websocket.close()
|
||||
except:
|
||||
pass
|
||||
await self.websocket.send_json({
|
||||
"type": "feedback_received",
|
||||
"message": "反饋已成功提交",
|
||||
"status": self.status.value
|
||||
})
|
||||
except Exception as e:
|
||||
debug_log(f"發送反饋確認失敗: {e}")
|
||||
|
||||
# 重構:不再自動關閉 WebSocket,保持連接以支援頁面持久性
|
||||
|
||||
def _process_images(self, images: List[dict]) -> List[dict]:
|
||||
"""
|
||||
@ -304,14 +358,14 @@ class WebFeedbackSession:
|
||||
except Exception as e:
|
||||
debug_log(f"清理會話 {self.session_id} 資源時發生錯誤: {e}")
|
||||
|
||||
def cleanup(self):
|
||||
"""同步清理會話資源(保持向後兼容)"""
|
||||
def _cleanup_sync(self):
|
||||
"""同步清理會話資源(但保留 WebSocket 連接)"""
|
||||
if self._cleanup_done:
|
||||
return
|
||||
|
||||
self._cleanup_done = True
|
||||
debug_log(f"同步清理會話 {self.session_id} 資源...")
|
||||
|
||||
|
||||
debug_log(f"同步清理會話 {self.session_id} 資源(保留 WebSocket)...")
|
||||
|
||||
# 只清理進程,不清理 WebSocket 連接
|
||||
if self.process:
|
||||
try:
|
||||
self.process.terminate()
|
||||
@ -321,7 +375,30 @@ class WebFeedbackSession:
|
||||
self.process.kill()
|
||||
except:
|
||||
pass
|
||||
self.process = None
|
||||
|
||||
self.process = None
|
||||
|
||||
# 清理臨時數據
|
||||
self.command_logs.clear()
|
||||
# 注意:不設置 _cleanup_done = True,因為還需要清理 WebSocket
|
||||
|
||||
def cleanup(self):
|
||||
"""同步清理會話資源(保持向後兼容)"""
|
||||
if self._cleanup_done:
|
||||
return
|
||||
|
||||
self._cleanup_done = True
|
||||
debug_log(f"同步清理會話 {self.session_id} 資源...")
|
||||
|
||||
if self.process:
|
||||
try:
|
||||
self.process.terminate()
|
||||
self.process.wait(timeout=5)
|
||||
except:
|
||||
try:
|
||||
self.process.kill()
|
||||
except:
|
||||
pass
|
||||
self.process = None
|
||||
|
||||
# 設置完成事件
|
||||
self.feedback_completed.set()
|
||||
self.feedback_completed.set()
|
@ -9,6 +9,7 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@ -24,32 +25,30 @@ if TYPE_CHECKING:
|
||||
|
||||
def setup_routes(manager: 'WebUIManager'):
|
||||
"""設置路由"""
|
||||
|
||||
|
||||
@manager.app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
"""首頁"""
|
||||
return manager.templates.TemplateResponse("index.html", {
|
||||
"request": request,
|
||||
"title": "MCP Feedback Enhanced"
|
||||
})
|
||||
"""統一回饋頁面 - 重構後的主頁面"""
|
||||
# 獲取當前活躍會話
|
||||
current_session = manager.get_current_session()
|
||||
|
||||
@manager.app.get("/session/{session_id}", response_class=HTMLResponse)
|
||||
async def feedback_session(request: Request, session_id: str):
|
||||
"""回饋會話頁面"""
|
||||
session = manager.get_session(session_id)
|
||||
if not session:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"error": "會話不存在"}
|
||||
)
|
||||
|
||||
if not current_session:
|
||||
# 沒有活躍會話時顯示等待頁面
|
||||
return manager.templates.TemplateResponse("index.html", {
|
||||
"request": request,
|
||||
"title": "MCP Feedback Enhanced",
|
||||
"has_session": False,
|
||||
"version": __version__
|
||||
})
|
||||
|
||||
# 有活躍會話時顯示回饋頁面
|
||||
return manager.templates.TemplateResponse("feedback.html", {
|
||||
"request": request,
|
||||
"session_id": session_id,
|
||||
"project_directory": session.project_directory,
|
||||
"summary": session.summary,
|
||||
"project_directory": current_session.project_directory,
|
||||
"summary": current_session.summary,
|
||||
"title": "Interactive Feedback - 回饋收集",
|
||||
"version": __version__
|
||||
"version": __version__,
|
||||
"has_session": True
|
||||
})
|
||||
|
||||
@manager.app.get("/api/translations")
|
||||
@ -81,27 +80,93 @@ def setup_routes(manager: 'WebUIManager'):
|
||||
debug_log(f"Web 翻譯 API 返回 {len(translations)} 種語言的數據")
|
||||
return JSONResponse(content=translations)
|
||||
|
||||
@manager.app.websocket("/ws/{session_id}")
|
||||
async def websocket_endpoint(websocket: WebSocket, session_id: str):
|
||||
"""WebSocket 端點"""
|
||||
session = manager.get_session(session_id)
|
||||
@manager.app.get("/api/session-status")
|
||||
async def get_session_status():
|
||||
"""獲取當前會話狀態"""
|
||||
current_session = manager.get_current_session()
|
||||
|
||||
if not current_session:
|
||||
return JSONResponse(content={
|
||||
"has_session": False,
|
||||
"status": "no_session",
|
||||
"message": "沒有活躍會話"
|
||||
})
|
||||
|
||||
return JSONResponse(content={
|
||||
"has_session": True,
|
||||
"status": "active",
|
||||
"session_info": {
|
||||
"project_directory": current_session.project_directory,
|
||||
"summary": current_session.summary,
|
||||
"feedback_completed": current_session.feedback_completed.is_set()
|
||||
}
|
||||
})
|
||||
|
||||
@manager.app.get("/api/current-session")
|
||||
async def get_current_session():
|
||||
"""獲取當前會話詳細信息"""
|
||||
current_session = manager.get_current_session()
|
||||
|
||||
if not current_session:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"error": "沒有活躍會話"}
|
||||
)
|
||||
|
||||
return JSONResponse(content={
|
||||
"project_directory": current_session.project_directory,
|
||||
"summary": current_session.summary,
|
||||
"feedback_completed": current_session.feedback_completed.is_set(),
|
||||
"command_logs": current_session.command_logs,
|
||||
"images_count": len(current_session.images)
|
||||
})
|
||||
|
||||
@manager.app.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
"""WebSocket 端點 - 重構後移除 session_id 依賴"""
|
||||
# 獲取當前活躍會話
|
||||
session = manager.get_current_session()
|
||||
if not session:
|
||||
await websocket.close(code=4004, reason="會話不存在")
|
||||
await websocket.close(code=4004, reason="沒有活躍會話")
|
||||
return
|
||||
|
||||
|
||||
await websocket.accept()
|
||||
session.websocket = websocket
|
||||
|
||||
debug_log(f"WebSocket 連接建立: {session_id}")
|
||||
|
||||
|
||||
debug_log(f"WebSocket 連接建立: 當前活躍會話")
|
||||
|
||||
# 發送連接成功消息
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"type": "connection_established",
|
||||
"message": "WebSocket 連接已建立"
|
||||
})
|
||||
|
||||
# 檢查是否有待發送的會話更新
|
||||
if getattr(manager, '_pending_session_update', False):
|
||||
await websocket.send_json({
|
||||
"type": "session_updated",
|
||||
"message": "新會話已創建,正在更新頁面內容",
|
||||
"session_info": {
|
||||
"project_directory": session.project_directory,
|
||||
"summary": session.summary,
|
||||
"session_id": session.session_id
|
||||
}
|
||||
})
|
||||
manager._pending_session_update = False
|
||||
debug_log("已發送會話更新通知到前端")
|
||||
|
||||
except Exception as e:
|
||||
debug_log(f"發送連接確認失敗: {e}")
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
message = json.loads(data)
|
||||
await handle_websocket_message(manager, session, message)
|
||||
|
||||
|
||||
except WebSocketDisconnect:
|
||||
debug_log(f"WebSocket 連接斷開: {session_id}")
|
||||
debug_log(f"WebSocket 連接斷開")
|
||||
except Exception as e:
|
||||
debug_log(f"WebSocket 錯誤: {e}")
|
||||
finally:
|
||||
@ -181,35 +246,154 @@ def setup_routes(manager: 'WebUIManager'):
|
||||
content={"status": "error", "message": f"清除失敗: {str(e)}"}
|
||||
)
|
||||
|
||||
@manager.app.get("/api/active-tabs")
|
||||
async def get_active_tabs():
|
||||
"""獲取活躍標籤頁信息 - 優先使用全局狀態"""
|
||||
current_time = time.time()
|
||||
expired_threshold = 60
|
||||
|
||||
# 清理過期的全局標籤頁
|
||||
valid_global_tabs = {}
|
||||
for tab_id, tab_info in manager.global_active_tabs.items():
|
||||
if current_time - tab_info.get('last_seen', 0) <= expired_threshold:
|
||||
valid_global_tabs[tab_id] = tab_info
|
||||
|
||||
manager.global_active_tabs = valid_global_tabs
|
||||
|
||||
# 如果有當前會話,也更新會話的標籤頁狀態
|
||||
current_session = manager.get_current_session()
|
||||
if current_session:
|
||||
# 合併會話標籤頁到全局(如果有的話)
|
||||
session_tabs = getattr(current_session, 'active_tabs', {})
|
||||
for tab_id, tab_info in session_tabs.items():
|
||||
if current_time - tab_info.get('last_seen', 0) <= expired_threshold:
|
||||
valid_global_tabs[tab_id] = tab_info
|
||||
|
||||
# 更新會話的活躍標籤頁
|
||||
current_session.active_tabs = valid_global_tabs.copy()
|
||||
manager.global_active_tabs = valid_global_tabs
|
||||
|
||||
return JSONResponse(content={
|
||||
"has_session": current_session is not None,
|
||||
"active_tabs": valid_global_tabs,
|
||||
"count": len(valid_global_tabs)
|
||||
})
|
||||
|
||||
@manager.app.post("/api/register-tab")
|
||||
async def register_tab(request: Request):
|
||||
"""註冊新標籤頁"""
|
||||
try:
|
||||
data = await request.json()
|
||||
tab_id = data.get("tabId")
|
||||
|
||||
if not tab_id:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "缺少 tabId"}
|
||||
)
|
||||
|
||||
current_session = manager.get_current_session()
|
||||
if not current_session:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"error": "沒有活躍會話"}
|
||||
)
|
||||
|
||||
# 註冊標籤頁
|
||||
tab_info = {
|
||||
'timestamp': time.time() * 1000, # 毫秒時間戳
|
||||
'last_seen': time.time(),
|
||||
'registered_at': time.time()
|
||||
}
|
||||
|
||||
if not hasattr(current_session, 'active_tabs'):
|
||||
current_session.active_tabs = {}
|
||||
|
||||
current_session.active_tabs[tab_id] = tab_info
|
||||
|
||||
# 同時更新全局標籤頁狀態
|
||||
manager.global_active_tabs[tab_id] = tab_info
|
||||
|
||||
debug_log(f"標籤頁已註冊: {tab_id}")
|
||||
|
||||
return JSONResponse(content={
|
||||
"status": "success",
|
||||
"tabId": tab_id,
|
||||
"registered": True
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
debug_log(f"註冊標籤頁失敗: {e}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": f"註冊失敗: {str(e)}"}
|
||||
)
|
||||
|
||||
|
||||
async def handle_websocket_message(manager: 'WebUIManager', session, data: dict):
|
||||
"""處理 WebSocket 消息"""
|
||||
message_type = data.get("type")
|
||||
|
||||
|
||||
if message_type == "submit_feedback":
|
||||
# 提交回饋
|
||||
feedback = data.get("feedback", "")
|
||||
images = data.get("images", [])
|
||||
settings = data.get("settings", {})
|
||||
await session.submit_feedback(feedback, images, settings)
|
||||
|
||||
|
||||
elif message_type == "run_command":
|
||||
# 執行命令
|
||||
command = data.get("command", "")
|
||||
if command.strip():
|
||||
await session.run_command(command)
|
||||
|
||||
elif message_type == "get_status":
|
||||
# 獲取會話狀態
|
||||
if session.websocket:
|
||||
try:
|
||||
await session.websocket.send_json({
|
||||
"type": "status_update",
|
||||
"status_info": session.get_status_info()
|
||||
})
|
||||
except Exception as e:
|
||||
debug_log(f"發送狀態更新失敗: {e}")
|
||||
|
||||
elif message_type == "heartbeat":
|
||||
# WebSocket 心跳處理
|
||||
tab_id = data.get("tabId", "unknown")
|
||||
timestamp = data.get("timestamp", 0)
|
||||
|
||||
tab_info = {
|
||||
'timestamp': timestamp,
|
||||
'last_seen': time.time()
|
||||
}
|
||||
|
||||
# 更新會話的標籤頁信息
|
||||
if hasattr(session, 'active_tabs'):
|
||||
session.active_tabs[tab_id] = tab_info
|
||||
else:
|
||||
session.active_tabs = {tab_id: tab_info}
|
||||
|
||||
# 同時更新全局標籤頁狀態
|
||||
manager.global_active_tabs[tab_id] = tab_info
|
||||
|
||||
# 發送心跳回應
|
||||
if session.websocket:
|
||||
try:
|
||||
await session.websocket.send_json({
|
||||
"type": "heartbeat_response",
|
||||
"tabId": tab_id,
|
||||
"timestamp": timestamp
|
||||
})
|
||||
except Exception as e:
|
||||
debug_log(f"發送心跳回應失敗: {e}")
|
||||
|
||||
elif message_type == "user_timeout":
|
||||
# 用戶設置的超時已到
|
||||
debug_log(f"收到用戶超時通知: {session.session_id}")
|
||||
# 清理會話資源
|
||||
await session._cleanup_resources_on_timeout()
|
||||
# 如果沒有其他活躍會話,停止服務器
|
||||
if len(manager.sessions) <= 1: # 當前會話即將被移除
|
||||
debug_log("用戶超時,沒有其他活躍會話,準備停止服務器")
|
||||
# 延遲停止服務器,給前端時間關閉
|
||||
import asyncio
|
||||
asyncio.create_task(_delayed_server_stop(manager))
|
||||
# 重構:不再自動停止服務器,保持服務器運行以支援持久性
|
||||
|
||||
else:
|
||||
debug_log(f"未知的消息類型: {message_type}")
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -828,6 +828,33 @@
|
||||
background: rgba(0, 122, 204, 0.15);
|
||||
}
|
||||
|
||||
/* 佈局模式樣式 */
|
||||
|
||||
/* 預設分離模式 - 顯示回饋和AI摘要頁籤,隱藏合併模式頁籤 */
|
||||
body.layout-separate .tab-button[data-tab="combined"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.layout-separate .tab-button[data-tab="feedback"],
|
||||
body.layout-separate .tab-button[data-tab="summary"] {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* 合併模式 - 顯示合併模式頁籤,隱藏回饋和AI摘要頁籤 */
|
||||
body.layout-combined-vertical .tab-button[data-tab="combined"],
|
||||
body.layout-combined-horizontal .tab-button[data-tab="combined"] {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
body.layout-combined-vertical .tab-button[data-tab="feedback"],
|
||||
body.layout-combined-vertical .tab-button[data-tab="summary"],
|
||||
body.layout-combined-horizontal .tab-button[data-tab="feedback"],
|
||||
body.layout-combined-horizontal .tab-button[data-tab="summary"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 合併模式分頁的水平佈局樣式 */
|
||||
#tab-combined.active.combined-horizontal .combined-content {
|
||||
display: flex !important;
|
||||
@ -1025,6 +1052,74 @@
|
||||
.compatibility-hint-btn:hover {
|
||||
background: #1976d2;
|
||||
}
|
||||
|
||||
/* 回饋狀態指示器樣式 */
|
||||
.feedback-status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
margin: 16px 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid;
|
||||
background: var(--card-bg);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.feedback-status-indicator .status-icon {
|
||||
font-size: 24px;
|
||||
margin-right: 12px;
|
||||
min-width: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.feedback-status-indicator .status-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.feedback-status-indicator .status-text strong {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.feedback-status-indicator .status-text span {
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.feedback-status-indicator.status-waiting {
|
||||
border-color: var(--accent-color);
|
||||
background: rgba(74, 144, 226, 0.1);
|
||||
}
|
||||
|
||||
.feedback-status-indicator.status-processing {
|
||||
border-color: #ffa500;
|
||||
background: rgba(255, 165, 0, 0.1);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.feedback-status-indicator.status-submitted {
|
||||
border-color: var(--success-color);
|
||||
background: rgba(40, 167, 69, 0.1);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* 禁用狀態的樣式 */
|
||||
.image-upload-area.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.text-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -1073,6 +1168,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- 回饋分頁 -->
|
||||
<div id="tab-feedback" class="tab-content active">
|
||||
<div class="section-description" data-i18n="feedback.description">
|
||||
@ -1209,14 +1306,27 @@
|
||||
<!-- 回饋輸入區域 -->
|
||||
<div class="combined-section">
|
||||
<h3 class="combined-section-title" data-i18n="combined.feedbackTitle">💬 提供回饋</h3>
|
||||
|
||||
|
||||
<!-- 等待回饋狀態指示器 -->
|
||||
<div id="combinedFeedbackStatusIndicator" class="feedback-status-indicator status-waiting" style="display: none;">
|
||||
<div class="status-icon">⏳</div>
|
||||
<div class="status-text">
|
||||
<strong data-i18n="feedback.status.waiting.title">等待您的回饋</strong>
|
||||
<span data-i18n="feedback.status.waiting.message">請提供您對 AI 工作成果的意見和建議</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label class="input-label" data-i18n="feedback.textLabel">文字回饋</label>
|
||||
<textarea
|
||||
id="combinedFeedbackText"
|
||||
class="text-input"
|
||||
<textarea
|
||||
id="combinedFeedbackText"
|
||||
class="text-input"
|
||||
data-i18n-placeholder="feedback.detailedPlaceholder"
|
||||
placeholder="請在這裡輸入您的回饋..."
|
||||
placeholder="請在這裡輸入您的回饋...
|
||||
|
||||
💡 小提示:
|
||||
• 按 Ctrl+Enter/Cmd+Enter (支援數字鍵盤) 可快速提交
|
||||
• 按 Ctrl+V/Cmd+V 可直接貼上剪貼板圖片"
|
||||
style="min-height: 150px;"
|
||||
></textarea>
|
||||
</div>
|
||||
@ -1510,8 +1620,8 @@
|
||||
</div>
|
||||
|
||||
<!-- WebSocket 和 JavaScript -->
|
||||
<script src="/static/js/i18n.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script src="/static/js/i18n.js?v=2025010510"></script>
|
||||
<script src="/static/js/app.js?v=2025010510"></script>
|
||||
<script>
|
||||
// 等待 I18nManager 初始化完成後再初始化 FeedbackApp
|
||||
async function initializeApp() {
|
||||
|
@ -1,16 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<html lang="zh-TW" id="html-root">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ title }}</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
<style>
|
||||
:root {
|
||||
/* 深色主題顏色變數 */
|
||||
--bg-primary: #1e1e1e;
|
||||
--bg-secondary: #2d2d30;
|
||||
--bg-tertiary: #252526;
|
||||
--surface-color: #333333;
|
||||
--text-primary: #cccccc;
|
||||
--text-secondary: #9e9e9e;
|
||||
--accent-color: #007acc;
|
||||
--accent-hover: #005a9e;
|
||||
--border-color: #464647;
|
||||
--success-color: #4caf50;
|
||||
--warning-color: #ff9800;
|
||||
--error-color: #f44336;
|
||||
--info-color: #2196f3;
|
||||
}
|
||||
|
||||
* {
|
||||
@ -26,12 +36,19 @@
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.container {
|
||||
/* 等待會話時的樣式 */
|
||||
.waiting-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.waiting-content {
|
||||
max-width: 600px;
|
||||
padding: 40px;
|
||||
background: var(--bg-secondary);
|
||||
@ -39,37 +56,267 @@
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.title {
|
||||
.waiting-title {
|
||||
font-size: 2.5em;
|
||||
font-weight: bold;
|
||||
color: var(--accent-color);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.description {
|
||||
.waiting-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.2em;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.status {
|
||||
.waiting-status {
|
||||
padding: 20px;
|
||||
background: rgba(0, 122, 204, 0.1);
|
||||
border: 1px solid var(--accent-color);
|
||||
border-radius: 8px;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* 連接狀態指示器 */
|
||||
.connection-status {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.connection-indicator {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--error-color);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.connection-indicator.connected {
|
||||
background: var(--success-color);
|
||||
box-shadow: 0 0 8px rgba(76, 175, 80, 0.5);
|
||||
}
|
||||
|
||||
.connection-indicator.connecting {
|
||||
background: var(--warning-color);
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.connection-indicator.error {
|
||||
background: var(--error-color);
|
||||
}
|
||||
|
||||
.connection-indicator.disconnected {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* 主容器 - 有會話時顯示 */
|
||||
.main-container {
|
||||
display: none;
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-container.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* 回饋界面樣式 */
|
||||
.feedback-container {
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 15px 0;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: var(--accent-color);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.project-info {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ai-summary-section,
|
||||
.feedback-section,
|
||||
.command-section {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.ai-summary-section h2,
|
||||
.feedback-section h3,
|
||||
.command-section h3 {
|
||||
color: var(--accent-color);
|
||||
margin-bottom: 16px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.text-input,
|
||||
.command-input-line {
|
||||
width: 100%;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.text-input:focus,
|
||||
.command-input-line:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.command-input-line {
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.command-output {
|
||||
background: #0f0f0f;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
color: #00ff00;
|
||||
text-shadow: 0 0 5px #00ff00;
|
||||
white-space: pre-wrap;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--surface-color);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1 class="title">MCP Feedback Enhanced</h1>
|
||||
<p class="description">
|
||||
Web UI 互動式回饋收集工具
|
||||
</p>
|
||||
<div class="status">
|
||||
Web UI 服務已啟動。請等待會話建立或直接訪問具體的會話 URL。
|
||||
<!-- 連接狀態指示器 -->
|
||||
<div class="connection-status">
|
||||
<div class="connection-indicator" id="connectionIndicator"></div>
|
||||
<span id="connectionText">連接中...</span>
|
||||
</div>
|
||||
|
||||
<!-- 等待會話的頁面 -->
|
||||
<div class="waiting-container" id="waitingContainer">
|
||||
<div class="waiting-content">
|
||||
<h1 class="waiting-title">MCP Feedback Enhanced</h1>
|
||||
<p class="waiting-description">
|
||||
Web UI 互動式回饋收集工具
|
||||
</p>
|
||||
<div class="waiting-status">
|
||||
等待 MCP 服務調用以建立回饋會話...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主要回饋界面 -->
|
||||
<div class="main-container" id="mainContainer">
|
||||
<!-- 這裡將動態載入回饋界面內容 -->
|
||||
</div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="/static/js/i18n.js?v=2025010505"></script>
|
||||
<script src="/static/js/app.js?v=2025010505"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
Loading…
x
Reference in New Issue
Block a user