生成pdf报告
This commit is contained in:
parent
38cfb67325
commit
fca20523ca
BIN
assets/.DS_Store
vendored
BIN
assets/.DS_Store
vendored
Binary file not shown.
BIN
assets/fonts/STHeiti Light.ttc
Normal file
BIN
assets/fonts/STHeiti Light.ttc
Normal file
Binary file not shown.
BIN
assets/fonts/STHeiti-Medium-4.ttc
Normal file
BIN
assets/fonts/STHeiti-Medium-4.ttc
Normal file
Binary file not shown.
BIN
assets/fonts/SourceHanSansCN-VF-2.otf
Normal file
BIN
assets/fonts/SourceHanSansCN-VF-2.otf
Normal file
Binary file not shown.
BIN
assets/fonts/王漢宗中仿宋簡.ttf
Normal file
BIN
assets/fonts/王漢宗中仿宋簡.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/站酷酷黑体.ttf
Normal file
BIN
assets/fonts/站酷酷黑体.ttf
Normal file
Binary file not shown.
@ -4,7 +4,7 @@ import json
|
||||
|
||||
class ResponseSchemaValidationCase(BaseAPITestCase):
|
||||
id = "TC-CORE-FUNC-001"
|
||||
name = "Response Body JSON Schema Validation"
|
||||
name = "返回体JSON Schema验证"
|
||||
description = "验证API响应体是否符合API规范中定义的JSON Schema。"
|
||||
severity = TestSeverity.CRITICAL
|
||||
tags = ["core-functionality", "schema-validation", "output-format"]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -7,8 +7,8 @@ import string
|
||||
|
||||
class InvalidEnumValueCase(BaseAPITestCase):
|
||||
id = "TC-ERROR-4006"
|
||||
name = "Error Code 4006 - Invalid Enum Value Validation"
|
||||
description = "测试当发送的参数值不在指定的枚举范围内时,API是否按预期返回code=-1的错误(状态码应为200)。"
|
||||
name = "非法枚举值检查"
|
||||
description = "测试当发送的参数值不在指定的枚举范围内时,API是否按预期返回code=4006的错误。"
|
||||
severity = TestSeverity.MEDIUM
|
||||
tags = ["error-handling", "appendix-b", "4006", "invalid-enum"]
|
||||
execution_order = 204 # 在数值越界之后执行
|
||||
|
||||
@ -5,8 +5,8 @@ from ddms_compliance_suite.utils import schema_utils # Keep this import for util
|
||||
|
||||
class MissingRequiredFieldBodyCase(BaseAPITestCase):
|
||||
id = "TC-ERROR-4003-BODY"
|
||||
name = "Error Code -1 - Missing Required Request Body Field Validation"
|
||||
description = "测试当请求体中缺少API规范定义的必填字段时,API是否按预期返回code=-1的错误。"
|
||||
name = "缺失必填请求体字段检查"
|
||||
description = "测试当请求体中缺少API规范定义的必填字段时,API是否按预期返回code=4003的错误。"
|
||||
severity = TestSeverity.HIGH
|
||||
tags = ["error-handling", "appendix-b", "4003", "required-fields", "request-body"]
|
||||
execution_order = 210
|
||||
|
||||
@ -4,8 +4,8 @@ import copy
|
||||
|
||||
class MissingRequiredFieldQueryCase(BaseAPITestCase):
|
||||
id = "TC-ERROR-4003-QUERY"
|
||||
name = "Error Code -1 - Missing Required Query Parameter Validation"
|
||||
description = "测试当请求中缺少API规范定义的必填查询参数时,API是否按预期返回code=-1的错误。"
|
||||
name = "缺失必填查询参数检查"
|
||||
description = "测试当请求中缺少API规范定义的必填查询参数时,API是否按预期返回code=4003的错误。"
|
||||
severity = TestSeverity.HIGH
|
||||
tags = ["error-handling", "appendix-b", "4003", "required-fields", "query-parameters"]
|
||||
execution_order = 211 # After body, before original combined one might have been
|
||||
|
||||
@ -5,8 +5,8 @@ from ddms_compliance_suite.utils import schema_utils
|
||||
|
||||
class NumberOutOfRangeCase(BaseAPITestCase):
|
||||
id = "TC-ERROR-4002"
|
||||
name = "Error Code -1 - Number Value Out of Range Validation"
|
||||
description = "测试当发送的数值参数超出范围限制时,API是否按预期返回code=-1的错误(状态码应为200)。"
|
||||
name = "数值参数越界检查"
|
||||
description = "测试当发送的数值参数超出范围限制时,API是否按预期返回code=4002的错误。"
|
||||
severity = TestSeverity.MEDIUM
|
||||
tags = ["error-handling", "appendix-b", "4002", "out-of-range"]
|
||||
execution_order = 203 # 在类型不匹配测试之后执行
|
||||
|
||||
@ -6,8 +6,8 @@ from ddms_compliance_suite.utils import schema_utils
|
||||
|
||||
class TypeMismatchBodyCase(BaseAPITestCase):
|
||||
id = "TC-ERROR-4001-BODY"
|
||||
name = "Error Code -1 - Request Body Type Mismatch Validation"
|
||||
description = "测试当发送的请求体中字段的数据类型与API规范定义不符时,API是否按预期返回code=-1的错误。"
|
||||
name = "请求体字段类型不匹配检查"
|
||||
description = "测试当发送的请求体中字段的数据类型与API规范定义不符时,API是否按预期返回code=4001的错误。"
|
||||
severity = TestSeverity.MEDIUM
|
||||
tags = ["error-handling", "appendix-b", "4001", "request-body"]
|
||||
execution_order = 202 # Slightly after query param one
|
||||
|
||||
@ -6,8 +6,8 @@ from ddms_compliance_suite.utils import schema_utils
|
||||
|
||||
class TypeMismatchQueryParamCase(BaseAPITestCase):
|
||||
id = "TC-ERROR-4001-QUERY"
|
||||
name = "Error Code -1 - Query Parameter Type Mismatch Validation"
|
||||
description = "测试当发送的查询参数数据类型与API规范定义不符时,API是否按预期返回code=-1的错误。"
|
||||
name = "查询参数类型不匹配检查"
|
||||
description = "测试当发送的查询参数数据类型与API规范定义不符时,API是否按预期返回code=4001的错误。"
|
||||
severity = TestSeverity.MEDIUM
|
||||
tags = ["error-handling", "appendix-b", "4001", "query-parameters"]
|
||||
execution_order = 201 # Slightly after the combined one might have been
|
||||
|
||||
@ -4,7 +4,7 @@ import urllib.parse
|
||||
|
||||
class HTTPSMandatoryCase(BaseAPITestCase):
|
||||
id = "TC-SECURITY-001"
|
||||
name = "HTTPS Protocol Mandatory Verification"
|
||||
name = "HTTPS 协议强制性检查"
|
||||
description = "验证API端点是否通过HTTPS提供服务,以及HTTP请求是否被拒绝或重定向到HTTPS。"
|
||||
severity = TestSeverity.CRITICAL
|
||||
tags = ["security", "https", "transport-security"]
|
||||
|
||||
Binary file not shown.
@ -1,3 +1,2 @@
|
||||
[
|
||||
"API应该使用正确的HTTP方法:GET用于检索,POST用于创建,PUT用于更新,DELETE用于删除"
|
||||
]
|
||||
@ -21,6 +21,15 @@ class LLMComplianceCheckTestCase(BaseAPITestCase):
|
||||
|
||||
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
|
||||
results = []
|
||||
|
||||
# 如果合规性标准列表为空,则跳过测试
|
||||
if not self.compliance_criteria:
|
||||
return [ValidationResult(
|
||||
passed=True,
|
||||
message="合规性标准列表为空,跳过LLM合规性检查。",
|
||||
details={"reason": "compliance_criteria.json is empty or contains an empty list."}
|
||||
)]
|
||||
|
||||
# 收集API所有关键信息,包括实例数据和Schema定义
|
||||
api_info = {
|
||||
# API元数据
|
||||
|
||||
BIN
ddms_compliance_suite/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
ddms_compliance_suite/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
12304
log_stage.txt
12304
log_stage.txt
File diff suppressed because one or more lines are too long
193
run_api_tests.py
193
run_api_tests.py
@ -14,8 +14,26 @@ import json
|
||||
import logging
|
||||
import argparse
|
||||
import datetime
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
import string # 导入string模块用于字符过滤
|
||||
import unicodedata # 导入unicodedata用于字符净化
|
||||
import html
|
||||
|
||||
# PDF生成库 - 使用ReportLab并打包字体
|
||||
try:
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont, TTFError
|
||||
|
||||
|
||||
reportlab_available = True
|
||||
except ImportError:
|
||||
reportlab_available = False
|
||||
|
||||
from ddms_compliance_suite.api_caller.caller import APICallDetail
|
||||
from ddms_compliance_suite.test_orchestrator import APITestOrchestrator, TestSummary
|
||||
@ -39,6 +57,7 @@ def parse_args():
|
||||
parser.add_argument('--output', '-o', help='输出目录或主报告文件路径 (例如 ./test_reports/ 或 ./test_reports/summary.json)')
|
||||
parser.add_argument('--format', choices=['json', 'html'], default='json', help='主测试摘要报告的输出格式')
|
||||
parser.add_argument('--api-calls-output', help='API 调用详情的 Markdown 输出文件路径 (例如 ./test_reports/api_calls.md)。如果未提供,将尝试使用 --output 目录和默认文件名 api_call_details.md。始终会额外生成一个同名的 .txt 文件包含纯 cURL 命令。')
|
||||
parser.add_argument('--generate-pdf', action='store_true', help='是否生成中文PDF报告', default=True)
|
||||
|
||||
# API定义参数
|
||||
api_group = parser.add_argument_group('API定义源')
|
||||
@ -341,17 +360,153 @@ def save_api_call_details_to_file(api_call_details: List[APICallDetail], output_
|
||||
except Exception as e:
|
||||
logger.error(f"保存API调用详情到 Markdown 文件 {md_output_file} 失败: {e}", exc_info=True)
|
||||
|
||||
# 额外生成 .txt 文件,只包含 cURL 命令
|
||||
# txt_output_filename = md_output_file.with_suffix('.txt').name
|
||||
# txt_output_file_path = output_dir / txt_output_filename
|
||||
|
||||
# try:
|
||||
# with open(txt_output_file_path, 'w', encoding='utf-8') as f_txt:
|
||||
# for detail in api_call_details:
|
||||
# f_txt.write(detail.curl_command + '\n')
|
||||
# logger.info(f"可直接执行的cURL命令已保存到纯文本文件: {txt_output_file_path}")
|
||||
# except Exception as e:
|
||||
# logger.error(f"保存cURL命令到文本文件 {txt_output_file_path} 失败: {e}", exc_info=True)
|
||||
def save_pdf_report(summary_data, output_path: Path):
|
||||
"""将测试摘要保存为格式化的PDF文件"""
|
||||
logger.info(f"开始生成PDF报告: {output_path}")
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
# --- 统一的字体管理和注册 ---
|
||||
font_name = 'SimSun' # 使用一个简单清晰的注册名
|
||||
font_path = 'assets/fonts/STHeiti-Medium-4.ttc'
|
||||
|
||||
if not Path(font_path).exists():
|
||||
logger.error(f"字体文件未找到: {Path(font_path).resolve()}")
|
||||
return
|
||||
|
||||
# 关键修复: 对于 .ttc (TrueType Collection) 文件, 必须指定 subfontIndex
|
||||
pdfmetrics.registerFont(TTFont(font_name, font_path, subfontIndex=0))
|
||||
# 将注册的字体关联到 'SimSun' 字体族
|
||||
pdfmetrics.registerFontFamily(font_name, normal=font_name, bold=font_name, italic=font_name, boldItalic=font_name)
|
||||
|
||||
doc = SimpleDocTemplate(str(output_path), pagesize=A4, title="API测试报告")
|
||||
elements = []
|
||||
|
||||
# --- 统一样式定义, 全部使用注册的字体名 ---
|
||||
styles = getSampleStyleSheet()
|
||||
title_style = ParagraphStyle('ChineseTitle', parent=styles['Title'], fontName=font_name, fontSize=22, leading=28)
|
||||
heading_style = ParagraphStyle('ChineseHeading', parent=styles['Heading1'], fontName=font_name, fontSize=16, leading=20, spaceAfter=8)
|
||||
normal_style = ParagraphStyle('ChineseNormal', parent=styles['Normal'], fontName=font_name, fontSize=10, leading=14)
|
||||
|
||||
def to_para(text, style=normal_style, escape=True):
|
||||
"""
|
||||
根据用户建议移除 textwrap 以进行诊断。
|
||||
此版本只包含净化和基本的换行符替换。
|
||||
"""
|
||||
if text is None:
|
||||
content = ""
|
||||
else:
|
||||
content = str(text)
|
||||
|
||||
if escape:
|
||||
content = html.escape(content)
|
||||
|
||||
# 依然保留Unicode控制字符的净化
|
||||
content = "".join(ch for ch in content if unicodedata.category(ch)[0] != 'C')
|
||||
|
||||
if not content.strip():
|
||||
# 对于完全空白或None的输入,返回一个安全的非换行空格
|
||||
return Paragraph(' ', style)
|
||||
|
||||
# 只使用基本的换行符替换
|
||||
content = content.replace('\n', '<br/>')
|
||||
|
||||
return Paragraph(content, style)
|
||||
|
||||
# 3. 填充PDF内容
|
||||
elements.append(to_para("API 测试报告", title_style, escape=False))
|
||||
elements.append(Spacer(1, 20))
|
||||
|
||||
elements.append(to_para("测试摘要", heading_style, escape=False))
|
||||
overall = summary_data.get('overall_summary', {})
|
||||
|
||||
# 从JSON提取并格式化时间
|
||||
try:
|
||||
start_time_str = summary_data.get('start_time', 'N/A')
|
||||
end_time_str = summary_data.get('end_time', 'N/A')
|
||||
duration = summary_data.get('duration_seconds', summary_data.get('duration', 0.0))
|
||||
|
||||
start_time_formatted = datetime.datetime.fromisoformat(start_time_str).strftime('%Y-%m-%d %H:%M:%S') if start_time_str != 'N/A' else 'N/A'
|
||||
end_time_formatted = datetime.datetime.fromisoformat(end_time_str).strftime('%Y-%m-%d %H:%M:%S') if end_time_str != 'N/A' else 'N/A'
|
||||
except:
|
||||
start_time_formatted = start_time_str
|
||||
end_time_formatted = end_time_str
|
||||
|
||||
summary_table_data = [
|
||||
[to_para("<b>开始时间</b>", escape=False), to_para(start_time_formatted)],
|
||||
[to_para("<b>结束时间</b>", escape=False), to_para(end_time_formatted)],
|
||||
[to_para("<b>总耗时</b>", escape=False), to_para(f"{float(duration):.2f} 秒")],
|
||||
[to_para("<b>测试的端点数</b>", escape=False), to_para(overall.get('endpoints_tested', 'N/A'))],
|
||||
[to_para("<b>执行的用例总数</b>", escape=False), to_para(overall.get('total_test_cases_executed', 'N/A'))],
|
||||
]
|
||||
summary_table = Table(summary_table_data, colWidths=[120, '*'])
|
||||
summary_table.setStyle(TableStyle([('GRID', (0,0), (-1,-1), 1, colors.grey), ('VALIGN', (0,0), (-1,-1), 'MIDDLE')]))
|
||||
elements.append(summary_table)
|
||||
elements.append(Spacer(1, 20))
|
||||
|
||||
elements.append(to_para("结果统计", heading_style, escape=False))
|
||||
results_table_data = [
|
||||
[to_para("<b>指标</b>", escape=False), to_para("<b>通过 ✅</b>", escape=False), to_para("<b>失败 ❌</b>", escape=False), to_para("<b>错误 ⚠️</b>", escape=False), to_para("<b>成功率</b>", escape=False)],
|
||||
[to_para("端点"), to_para(overall.get('endpoints_passed', 'N/A')), to_para(overall.get('endpoints_failed', 'N/A')), to_para(overall.get('endpoints_error', 'N/A')), to_para(f"<b>{overall.get('endpoint_success_rate', 'N/A')}%</b>", escape=False)],
|
||||
[to_para("测试用例"), to_para(overall.get('test_cases_passed', 'N/A')), to_para(overall.get('test_cases_failed', 'N/A')), to_para(overall.get('test_cases_error', 'N/A')), to_para(f"<b>{overall.get('test_case_success_rate', 'N/A')}%</b>", escape=False)]
|
||||
]
|
||||
results_table = Table(results_table_data, colWidths=['*', 60, 60, 60, 80])
|
||||
results_table.setStyle(TableStyle([('GRID', (0,0), (-1,-1), 1, colors.grey), ('BACKGROUND', (0,0), (-1,0), colors.lightgrey), ('ALIGN', (0,0), (-1,-1), 'CENTER'), ('VALIGN', (0,0), (-1,-1), 'MIDDLE')]))
|
||||
elements.append(results_table)
|
||||
elements.append(Spacer(1, 20))
|
||||
|
||||
elements.append(to_para("详细测试结果", heading_style, escape=False))
|
||||
|
||||
detailed_results = summary_data.get('endpoint_results', [])
|
||||
if not detailed_results:
|
||||
elements.append(to_para("无详细测试结果。"))
|
||||
else:
|
||||
# --- 全新实现:放弃表格,使用卡片式布局 ---
|
||||
status_map = {
|
||||
"PASSED": ("通过", colors.green),
|
||||
"FAILED": ("失败", colors.red),
|
||||
"ERROR": ("错误", colors.orange),
|
||||
"SKIPPED": ("跳过", colors.grey)
|
||||
}
|
||||
|
||||
for endpoint_result in detailed_results:
|
||||
endpoint_name = endpoint_result.get('endpoint_name', 'N/A')
|
||||
|
||||
# 为每个端点添加一个子标题 (移除<b>)
|
||||
endpoint_style = ParagraphStyle('endpoint_heading', parent=heading_style, fontSize=12, spaceBefore=12, spaceAfter=6)
|
||||
elements.append(to_para(f"端点: {endpoint_name}", style=endpoint_style))
|
||||
|
||||
test_cases = endpoint_result.get('executed_test_cases', [])
|
||||
if not test_cases:
|
||||
elements.append(to_para("该端点没有执行测试用例。", style=normal_style))
|
||||
continue
|
||||
|
||||
for tc_result in test_cases:
|
||||
# 移除 <b>
|
||||
elements.append(to_para(f"用例: {tc_result.get('test_case_name', 'N/A')}"))
|
||||
|
||||
# 使用 <font> 标签为状态词语上色
|
||||
status_en = tc_result.get('status', 'N/A')
|
||||
status_cn, status_color = status_map.get(status_en, (status_en, colors.black))
|
||||
status_text = f"状态: <font color='{status_color.hexval()}'>{status_cn}</font>"
|
||||
elements.append(to_para(status_text, escape=False)) # escape=False 以正确渲染font标签
|
||||
|
||||
# 将 "消息:" 标签和内容分离,不再使用 <br>
|
||||
elements.append(to_para("消息:"))
|
||||
message_text = tc_result.get('message', '')
|
||||
message_style = ParagraphStyle('message_style', parent=normal_style, leftIndent=15)
|
||||
elements.append(to_para(message_text, style=message_style, escape=True))
|
||||
|
||||
# 在每个测试用例后添加一条水平分割线
|
||||
elements.append(Spacer(1, 6))
|
||||
elements.append(HRFlowable(width="100%", thickness=0.5, color=colors.grey))
|
||||
elements.append(Spacer(1, 6))
|
||||
|
||||
# 构建PDF
|
||||
doc.build(elements)
|
||||
logger.info(f"PDF报告已成功生成: {output_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"构建PDF文档时出错: {e}", exc_info=True)
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
@ -464,9 +619,23 @@ def main():
|
||||
test_summary.finalize_summary() # 重新计算总时长等
|
||||
test_summary.print_summary_to_console() # 打印包含阶段结果的更新摘要
|
||||
|
||||
# 保存主测试摘要 (现在可能包含测试阶段结果)
|
||||
# 保存主测试摘要,格式由用户指定
|
||||
main_report_file_path = output_directory / f"summary.{args.format}"
|
||||
save_results(test_summary, str(main_report_file_path), args.format)
|
||||
|
||||
# 如果需要生成PDF报告
|
||||
if args.generate_pdf:
|
||||
json_summary_path_for_pdf = output_directory / "summary.json"
|
||||
|
||||
# 如果用户请求的不是json,我们需要额外生成一份json文件作为PDF的数据源
|
||||
if args.format != 'json':
|
||||
with open(json_summary_path_for_pdf, 'w', encoding='utf-8') as f:
|
||||
f.write(test_summary.to_json(pretty=True))
|
||||
logger.info(f"为生成PDF而创建临时JSON摘要: {json_summary_path_for_pdf}")
|
||||
|
||||
pdf_report_path = output_directory / "report_cn.pdf"
|
||||
save_pdf_report(test_summary.to_dict(), pdf_report_path)
|
||||
|
||||
# API调用详情报告也应保存在同一时间戳目录中
|
||||
api_calls_filename = "api_call_details.md"
|
||||
if orchestrator:
|
||||
|
||||
9110
test_reports/2025-06-27_17-52-16/api_call_details.md
Normal file
9110
test_reports/2025-06-27_17-52-16/api_call_details.md
Normal file
File diff suppressed because it is too large
Load Diff
BIN
test_reports/2025-06-27_17-52-16/report_cn.pdf
Normal file
BIN
test_reports/2025-06-27_17-52-16/report_cn.pdf
Normal file
Binary file not shown.
2358
test_reports/2025-06-27_17-52-16/summary.json
Normal file
2358
test_reports/2025-06-27_17-52-16/summary.json
Normal file
File diff suppressed because it is too large
Load Diff
9229
test_reports/2025-06-27_17-56-31/api_call_details.md
Normal file
9229
test_reports/2025-06-27_17-56-31/api_call_details.md
Normal file
File diff suppressed because it is too large
Load Diff
BIN
test_reports/2025-06-27_17-56-31/report_cn.pdf
Normal file
BIN
test_reports/2025-06-27_17-56-31/report_cn.pdf
Normal file
Binary file not shown.
2385
test_reports/2025-06-27_17-56-31/summary.json
Normal file
2385
test_reports/2025-06-27_17-56-31/summary.json
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user