dash-devtools 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dash_devtools/__init__.py +8 -0
- dash_devtools/__main__.py +11 -0
- dash_devtools/ai_engine.py +441 -0
- dash_devtools/browser.py +541 -0
- dash_devtools/cli.py +1452 -0
- dash_devtools/database.py +338 -0
- dash_devtools/dbdiagram.py +183 -0
- dash_devtools/e2e.py +329 -0
- dash_devtools/fixers/__init__.py +57 -0
- dash_devtools/fixers/migration_fixer.py +115 -0
- dash_devtools/fixers/ux_fixer.py +106 -0
- dash_devtools/fixers/version_bumper.py +115 -0
- dash_devtools/gas_mes_test.py +1241 -0
- dash_devtools/generators/__init__.py +84 -0
- dash_devtools/health.py +476 -0
- dash_devtools/hooks/__init__.py +250 -0
- dash_devtools/hooks/pre_commit.py +161 -0
- dash_devtools/hooks/pre_push.py +275 -0
- dash_devtools/init_test.py +352 -0
- dash_devtools/markdown_report.py +309 -0
- dash_devtools/migrators/__init__.py +21 -0
- dash_devtools/perf.py +321 -0
- dash_devtools/report.py +667 -0
- dash_devtools/reporters/__init__.py +11 -0
- dash_devtools/spec.py +230 -0
- dash_devtools/stats.py +355 -0
- dash_devtools/test_suite.py +690 -0
- dash_devtools/testing.py +416 -0
- dash_devtools/validators/__init__.py +157 -0
- dash_devtools/validators/backend/__init__.py +12 -0
- dash_devtools/validators/backend/nodejs.py +245 -0
- dash_devtools/validators/backend/python.py +439 -0
- dash_devtools/validators/code_quality.py +243 -0
- dash_devtools/validators/common/__init__.py +11 -0
- dash_devtools/validators/common/quality.py +319 -0
- dash_devtools/validators/common/security.py +270 -0
- dash_devtools/validators/common/spec.py +273 -0
- dash_devtools/validators/detector.py +394 -0
- dash_devtools/validators/frontend/__init__.py +14 -0
- dash_devtools/validators/frontend/angular.py +245 -0
- dash_devtools/validators/frontend/gas.py +310 -0
- dash_devtools/validators/frontend/vite.py +539 -0
- dash_devtools/validators/migration.py +292 -0
- dash_devtools/validators/performance.py +167 -0
- dash_devtools/validators/security.py +205 -0
- dash_devtools/vision/__init__.py +368 -0
- dash_devtools/watch.py +266 -0
- dash_devtools/word_report.py +690 -0
- dash_devtools-1.0.0.dist-info/METADATA +834 -0
- dash_devtools-1.0.0.dist-info/RECORD +53 -0
- dash_devtools-1.0.0.dist-info/WHEEL +5 -0
- dash_devtools-1.0.0.dist-info/entry_points.txt +2 -0
- dash_devtools-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
"""
|
|
2
|
+
四大類型測試套件整合模組
|
|
3
|
+
|
|
4
|
+
支援測試類型:
|
|
5
|
+
- UIT (Unit Integration Testing): 單元測試 + 覆蓋率
|
|
6
|
+
- Smoke: 煙霧測試 (關鍵路徑快速驗證)
|
|
7
|
+
- E2E (End-to-End): 端對端測試
|
|
8
|
+
- UAT (User Acceptance Testing): 使用者驗收測試
|
|
9
|
+
|
|
10
|
+
支援測試框架:
|
|
11
|
+
- Vitest (推薦)
|
|
12
|
+
- Jest
|
|
13
|
+
- Playwright (E2E/Smoke/UAT)
|
|
14
|
+
- Pytest
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import subprocess
|
|
19
|
+
import re
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Dict, List, Optional, Tuple
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
from rich.console import Console
|
|
25
|
+
from rich.panel import Panel
|
|
26
|
+
from rich.table import Table
|
|
27
|
+
from rich.text import Text
|
|
28
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
29
|
+
|
|
30
|
+
console = Console()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class TestCase:
|
|
35
|
+
"""單一測試案例"""
|
|
36
|
+
name: str
|
|
37
|
+
status: str # passed, failed, skipped
|
|
38
|
+
duration: float = 0.0
|
|
39
|
+
error: str = ""
|
|
40
|
+
screenshot: str = "" # 截圖路徑
|
|
41
|
+
api_response: str = "" # API 測試回應 (JSON)
|
|
42
|
+
terminal_output: str = "" # 終端輸出 (UIT)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class TestTypeResult:
|
|
47
|
+
"""單一測試類型結果"""
|
|
48
|
+
test_type: str # UIT, Smoke, E2E, UAT
|
|
49
|
+
passed: int = 0
|
|
50
|
+
failed: int = 0
|
|
51
|
+
skipped: int = 0
|
|
52
|
+
duration: float = 0.0
|
|
53
|
+
coverage: float = 0.0
|
|
54
|
+
success: bool = True
|
|
55
|
+
error: str = ""
|
|
56
|
+
details: List[str] = field(default_factory=list)
|
|
57
|
+
test_cases: List[TestCase] = field(default_factory=list) # 測試案例列表
|
|
58
|
+
not_configured: bool = False # 該測試類型未設定 (優雅跳過)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class TestSuiteResult:
|
|
63
|
+
"""完整測試套件結果"""
|
|
64
|
+
project_name: str
|
|
65
|
+
results: Dict[str, TestTypeResult] = field(default_factory=dict)
|
|
66
|
+
total_passed: int = 0
|
|
67
|
+
total_failed: int = 0
|
|
68
|
+
total_duration: float = 0.0
|
|
69
|
+
overall_success: bool = True
|
|
70
|
+
coverage: float = 0.0
|
|
71
|
+
timestamp: str = ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TestSuiteRunner:
|
|
75
|
+
"""四大類型測試套件執行器"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, project_path: str):
|
|
78
|
+
self.project_path = Path(project_path)
|
|
79
|
+
self.project_name = self.project_path.name
|
|
80
|
+
|
|
81
|
+
def detect_test_setup(self) -> Dict:
|
|
82
|
+
"""偵測專案測試設定"""
|
|
83
|
+
setup = {
|
|
84
|
+
'has_vitest': False,
|
|
85
|
+
'has_jest': False,
|
|
86
|
+
'has_playwright': False,
|
|
87
|
+
'has_pytest': False,
|
|
88
|
+
'package_scripts': {},
|
|
89
|
+
'test_dirs': []
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# 檢查 package.json
|
|
93
|
+
package_json = self.project_path / 'package.json'
|
|
94
|
+
if package_json.exists():
|
|
95
|
+
try:
|
|
96
|
+
pkg = json.loads(package_json.read_text())
|
|
97
|
+
deps = {**pkg.get('dependencies', {}), **pkg.get('devDependencies', {})}
|
|
98
|
+
setup['package_scripts'] = pkg.get('scripts', {})
|
|
99
|
+
|
|
100
|
+
setup['has_vitest'] = 'vitest' in deps
|
|
101
|
+
setup['has_jest'] = 'jest' in deps
|
|
102
|
+
setup['has_playwright'] = '@playwright/test' in deps
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
# 檢查測試目錄
|
|
107
|
+
for test_dir in ['e2e', 'tests', 'test', '__tests__', 'spec']:
|
|
108
|
+
if (self.project_path / test_dir).exists():
|
|
109
|
+
setup['test_dirs'].append(test_dir)
|
|
110
|
+
|
|
111
|
+
# 檢查 Playwright 設定
|
|
112
|
+
if (self.project_path / 'playwright.config.ts').exists():
|
|
113
|
+
setup['has_playwright'] = True
|
|
114
|
+
|
|
115
|
+
# 檢查 Python
|
|
116
|
+
if (self.project_path / 'pytest.ini').exists() or \
|
|
117
|
+
(self.project_path / 'pyproject.toml').exists():
|
|
118
|
+
setup['has_pytest'] = True
|
|
119
|
+
|
|
120
|
+
return setup
|
|
121
|
+
|
|
122
|
+
def run_uit(self, with_coverage: bool = True) -> TestTypeResult:
|
|
123
|
+
"""執行 UIT 單元測試"""
|
|
124
|
+
result = TestTypeResult(test_type='UIT')
|
|
125
|
+
setup = self.detect_test_setup()
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
if setup['has_vitest']:
|
|
129
|
+
# 使用 JSON reporter 取得詳細結果
|
|
130
|
+
cmd = ['npx', 'vitest', 'run', '--reporter=json']
|
|
131
|
+
if with_coverage:
|
|
132
|
+
cmd.append('--coverage')
|
|
133
|
+
|
|
134
|
+
proc = subprocess.run(
|
|
135
|
+
cmd,
|
|
136
|
+
cwd=self.project_path,
|
|
137
|
+
capture_output=True,
|
|
138
|
+
text=True,
|
|
139
|
+
timeout=300
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
result.success = proc.returncode == 0
|
|
143
|
+
output = proc.stdout + proc.stderr
|
|
144
|
+
|
|
145
|
+
# 移除 ANSI 顏色碼
|
|
146
|
+
ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
|
|
147
|
+
clean_output = ansi_escape.sub('', output)
|
|
148
|
+
|
|
149
|
+
# 嘗試解析 JSON 並建立摘要
|
|
150
|
+
terminal_summary = ""
|
|
151
|
+
try:
|
|
152
|
+
# 找到 JSON 部分 (Vitest JSON 輸出)
|
|
153
|
+
json_match = re.search(r'(\{[\s\S]*"testResults"[\s\S]*\})', proc.stdout)
|
|
154
|
+
if json_match:
|
|
155
|
+
json_data = json.loads(json_match.group(1))
|
|
156
|
+
|
|
157
|
+
# 從 JSON 提取統計數據
|
|
158
|
+
result.passed = json_data.get('numPassedTests', 0)
|
|
159
|
+
result.failed = json_data.get('numFailedTests', 0)
|
|
160
|
+
num_total = json_data.get('numTotalTests', 0)
|
|
161
|
+
num_suites = json_data.get('numTotalTestSuites', 0)
|
|
162
|
+
|
|
163
|
+
# 解析覆蓋率 (從 stderr)
|
|
164
|
+
coverage_match = re.search(r'All files\s+\|\s+([\d.]+)', clean_output)
|
|
165
|
+
if coverage_match:
|
|
166
|
+
result.coverage = float(coverage_match.group(1))
|
|
167
|
+
|
|
168
|
+
# 從 JSON 建立人類可讀的摘要
|
|
169
|
+
summary_parts = [
|
|
170
|
+
f"Test Suites: {num_suites}",
|
|
171
|
+
f"Tests: {result.passed} passed" + (f", {result.failed} failed" if result.failed else ""),
|
|
172
|
+
f"Total: {num_total} tests"
|
|
173
|
+
]
|
|
174
|
+
if result.coverage > 0:
|
|
175
|
+
summary_parts.append(f"Coverage: {result.coverage:.1f}%")
|
|
176
|
+
|
|
177
|
+
terminal_summary = '\n'.join(summary_parts)
|
|
178
|
+
|
|
179
|
+
for test_file in json_data.get('testResults', []):
|
|
180
|
+
file_name = Path(test_file.get('name', '')).name
|
|
181
|
+
for assertion in test_file.get('assertionResults', []):
|
|
182
|
+
test_name = ' › '.join(assertion.get('ancestorTitles', []) + [assertion.get('title', '')])
|
|
183
|
+
status = assertion.get('status', 'passed')
|
|
184
|
+
# Vitest duration 是毫秒,轉為秒 (與 Playwright 統一)
|
|
185
|
+
duration = assertion.get('duration', 0) / 1000 # ms -> s
|
|
186
|
+
result.test_cases.append(TestCase(
|
|
187
|
+
name=f"{file_name} › {test_name}",
|
|
188
|
+
status=status,
|
|
189
|
+
duration=duration
|
|
190
|
+
# UIT 不顯示 terminal_output (統計已在報告摘要中)
|
|
191
|
+
))
|
|
192
|
+
except (json.JSONDecodeError, KeyError):
|
|
193
|
+
# 備援:從輸出解析測試名稱
|
|
194
|
+
# 格式: ✓ src/app/core/services/warehouse.service.spec.ts (25 tests) 2ms
|
|
195
|
+
for match in re.finditer(r'[✓✗]\s+(\S+\.spec\.ts)\s+\((\d+)\s+tests?\)', clean_output):
|
|
196
|
+
file_name = Path(match.group(1)).name
|
|
197
|
+
test_count = int(match.group(2))
|
|
198
|
+
# 無法取得個別測試名稱,用檔案名代替
|
|
199
|
+
result.test_cases.append(TestCase(
|
|
200
|
+
name=f"{file_name} ({test_count} tests)",
|
|
201
|
+
status='passed' if '✓' in match.group(0) else 'failed'
|
|
202
|
+
))
|
|
203
|
+
|
|
204
|
+
# 解析統計
|
|
205
|
+
match = re.search(r'Tests\s+(\d+)\s+passed', clean_output)
|
|
206
|
+
if match:
|
|
207
|
+
result.passed = int(match.group(1))
|
|
208
|
+
else:
|
|
209
|
+
match = re.search(r'(\d+)\s+passed', clean_output)
|
|
210
|
+
if match:
|
|
211
|
+
result.passed = int(match.group(1))
|
|
212
|
+
|
|
213
|
+
match = re.search(r'(\d+)\s+failed', clean_output)
|
|
214
|
+
if match:
|
|
215
|
+
result.failed = int(match.group(1))
|
|
216
|
+
|
|
217
|
+
# 解析覆蓋率
|
|
218
|
+
match = re.search(r'All files\s+\|\s+([\d.]+)', clean_output)
|
|
219
|
+
if match:
|
|
220
|
+
result.coverage = float(match.group(1))
|
|
221
|
+
|
|
222
|
+
# 解析時間
|
|
223
|
+
match = re.search(r'Duration\s+([\d.]+)ms', clean_output)
|
|
224
|
+
if match:
|
|
225
|
+
result.duration = float(match.group(1)) / 1000
|
|
226
|
+
else:
|
|
227
|
+
match = re.search(r'Duration\s+([\d.]+)s', clean_output)
|
|
228
|
+
if match:
|
|
229
|
+
result.duration = float(match.group(1))
|
|
230
|
+
|
|
231
|
+
elif setup['has_jest']:
|
|
232
|
+
cmd = ['npx', 'jest', '--coverage'] if with_coverage else ['npx', 'jest']
|
|
233
|
+
proc = subprocess.run(
|
|
234
|
+
cmd,
|
|
235
|
+
cwd=self.project_path,
|
|
236
|
+
capture_output=True,
|
|
237
|
+
text=True,
|
|
238
|
+
timeout=300
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
result.success = proc.returncode == 0
|
|
242
|
+
output = proc.stdout + proc.stderr
|
|
243
|
+
|
|
244
|
+
match = re.search(r'Tests:\s+(\d+) passed', output)
|
|
245
|
+
if match:
|
|
246
|
+
result.passed = int(match.group(1))
|
|
247
|
+
|
|
248
|
+
match = re.search(r'(\d+) failed', output)
|
|
249
|
+
if match:
|
|
250
|
+
result.failed = int(match.group(1))
|
|
251
|
+
|
|
252
|
+
elif setup['has_pytest']:
|
|
253
|
+
cmd = ['python', '-m', 'pytest', '--cov', '--cov-report=term'] if with_coverage \
|
|
254
|
+
else ['python', '-m', 'pytest', '-v']
|
|
255
|
+
proc = subprocess.run(
|
|
256
|
+
cmd,
|
|
257
|
+
cwd=self.project_path,
|
|
258
|
+
capture_output=True,
|
|
259
|
+
text=True,
|
|
260
|
+
timeout=300
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
result.success = proc.returncode == 0
|
|
264
|
+
output = proc.stdout + proc.stderr
|
|
265
|
+
|
|
266
|
+
match = re.search(r'(\d+) passed', output)
|
|
267
|
+
if match:
|
|
268
|
+
result.passed = int(match.group(1))
|
|
269
|
+
|
|
270
|
+
match = re.search(r'(\d+) failed', output)
|
|
271
|
+
if match:
|
|
272
|
+
result.failed = int(match.group(1))
|
|
273
|
+
|
|
274
|
+
match = re.search(r'TOTAL\s+\d+\s+\d+\s+(\d+)%', output)
|
|
275
|
+
if match:
|
|
276
|
+
result.coverage = float(match.group(1))
|
|
277
|
+
|
|
278
|
+
else:
|
|
279
|
+
# 無測試框架,優雅跳過 (不視為失敗)
|
|
280
|
+
result.success = True
|
|
281
|
+
result.not_configured = True
|
|
282
|
+
result.error = "未設定單元測試框架"
|
|
283
|
+
|
|
284
|
+
except subprocess.TimeoutExpired:
|
|
285
|
+
result.success = False
|
|
286
|
+
result.error = "測試超時 (5分鐘)"
|
|
287
|
+
except Exception as e:
|
|
288
|
+
result.success = False
|
|
289
|
+
result.error = str(e)
|
|
290
|
+
|
|
291
|
+
return result
|
|
292
|
+
|
|
293
|
+
def run_playwright_tests(self, spec_pattern: str, test_type: str, capture_screenshots: bool = True) -> TestTypeResult:
|
|
294
|
+
"""執行 Playwright 測試"""
|
|
295
|
+
result = TestTypeResult(test_type=test_type)
|
|
296
|
+
setup = self.detect_test_setup()
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
# 檢查是否有 Playwright
|
|
300
|
+
if not setup['has_playwright']:
|
|
301
|
+
result.success = True
|
|
302
|
+
result.not_configured = True
|
|
303
|
+
result.error = "未安裝 Playwright"
|
|
304
|
+
return result
|
|
305
|
+
|
|
306
|
+
# 檢查是否有對應的測試檔案
|
|
307
|
+
spec_files = list(self.project_path.glob(f'e2e/{spec_pattern}'))
|
|
308
|
+
if not spec_files:
|
|
309
|
+
result.success = True
|
|
310
|
+
result.not_configured = True
|
|
311
|
+
result.error = f"未找到 {spec_pattern}"
|
|
312
|
+
return result
|
|
313
|
+
|
|
314
|
+
# 為每個測試類型建立獨立的輸出目錄
|
|
315
|
+
output_dir = self.project_path / 'test-results' / test_type.lower()
|
|
316
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
317
|
+
|
|
318
|
+
# 使用 JSON reporter 取得詳細結果
|
|
319
|
+
cmd = [
|
|
320
|
+
'npx', 'playwright', 'test', f'e2e/{spec_pattern}',
|
|
321
|
+
'--reporter=json',
|
|
322
|
+
f'--output={output_dir}'
|
|
323
|
+
]
|
|
324
|
+
|
|
325
|
+
# 設定環境變數啟用截圖
|
|
326
|
+
env = dict(subprocess.os.environ)
|
|
327
|
+
if capture_screenshots:
|
|
328
|
+
env['SCREENSHOT_ALL'] = '1'
|
|
329
|
+
|
|
330
|
+
proc = subprocess.run(
|
|
331
|
+
cmd,
|
|
332
|
+
cwd=self.project_path,
|
|
333
|
+
capture_output=True,
|
|
334
|
+
text=True,
|
|
335
|
+
timeout=300,
|
|
336
|
+
env=env
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
output = proc.stdout + proc.stderr
|
|
340
|
+
result.success = proc.returncode == 0
|
|
341
|
+
|
|
342
|
+
# 嘗試解析 JSON 輸出
|
|
343
|
+
try:
|
|
344
|
+
# Playwright JSON 輸出在 stdout
|
|
345
|
+
json_data = json.loads(proc.stdout)
|
|
346
|
+
|
|
347
|
+
# 解析測試案例
|
|
348
|
+
for suite in json_data.get('suites', []):
|
|
349
|
+
self._parse_playwright_suite(suite, result)
|
|
350
|
+
|
|
351
|
+
# 計算統計
|
|
352
|
+
result.passed = sum(1 for tc in result.test_cases if tc.status == 'passed')
|
|
353
|
+
result.failed = sum(1 for tc in result.test_cases if tc.status == 'failed')
|
|
354
|
+
result.skipped = sum(1 for tc in result.test_cases if tc.status == 'skipped')
|
|
355
|
+
|
|
356
|
+
except json.JSONDecodeError:
|
|
357
|
+
# 備援:用正則解析
|
|
358
|
+
# 移除 ANSI 碼
|
|
359
|
+
ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
|
|
360
|
+
clean_output = ansi_escape.sub('', output)
|
|
361
|
+
|
|
362
|
+
match = re.search(r'(\d+) passed', clean_output)
|
|
363
|
+
if match:
|
|
364
|
+
result.passed = int(match.group(1))
|
|
365
|
+
|
|
366
|
+
match = re.search(r'(\d+) failed', clean_output)
|
|
367
|
+
if match:
|
|
368
|
+
result.failed = int(match.group(1))
|
|
369
|
+
|
|
370
|
+
# 解析測試名稱 (從輸出中提取)
|
|
371
|
+
# 格式: [chromium] › e2e/smoke.spec.ts:11:7 › Smoke Tests › SMOKE-01: 應用程式啟動
|
|
372
|
+
for match in re.finditer(r'› ([^›]+\.spec\.ts:\d+:\d+) › (.+)', clean_output):
|
|
373
|
+
test_name = match.group(2).strip()
|
|
374
|
+
# 判斷狀態
|
|
375
|
+
status = 'passed'
|
|
376
|
+
if '✓' in clean_output or 'passed' in clean_output:
|
|
377
|
+
status = 'passed'
|
|
378
|
+
result.test_cases.append(TestCase(name=test_name, status=status))
|
|
379
|
+
|
|
380
|
+
match = re.search(r'\(([\d.]+)s\)', output)
|
|
381
|
+
if match:
|
|
382
|
+
result.duration = float(match.group(1))
|
|
383
|
+
|
|
384
|
+
except subprocess.TimeoutExpired:
|
|
385
|
+
result.success = False
|
|
386
|
+
result.error = "測試超時"
|
|
387
|
+
except Exception as e:
|
|
388
|
+
result.success = False
|
|
389
|
+
result.error = str(e)
|
|
390
|
+
|
|
391
|
+
return result
|
|
392
|
+
|
|
393
|
+
def _parse_playwright_suite(self, suite: Dict, result: TestTypeResult, prefix: str = ""):
|
|
394
|
+
"""遞迴解析 Playwright 測試套件"""
|
|
395
|
+
suite_title = suite.get('title', '')
|
|
396
|
+
current_prefix = f"{prefix} › {suite_title}" if prefix else suite_title
|
|
397
|
+
|
|
398
|
+
# 解析 specs (測試案例)
|
|
399
|
+
for spec in suite.get('specs', []):
|
|
400
|
+
test_title = spec.get('title', '')
|
|
401
|
+
full_name = f"{current_prefix} › {test_title}" if current_prefix else test_title
|
|
402
|
+
|
|
403
|
+
# 取得測試結果
|
|
404
|
+
tests = spec.get('tests', [])
|
|
405
|
+
for test in tests:
|
|
406
|
+
results_list = test.get('results', [])
|
|
407
|
+
status = 'passed'
|
|
408
|
+
duration = 0.0
|
|
409
|
+
error = ''
|
|
410
|
+
screenshot = ''
|
|
411
|
+
api_response = ''
|
|
412
|
+
|
|
413
|
+
for res in results_list:
|
|
414
|
+
status = res.get('status', 'passed')
|
|
415
|
+
duration = res.get('duration', 0) / 1000 # 毫秒轉秒
|
|
416
|
+
if res.get('error'):
|
|
417
|
+
error = res['error'].get('message', '')[:200]
|
|
418
|
+
|
|
419
|
+
# 取得附件 (截圖或 API 回應)
|
|
420
|
+
attachments = res.get('attachments', [])
|
|
421
|
+
for att in attachments:
|
|
422
|
+
att_name = att.get('name', '')
|
|
423
|
+
if att_name == 'screenshot' and att.get('path'):
|
|
424
|
+
screenshot = att.get('path', '')
|
|
425
|
+
elif att_name == 'api-response' and att.get('body'):
|
|
426
|
+
# API 回應是 base64 編碼的 body
|
|
427
|
+
import base64
|
|
428
|
+
try:
|
|
429
|
+
body = att.get('body', '')
|
|
430
|
+
if body:
|
|
431
|
+
api_response = base64.b64decode(body).decode('utf-8')
|
|
432
|
+
except Exception:
|
|
433
|
+
api_response = att.get('body', '')
|
|
434
|
+
|
|
435
|
+
result.test_cases.append(TestCase(
|
|
436
|
+
name=full_name,
|
|
437
|
+
status=status,
|
|
438
|
+
duration=duration,
|
|
439
|
+
error=error,
|
|
440
|
+
screenshot=screenshot,
|
|
441
|
+
api_response=api_response
|
|
442
|
+
))
|
|
443
|
+
|
|
444
|
+
# 遞迴處理子套件
|
|
445
|
+
for sub_suite in suite.get('suites', []):
|
|
446
|
+
self._parse_playwright_suite(sub_suite, result, current_prefix)
|
|
447
|
+
|
|
448
|
+
def run_smoke(self) -> TestTypeResult:
|
|
449
|
+
"""執行 Smoke 煙霧測試"""
|
|
450
|
+
return self.run_playwright_tests('smoke.spec.ts', 'Smoke')
|
|
451
|
+
|
|
452
|
+
def run_e2e(self) -> TestTypeResult:
|
|
453
|
+
"""執行 E2E 端對端測試"""
|
|
454
|
+
# 嘗試多種命名模式
|
|
455
|
+
patterns = ['mes-system.spec.ts', '*.e2e.spec.ts', 'e2e.spec.ts', '!smoke.spec.ts&!uat.spec.ts']
|
|
456
|
+
result = self.run_playwright_tests('mes-system.spec.ts', 'E2E')
|
|
457
|
+
if result.passed == 0 and not result.error:
|
|
458
|
+
result = self.run_playwright_tests('*.e2e.spec.ts', 'E2E')
|
|
459
|
+
return result
|
|
460
|
+
|
|
461
|
+
def run_uat(self) -> TestTypeResult:
|
|
462
|
+
"""執行 UAT 使用者驗收測試"""
|
|
463
|
+
return self.run_playwright_tests('uat.spec.ts', 'UAT')
|
|
464
|
+
|
|
465
|
+
def run_all(self, test_types: List[str] = None) -> TestSuiteResult:
|
|
466
|
+
"""執行所有測試類型"""
|
|
467
|
+
if test_types is None:
|
|
468
|
+
test_types = ['UIT', 'Smoke', 'E2E', 'UAT']
|
|
469
|
+
|
|
470
|
+
suite_result = TestSuiteResult(
|
|
471
|
+
project_name=self.project_name,
|
|
472
|
+
timestamp=datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
for test_type in test_types:
|
|
476
|
+
if test_type.upper() == 'UIT':
|
|
477
|
+
result = self.run_uit(with_coverage=True)
|
|
478
|
+
elif test_type.upper() == 'SMOKE':
|
|
479
|
+
result = self.run_smoke()
|
|
480
|
+
elif test_type.upper() == 'E2E':
|
|
481
|
+
result = self.run_e2e()
|
|
482
|
+
elif test_type.upper() == 'UAT':
|
|
483
|
+
result = self.run_uat()
|
|
484
|
+
else:
|
|
485
|
+
continue
|
|
486
|
+
|
|
487
|
+
suite_result.results[test_type.upper()] = result
|
|
488
|
+
suite_result.total_passed += result.passed
|
|
489
|
+
suite_result.total_failed += result.failed
|
|
490
|
+
suite_result.total_duration += result.duration
|
|
491
|
+
|
|
492
|
+
if not result.success:
|
|
493
|
+
suite_result.overall_success = False
|
|
494
|
+
|
|
495
|
+
# 取得 UIT 覆蓋率
|
|
496
|
+
if 'UIT' in suite_result.results:
|
|
497
|
+
suite_result.coverage = suite_result.results['UIT'].coverage
|
|
498
|
+
|
|
499
|
+
return suite_result
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def render_test_suite_result(suite: TestSuiteResult):
|
|
503
|
+
"""渲染測試套件結果"""
|
|
504
|
+
|
|
505
|
+
# 標題
|
|
506
|
+
status_color = "green" if suite.overall_success else "red"
|
|
507
|
+
status_icon = "v" if suite.overall_success else "x"
|
|
508
|
+
|
|
509
|
+
title = Text()
|
|
510
|
+
title.append(f"\n {suite.project_name} ", style="bold white")
|
|
511
|
+
title.append("測試套件報告\n", style="dim")
|
|
512
|
+
console.print(Panel(title, border_style="cyan"))
|
|
513
|
+
|
|
514
|
+
# 測試類型結果表格
|
|
515
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
516
|
+
table.add_column("測試類型", style="white")
|
|
517
|
+
table.add_column("狀態", justify="center")
|
|
518
|
+
table.add_column("通過", justify="right", style="green")
|
|
519
|
+
table.add_column("失敗", justify="right", style="red")
|
|
520
|
+
table.add_column("時間", justify="right", style="dim")
|
|
521
|
+
table.add_column("覆蓋率", justify="right")
|
|
522
|
+
|
|
523
|
+
type_labels = {
|
|
524
|
+
'UIT': '單元測試 (UIT)',
|
|
525
|
+
'Smoke': '煙霧測試 (Smoke)',
|
|
526
|
+
'E2E': '端對端測試 (E2E)',
|
|
527
|
+
'UAT': '驗收測試 (UAT)'
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
configured_count = 0
|
|
531
|
+
for test_type, result in suite.results.items():
|
|
532
|
+
# 判斷狀態
|
|
533
|
+
if result.not_configured:
|
|
534
|
+
status = "[dim]N/A[/dim]"
|
|
535
|
+
elif result.success:
|
|
536
|
+
status = "[green]PASS[/green]"
|
|
537
|
+
configured_count += 1
|
|
538
|
+
else:
|
|
539
|
+
status = "[red]FAIL[/red]"
|
|
540
|
+
configured_count += 1
|
|
541
|
+
|
|
542
|
+
coverage_str = f"{result.coverage:.0f}%" if result.coverage > 0 else "-"
|
|
543
|
+
|
|
544
|
+
table.add_row(
|
|
545
|
+
type_labels.get(test_type, test_type),
|
|
546
|
+
status,
|
|
547
|
+
str(result.passed) if not result.not_configured else "-",
|
|
548
|
+
str(result.failed) if result.failed else "-",
|
|
549
|
+
f"{result.duration:.1f}s" if result.duration else "-",
|
|
550
|
+
coverage_str
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
console.print(table)
|
|
554
|
+
|
|
555
|
+
# 總計
|
|
556
|
+
console.print()
|
|
557
|
+
total = suite.total_passed + suite.total_failed
|
|
558
|
+
if total > 0:
|
|
559
|
+
pass_rate = (suite.total_passed / total) * 100
|
|
560
|
+
|
|
561
|
+
# 進度條
|
|
562
|
+
bar_width = 40
|
|
563
|
+
passed_width = int((suite.total_passed / total) * bar_width)
|
|
564
|
+
failed_width = bar_width - passed_width
|
|
565
|
+
|
|
566
|
+
bar = Text()
|
|
567
|
+
bar.append(" ")
|
|
568
|
+
bar.append("=" * passed_width, style="green")
|
|
569
|
+
bar.append("=" * failed_width, style="red")
|
|
570
|
+
bar.append(f" {pass_rate:.1f}%", style="green" if pass_rate >= 80 else "yellow")
|
|
571
|
+
console.print(bar)
|
|
572
|
+
console.print()
|
|
573
|
+
|
|
574
|
+
# 總結
|
|
575
|
+
summary = Text()
|
|
576
|
+
summary.append(" 總計: ")
|
|
577
|
+
summary.append(f"{suite.total_passed} passed", style="green")
|
|
578
|
+
summary.append(" / ")
|
|
579
|
+
summary.append(f"{suite.total_failed} failed", style="red" if suite.total_failed else "dim")
|
|
580
|
+
summary.append(f" | {suite.total_duration:.1f}s", style="dim")
|
|
581
|
+
console.print(summary)
|
|
582
|
+
|
|
583
|
+
# 覆蓋率
|
|
584
|
+
if suite.coverage > 0:
|
|
585
|
+
cov_color = "green" if suite.coverage >= 80 else "yellow" if suite.coverage >= 60 else "red"
|
|
586
|
+
console.print(f" 覆蓋率: [{cov_color}]{suite.coverage:.1f}%[/{cov_color}]")
|
|
587
|
+
|
|
588
|
+
# 最終狀態
|
|
589
|
+
console.print()
|
|
590
|
+
if configured_count == 0:
|
|
591
|
+
console.print(f" [yellow][-] 此專案未設定任何測試[/yellow]")
|
|
592
|
+
elif suite.overall_success:
|
|
593
|
+
console.print(f" [green][v] 所有測試通過[/green]")
|
|
594
|
+
else:
|
|
595
|
+
console.print(f" [red][x] 部分測試失敗[/red]")
|
|
596
|
+
|
|
597
|
+
console.print()
|
|
598
|
+
|
|
599
|
+
return {
|
|
600
|
+
'success': suite.overall_success,
|
|
601
|
+
'total_passed': suite.total_passed,
|
|
602
|
+
'total_failed': suite.total_failed,
|
|
603
|
+
'coverage': suite.coverage,
|
|
604
|
+
'results': {k: {
|
|
605
|
+
'passed': v.passed,
|
|
606
|
+
'failed': v.failed,
|
|
607
|
+
'success': v.success
|
|
608
|
+
} for k, v in suite.results.items()}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def run_test_suite(
|
|
613
|
+
project_path: str,
|
|
614
|
+
test_types: List[str] = None,
|
|
615
|
+
coverage: bool = True
|
|
616
|
+
) -> dict:
|
|
617
|
+
"""
|
|
618
|
+
執行測試套件
|
|
619
|
+
|
|
620
|
+
Args:
|
|
621
|
+
project_path: 專案路徑
|
|
622
|
+
test_types: 要執行的測試類型列表 (預設全部)
|
|
623
|
+
coverage: 是否包含覆蓋率 (預設 True)
|
|
624
|
+
|
|
625
|
+
Returns:
|
|
626
|
+
測試結果字典
|
|
627
|
+
"""
|
|
628
|
+
runner = TestSuiteRunner(project_path)
|
|
629
|
+
|
|
630
|
+
with Progress(
|
|
631
|
+
SpinnerColumn(),
|
|
632
|
+
TextColumn("[progress.description]{task.description}"),
|
|
633
|
+
console=console
|
|
634
|
+
) as progress:
|
|
635
|
+
# 偵測設定
|
|
636
|
+
task = progress.add_task("偵測測試設定...", total=None)
|
|
637
|
+
setup = runner.detect_test_setup()
|
|
638
|
+
progress.update(task, description="[green]設定偵測完成 v")
|
|
639
|
+
|
|
640
|
+
# 執行測試
|
|
641
|
+
task = progress.add_task("執行測試套件...", total=None)
|
|
642
|
+
suite_result = runner.run_all(test_types)
|
|
643
|
+
progress.update(task, description="[green]測試執行完成 v")
|
|
644
|
+
|
|
645
|
+
return render_test_suite_result(suite_result)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def run_test_suite_report(project_path: str, output_path: str = None) -> dict:
|
|
649
|
+
"""
|
|
650
|
+
產生測試套件報告
|
|
651
|
+
|
|
652
|
+
Args:
|
|
653
|
+
project_path: 專案路徑
|
|
654
|
+
output_path: 報告輸出路徑 (可選)
|
|
655
|
+
|
|
656
|
+
Returns:
|
|
657
|
+
報告字典
|
|
658
|
+
"""
|
|
659
|
+
runner = TestSuiteRunner(project_path)
|
|
660
|
+
suite_result = runner.run_all()
|
|
661
|
+
|
|
662
|
+
report = {
|
|
663
|
+
'project': suite_result.project_name,
|
|
664
|
+
'timestamp': suite_result.timestamp,
|
|
665
|
+
'overall_success': suite_result.overall_success,
|
|
666
|
+
'summary': {
|
|
667
|
+
'total_passed': suite_result.total_passed,
|
|
668
|
+
'total_failed': suite_result.total_failed,
|
|
669
|
+
'total_duration': suite_result.total_duration,
|
|
670
|
+
'coverage': suite_result.coverage
|
|
671
|
+
},
|
|
672
|
+
'tests': {}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
for test_type, result in suite_result.results.items():
|
|
676
|
+
report['tests'][test_type] = {
|
|
677
|
+
'success': result.success,
|
|
678
|
+
'passed': result.passed,
|
|
679
|
+
'failed': result.failed,
|
|
680
|
+
'duration': result.duration,
|
|
681
|
+
'coverage': result.coverage,
|
|
682
|
+
'error': result.error
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if output_path:
|
|
686
|
+
output_file = Path(output_path)
|
|
687
|
+
output_file.write_text(json.dumps(report, indent=2, ensure_ascii=False))
|
|
688
|
+
console.print(f"[green]報告已儲存: {output_path}[/green]")
|
|
689
|
+
|
|
690
|
+
return report
|