生成pdf报告
This commit is contained in:
Vendored
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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"]
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
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 # 在数值越界之后执行
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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元数据
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6153
-6151
File diff suppressed because one or more lines are too long
+181
-12
@@ -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:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user