step half finish
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,228 @@
|
||||
from typing import List, Dict, Any, Optional, Callable, Union
|
||||
import datetime
|
||||
import logging
|
||||
from enum import Enum
|
||||
|
||||
from .test_framework_core import ValidationResult, APIRequestContext, APIResponseContext
|
||||
from .api_caller.caller import APICallDetail # 需要APICallDetail来记录每个步骤的调用
|
||||
|
||||
class ScenarioStepDefinition:
|
||||
"""定义API场景中的一个单独步骤。"""
|
||||
def __init__(self,
|
||||
name: str,
|
||||
endpoint_spec_lookup_key: str, # 用于从全局API规范中查找端点定义的键
|
||||
request_overrides: Optional[Dict[str, Any]] = None,
|
||||
expected_status_codes: Optional[List[int]] = None,
|
||||
response_assertions: Optional[List[Callable[[APIResponseContext, Dict[str, Any]], List[ValidationResult]]]] = None,
|
||||
outputs_to_context: Optional[Dict[str, str]] = None):
|
||||
"""
|
||||
Args:
|
||||
name: 步骤的可读名称。
|
||||
endpoint_spec_lookup_key: 用于查找端点定义的键 (例如 "METHOD /path" 或 YAPI的_id)。
|
||||
request_overrides: 覆盖默认请求参数的字典。值可以是占位符,如 "{{scenario_context.user_id}}"。
|
||||
支持的键有: "path_params", "query_params", "headers", "body"。
|
||||
expected_status_codes: 预期的HTTP响应状态码列表。如果为None,则不进行特定状态码检查(除非由response_assertions处理)。
|
||||
response_assertions: 自定义断言函数列表。每个函数接收 APIResponseContext 和 scenario_context,返回 ValidationResult 列表。
|
||||
outputs_to_context: 从响应中提取数据到场景上下文的字典。
|
||||
键是存储到场景上下文中的变量名,值是提取路径 (例如 "response.body.data.id")。
|
||||
"""
|
||||
self.name = name
|
||||
self.endpoint_spec_lookup_key = endpoint_spec_lookup_key
|
||||
self.request_overrides = request_overrides if request_overrides is not None else {}
|
||||
self.expected_status_codes = expected_status_codes if expected_status_codes is not None else []
|
||||
self.response_assertions = response_assertions if response_assertions is not None else []
|
||||
self.outputs_to_context = outputs_to_context if outputs_to_context is not None else {}
|
||||
self.logger = logging.getLogger(f"scenario.step.{name}")
|
||||
|
||||
class BaseAPIScenario:
|
||||
"""
|
||||
API场景测试用例的基类。
|
||||
用户应继承此类来创建具体的测试场景。
|
||||
"""
|
||||
# --- 元数据 (由子类定义) ---
|
||||
id: str = "base_api_scenario"
|
||||
name: str = "基础API场景"
|
||||
description: str = "这是一个基础API场景,应由具体场景继承。"
|
||||
tags: List[str] = []
|
||||
steps: List[ScenarioStepDefinition] = [] # 子类需要填充此列表
|
||||
|
||||
def __init__(self,
|
||||
global_api_spec: Dict[str, Any], # 完整的API规范字典 (YAPI/Swagger解析后的原始字典)
|
||||
parsed_api_endpoints: List[Dict[str, Any]], # 解析后的端点列表,用于通过 lookup_key 查找
|
||||
llm_service: Optional[Any] = None):
|
||||
"""
|
||||
初始化API场景。
|
||||
Args:
|
||||
global_api_spec: 完整的API规范字典。
|
||||
parsed_api_endpoints: 从YAPI/Swagger解析出来的端点对象列表(通常是YAPIEndpoint或SwaggerEndpoint的to_dict()结果)。
|
||||
这些对象应包含用于匹配 `endpoint_spec_lookup_key` 的字段。
|
||||
llm_service: APITestOrchestrator 传入的 LLMService 实例 (可选)。
|
||||
"""
|
||||
self.global_api_spec = global_api_spec
|
||||
self.parsed_api_endpoints = parsed_api_endpoints # 用于快速查找
|
||||
self.llm_service = llm_service
|
||||
self.logger = logging.getLogger(f"scenario.{self.id}")
|
||||
self.logger.info(f"API场景 '{self.id}' ({self.name}) 已初始化。")
|
||||
|
||||
def _get_endpoint_spec_from_global(self, lookup_key: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
根据提供的 lookup_key 从 self.parsed_api_endpoints 中查找并返回端点定义。
|
||||
查找逻辑可能需要根据 lookup_key 的格式 (例如, YAPI _id, METHOD /path) 进行调整。
|
||||
简单实现:假设 lookup_key 是 METHOD /path 或 title。
|
||||
"""
|
||||
self.logger.debug(f"尝试为场景步骤查找端点: '{lookup_key}'")
|
||||
for endpoint_data in self.parsed_api_endpoints:
|
||||
# 尝试匹配 "METHOD /path" 格式 (常见于SwaggerEndpoint)
|
||||
method_path_key = f"{str(endpoint_data.get('method', '')).upper()} {endpoint_data.get('path', '')}"
|
||||
if lookup_key == method_path_key:
|
||||
self.logger.debug(f"通过 'METHOD /path' ('{method_path_key}') 找到端点。")
|
||||
return endpoint_data
|
||||
|
||||
# 尝试匹配 title (常见于YAPIEndpoint)
|
||||
if lookup_key == endpoint_data.get('title'):
|
||||
self.logger.debug(f"通过 'title' ('{endpoint_data.get('title')}') 找到端点。")
|
||||
return endpoint_data
|
||||
|
||||
# 尝试匹配 YAPI 的 _id (如果可用)
|
||||
if str(lookup_key) == str(endpoint_data.get('_id')): # 转换为字符串以确保比较
|
||||
self.logger.debug(f"通过 YAPI '_id' ('{endpoint_data.get('_id')}') 找到端点。")
|
||||
return endpoint_data
|
||||
|
||||
# 尝试匹配 Swagger/OpenAPI 的 operationId
|
||||
if lookup_key == endpoint_data.get('operationId'):
|
||||
self.logger.debug(f"通过 'operationId' ('{endpoint_data.get('operationId')}') 找到端点。")
|
||||
return endpoint_data
|
||||
|
||||
|
||||
self.logger.warning(f"未能在 parsed_api_endpoints 中找到 lookup_key 为 '{lookup_key}' 的端点。")
|
||||
return None
|
||||
|
||||
def before_scenario(self, scenario_context: Dict[str, Any]):
|
||||
"""在场景所有步骤执行前调用 (可选,供子类覆盖)"""
|
||||
self.logger.debug(f"Hook: before_scenario for '{self.id}'")
|
||||
pass
|
||||
|
||||
def after_scenario(self, scenario_context: Dict[str, Any], scenario_result: 'ExecutedScenarioResult'):
|
||||
"""在场景所有步骤执行后调用 (可选,供子类覆盖)"""
|
||||
self.logger.debug(f"Hook: after_scenario for '{self.id}'")
|
||||
pass
|
||||
|
||||
def before_step(self, step_definition: ScenarioStepDefinition, scenario_context: Dict[str, Any]):
|
||||
"""在每个步骤执行前调用 (可选,供子类覆盖)"""
|
||||
self.logger.debug(f"Hook: before_step '{step_definition.name}' for '{self.id}'")
|
||||
pass
|
||||
|
||||
def after_step(self, step_definition: ScenarioStepDefinition, step_result: 'ExecutedScenarioStepResult', scenario_context: Dict[str, Any]):
|
||||
"""在每个步骤执行后调用 (可选,供子类覆盖)"""
|
||||
self.logger.debug(f"Hook: after_step '{step_definition.name}' for '{self.id}'")
|
||||
pass
|
||||
|
||||
|
||||
class ExecutedScenarioStepResult:
|
||||
"""存储单个API场景步骤执行后的结果。"""
|
||||
class Status(str, Enum):
|
||||
PASSED = "通过"
|
||||
FAILED = "失败"
|
||||
ERROR = "执行错误"
|
||||
SKIPPED = "跳过"
|
||||
|
||||
def __init__(self,
|
||||
step_name: str,
|
||||
status: Status,
|
||||
message: str = "",
|
||||
validation_points: Optional[List[ValidationResult]] = None,
|
||||
duration: float = 0.0,
|
||||
api_call_detail: Optional[APICallDetail] = None,
|
||||
extracted_outputs: Optional[Dict[str, Any]] = None):
|
||||
self.step_name = step_name
|
||||
self.status = status
|
||||
self.message = message
|
||||
self.validation_points = validation_points if validation_points is not None else []
|
||||
self.duration = duration
|
||||
self.api_call_detail = api_call_detail # 存储此步骤的API调用详情
|
||||
self.extracted_outputs = extracted_outputs if extracted_outputs is not None else {} # 从此步骤提取并存入上下文的值
|
||||
self.timestamp = datetime.datetime.now()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"step_name": self.step_name,
|
||||
"status": self.status.value,
|
||||
"message": self.message,
|
||||
"duration_seconds": self.duration,
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
"validation_points": [vp.to_dict() if hasattr(vp, 'to_dict') else {"passed": vp.passed, "message": vp.message, "details": vp.details} for vp in self.validation_points],
|
||||
"api_call_detail": self.api_call_detail.to_dict() if self.api_call_detail and hasattr(self.api_call_detail, 'to_dict') else None,
|
||||
"extracted_outputs": self.extracted_outputs
|
||||
}
|
||||
|
||||
class ExecutedScenarioResult:
|
||||
"""存储整个API场景执行后的结果。"""
|
||||
class Status(str, Enum):
|
||||
PASSED = "通过" # 所有步骤都通过
|
||||
FAILED = "失败" # 任何一个步骤失败或出错
|
||||
SKIPPED = "跳过" # 整个场景被跳过
|
||||
|
||||
def __init__(self,
|
||||
scenario_id: str,
|
||||
scenario_name: str,
|
||||
overall_status: Status = Status.SKIPPED,
|
||||
message: str = ""):
|
||||
self.scenario_id = scenario_id
|
||||
self.scenario_name = scenario_name
|
||||
self.overall_status = overall_status
|
||||
self.message = message
|
||||
self.executed_steps: List[ExecutedScenarioStepResult] = []
|
||||
self.scenario_context_final_state: Dict[str, Any] = {}
|
||||
self.start_time = datetime.datetime.now()
|
||||
self.end_time: Optional[datetime.datetime] = None
|
||||
|
||||
def add_step_result(self, result: ExecutedScenarioStepResult):
|
||||
self.executed_steps.append(result)
|
||||
|
||||
def finalize_scenario_result(self, final_context: Dict[str, Any]):
|
||||
self.end_time = datetime.datetime.now()
|
||||
self.scenario_context_final_state = final_context
|
||||
|
||||
if not self.executed_steps and self.overall_status == ExecutedScenarioResult.Status.SKIPPED:
|
||||
pass # 保持 SKIPPED
|
||||
elif any(step.status == ExecutedScenarioStepResult.Status.ERROR for step in self.executed_steps):
|
||||
self.overall_status = ExecutedScenarioResult.Status.FAILED
|
||||
if not self.message: self.message = "场景中至少一个步骤执行出错。"
|
||||
elif any(step.status == ExecutedScenarioStepResult.Status.FAILED for step in self.executed_steps):
|
||||
self.overall_status = ExecutedScenarioResult.Status.FAILED
|
||||
if not self.message: self.message = "场景中至少一个步骤失败。"
|
||||
elif all(step.status == ExecutedScenarioStepResult.Status.SKIPPED for step in self.executed_steps) and self.executed_steps:
|
||||
self.overall_status = ExecutedScenarioResult.Status.SKIPPED # 如果所有步骤都跳过了
|
||||
if not self.message: self.message = "场景中的所有步骤都被跳过。"
|
||||
elif not self.executed_steps: # 没有步骤执行,也不是初始的SKIPPED
|
||||
self.overall_status = ExecutedScenarioResult.Status.FAILED # 或 ERROR
|
||||
if not self.message: self.message = "场景中没有步骤被执行。"
|
||||
else: # 所有步骤都通过
|
||||
self.overall_status = ExecutedScenarioResult.Status.PASSED
|
||||
if not self.message: self.message = "场景所有步骤成功通过。"
|
||||
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
if self.start_time and self.end_time:
|
||||
return (self.end_time - self.start_time).total_seconds()
|
||||
return 0.0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"scenario_id": self.scenario_id,
|
||||
"scenario_name": self.scenario_name,
|
||||
"overall_status": self.overall_status.value,
|
||||
"message": self.message,
|
||||
"duration_seconds": f"{self.duration:.2f}",
|
||||
"start_time": self.start_time.isoformat(),
|
||||
"end_time": self.end_time.isoformat() if self.end_time else None,
|
||||
"executed_steps": [step.to_dict() for step in self.executed_steps],
|
||||
"scenario_context_final_state": self.scenario_context_final_state # 可能包含敏感信息,按需处理
|
||||
}
|
||||
|
||||
def to_json(self, pretty=True) -> str:
|
||||
import json # 局部导入
|
||||
indent = 2 if pretty else None
|
||||
# 对于 scenario_context_final_state,可能需要自定义序列化器来处理复杂对象
|
||||
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False, default=str)
|
||||
@@ -0,0 +1,90 @@
|
||||
import os
|
||||
import importlib.util
|
||||
import inspect
|
||||
import logging
|
||||
from typing import List, Type, Dict, Optional
|
||||
|
||||
from .scenario_framework import BaseAPIScenario # 从新的场景框架模块导入
|
||||
|
||||
class ScenarioRegistry:
|
||||
"""
|
||||
负责发现、加载和管理所有自定义的 BaseAPIScenario 类。
|
||||
"""
|
||||
def __init__(self, scenarios_dir: Optional[str] = None):
|
||||
"""
|
||||
初始化 ScenarioRegistry。
|
||||
Args:
|
||||
scenarios_dir: 存放自定义API场景 (.py 文件) 的目录路径。如果为None,则不进行发现。
|
||||
"""
|
||||
self.scenarios_dir = scenarios_dir
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self._registry: Dict[str, Type[BaseAPIScenario]] = {}
|
||||
self._scenario_classes: List[Type[BaseAPIScenario]] = []
|
||||
if self.scenarios_dir:
|
||||
self.discover_scenarios()
|
||||
else:
|
||||
self.logger.info("ScenarioRegistry 初始化时未提供 scenarios_dir,跳过场景发现。")
|
||||
|
||||
def discover_scenarios(self):
|
||||
"""
|
||||
扫描指定目录及其所有子目录,动态导入模块,并注册所有继承自 BaseAPIScenario 的类。
|
||||
"""
|
||||
if not self.scenarios_dir or not os.path.isdir(self.scenarios_dir):
|
||||
self.logger.warning(f"API场景目录不存在或不是一个目录: {self.scenarios_dir}")
|
||||
return
|
||||
|
||||
self.logger.info(f"开始从目录 '{self.scenarios_dir}' 及其子目录发现API场景...")
|
||||
found_count = 0
|
||||
for root_dir, _, files in os.walk(self.scenarios_dir):
|
||||
for filename in files:
|
||||
if filename.endswith(".py") and not filename.startswith("__"):
|
||||
module_name = filename[:-3]
|
||||
file_path = os.path.join(root_dir, filename)
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
||||
if spec and spec.loader:
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
self.logger.debug(f"成功导入API场景模块: {module_name} 从 {file_path}")
|
||||
|
||||
for name, obj in inspect.getmembers(module):
|
||||
if inspect.isclass(obj) and issubclass(obj, BaseAPIScenario) and obj is not BaseAPIScenario:
|
||||
if not hasattr(obj, 'id') or not obj.id:
|
||||
self.logger.error(f"API场景类 '{obj.__name__}' 在文件 '{file_path}' 中缺少有效的 'id' 属性,已跳过注册。")
|
||||
continue
|
||||
|
||||
if obj.id in self._registry:
|
||||
self.logger.warning(f"发现重复的API场景 ID: '{obj.id}' (来自类 '{obj.__name__}' in {file_path})。之前的定义将被覆盖。")
|
||||
|
||||
self._registry[obj.id] = obj
|
||||
# 更新 _scenario_classes 列表
|
||||
existing_class_indices = [i for i, sc_class in enumerate(self._scenario_classes) if sc_class.id == obj.id]
|
||||
if existing_class_indices:
|
||||
for index in sorted(existing_class_indices, reverse=True):
|
||||
del self._scenario_classes[index]
|
||||
|
||||
self._scenario_classes.append(obj)
|
||||
found_count += 1
|
||||
self.logger.info(f"已注册API场景: '{obj.id}' ({getattr(obj, 'name', 'N/A')}) 来自类 '{obj.__name__}' (路径: {file_path})")
|
||||
else:
|
||||
self.logger.error(f"无法为文件 '{file_path}' 创建模块规范 (用于API场景)。")
|
||||
except ImportError as e:
|
||||
self.logger.error(f"导入API场景模块 '{module_name}' 从 '{file_path}' 失败: {e}", exc_info=True)
|
||||
except AttributeError as e:
|
||||
self.logger.error(f"在API场景模块 '{module_name}' ({file_path}) 中查找场景时出错: {e}", exc_info=True)
|
||||
except Exception as e:
|
||||
self.logger.error(f"处理API场景文件 '{file_path}' 时发生未知错误: {e}", exc_info=True)
|
||||
|
||||
# 场景通常不需要像单个测试用例那样排序执行顺序,除非有特定需求
|
||||
# 如果需要,可以添加类似 execution_order 的属性并排序
|
||||
# self._scenario_classes.sort(key=lambda sc_class: (getattr(sc_class, 'execution_order', 100), sc_class.__name__))
|
||||
|
||||
self.logger.info(f"API场景发现完成。总共注册了 {len(self._registry)} 个独特的API场景 (基于ID)。发现并加载了 {len(self._scenario_classes)} 个API场景类。")
|
||||
|
||||
def get_scenario_by_id(self, scenario_id: str) -> Optional[Type[BaseAPIScenario]]:
|
||||
"""根据ID获取已注册的API场景类。"""
|
||||
return self._registry.get(scenario_id)
|
||||
|
||||
def get_all_scenario_classes(self) -> List[Type[BaseAPIScenario]]:
|
||||
"""获取所有已注册的API场景类列表。"""
|
||||
return list(self._scenario_classes) # 返回副本
|
||||
@@ -0,0 +1,454 @@
|
||||
"""
|
||||
API 阶段测试框架模块
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
from typing import List, Dict, Any, Callable, Optional, Union
|
||||
from enum import Enum
|
||||
|
||||
# Add Pydantic BaseModel for APIOperationSpec
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .test_framework_core import ValidationResult, APIResponseContext
|
||||
from .api_caller.caller import APICallDetail
|
||||
# Import ParsedAPISpec and endpoint types for type hinting and usage
|
||||
from .input_parser.parser import ParsedAPISpec, YAPIEndpoint, SwaggerEndpoint
|
||||
|
||||
# 尝试从 .llm_utils 导入,如果失败则 LLMService 为 None
|
||||
try:
|
||||
from .llm_utils.llm_service import LLMService
|
||||
except ImportError:
|
||||
LLMService = None
|
||||
logging.getLogger(__name__).info("LLMService not found in stage_framework, LLM related features for stages might be limited.")
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 定义 APIOperationSpec
|
||||
class APIOperationSpec(BaseModel):
|
||||
method: str
|
||||
path: str
|
||||
spec: Dict[str, Any] # 原始API端点定义字典
|
||||
operation_id: Optional[str] = None
|
||||
summary: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
# 默认的操作类型关键字映射
|
||||
# 键是标准化的操作类型,值是可能出现在API标题中的关键字列表
|
||||
DEFAULT_OPERATION_KEYWORDS: Dict[str, List[str]] = {
|
||||
"add": ["添加", "创建", "新增", "新建", "create", "add", "new"],
|
||||
"delete": ["删除", "移除", "delete", "remove"],
|
||||
"update": ["修改", "更新", "编辑", "update", "edit", "put"],
|
||||
"list_query": ["列表", "查询", "获取列表", "搜索", "list", "query", "search", "getall", "getlist"],
|
||||
"detail_query": ["详情", "获取单个", "getone", "detail", "getbyid"],
|
||||
# 可以根据需要添加更多通用操作类型
|
||||
}
|
||||
|
||||
|
||||
class StageStepDefinition:
|
||||
"""定义API测试阶段中的单个步骤。"""
|
||||
def __init__(self,
|
||||
name: str,
|
||||
endpoint_spec_lookup_key: Union[str, Dict[str, str]], # 例如 "GET /pets/{petId}" 或 {"method": "GET", "path": "/pets/{petId}"} 或操作类型 "add"
|
||||
description: Optional[str] = None, # <--- 添加 description 参数
|
||||
request_overrides: Optional[Dict[str, Any]] = None,
|
||||
expected_status_codes: Optional[List[int]] = None,
|
||||
response_assertions: Optional[List[Callable[[APIResponseContext, Dict[str, Any]], List[ValidationResult]]]] = None,
|
||||
outputs_to_context: Optional[Dict[str, str]] = None,
|
||||
order: int = 0): # 新增执行顺序
|
||||
self.name = name
|
||||
self.endpoint_spec_lookup_key = endpoint_spec_lookup_key
|
||||
self.description = description # <--- 设置 description 属性
|
||||
self.request_overrides = request_overrides or {}
|
||||
self.expected_status_codes = expected_status_codes or []
|
||||
self.response_assertions = response_assertions or []
|
||||
self.outputs_to_context = outputs_to_context or {}
|
||||
self.order = order
|
||||
|
||||
if not isinstance(self.endpoint_spec_lookup_key, (str, dict)):
|
||||
raise ValueError("StageStepDefinition: endpoint_spec_lookup_key must be a string (operation type or method/path) or a dict {'method': 'X', 'path': 'Y'}")
|
||||
|
||||
|
||||
class BaseAPIStage:
|
||||
"""
|
||||
API测试阶段的基类。
|
||||
一个测试阶段通常针对一个API分组(如YAPI中的一个分类,或Swagger中的一个Tag下的所有API),
|
||||
并定义了一系列有序的API调用步骤来完成一个完整的业务流程或集成测试。
|
||||
"""
|
||||
id: Optional[str] = None # 唯一ID,例如 "TC_USER_CRUD_STAGE"
|
||||
name: str = "Unnamed API Stage"
|
||||
description: str = ""
|
||||
tags: List[str] = []
|
||||
|
||||
# 由子类定义,表示此Stage中的API调用步骤
|
||||
steps: List[StageStepDefinition] = []
|
||||
|
||||
|
||||
def __init__(self,
|
||||
api_group_metadata: Dict[str, Any],
|
||||
apis_in_group: List[Dict[str, Any]], # 当前分组内所有API的定义列表 (字典格式)
|
||||
llm_service: Optional[LLMService] = None,
|
||||
global_api_spec: Optional[ParsedAPISpec] = None, # <--- 修改类型注解
|
||||
operation_keywords: Optional[Dict[str, List[str]]] = None):
|
||||
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
|
||||
self.current_api_group_metadata = api_group_metadata
|
||||
self.current_apis_in_group = apis_in_group # 这些应该是已经解析好的API定义字典
|
||||
self.llm_service = llm_service
|
||||
self.global_api_spec = global_api_spec # 保留 Optional[ParsedAPISpec]
|
||||
|
||||
self._operation_keywords = operation_keywords or DEFAULT_OPERATION_KEYWORDS
|
||||
self._matched_endpoints: Dict[str, Dict[str, Any]] = {} # 存储按操作类型匹配到的端点定义
|
||||
self._match_api_endpoints_in_group() # 初始化时自动匹配
|
||||
|
||||
# 确保子类定义了ID
|
||||
if not self.id:
|
||||
self.id = self.__class__.__name__
|
||||
self.logger.warning(f"BaseAPIStage subclass '{self.__class__.__name__}' does not have an 'id' attribute defined. Defaulting to class name: '{self.id}'. It is recommended to set a unique id.")
|
||||
|
||||
# 对步骤进行排序
|
||||
self.steps = sorted(self.steps, key=lambda s: s.order)
|
||||
|
||||
|
||||
def is_applicable_to_api_group(self, api_group_metadata: Dict[str, Any], apis_in_group: List[Dict[str, Any]]) -> bool:
|
||||
"""
|
||||
判断此测试阶段是否适用于给定的API分组。
|
||||
子类可以重写此方法以实现更复杂的适用性逻辑。
|
||||
|
||||
Args:
|
||||
api_group_metadata: API分组的元数据 (例如 YAPI category name/id 或 Swagger tag name/description)。
|
||||
apis_in_group: 该分组内的API端点定义列表。
|
||||
|
||||
Returns:
|
||||
True 如果此阶段适用于该API分组,否则 False。
|
||||
"""
|
||||
return True # 默认应用于所有分组
|
||||
|
||||
def _match_api_endpoints_in_group(self):
|
||||
"""
|
||||
在当前API分组 (`self.current_apis_in_group`) 中,根据API标题和关键字匹配操作类型。
|
||||
并将匹配到的API端点定义存储在 `self._matched_endpoints` 中。
|
||||
"""
|
||||
self.logger.debug(f"'{self.id}': Starting API endpoint matching within group '{self.current_api_group_metadata.get('name', 'UnknownGroup')}'. Found {len(self.current_apis_in_group)} APIs in group.")
|
||||
if not self.current_apis_in_group:
|
||||
self.logger.warning(f"'{self.id}': No APIs found in the current group '{self.current_api_group_metadata.get('name', 'UnknownGroup')}' to match against.")
|
||||
return
|
||||
|
||||
self._matched_endpoints = {} # 重置
|
||||
|
||||
# 尝试从API定义中获取标题的优先级
|
||||
title_keys_priority = ['title', 'summary', 'operationId', 'description']
|
||||
|
||||
for api_def in self.current_apis_in_group:
|
||||
if not isinstance(api_def, dict):
|
||||
self.logger.warning(f"'{self.id}': Found non-dict API definition in group, skipping: {type(api_def)}")
|
||||
continue
|
||||
|
||||
api_title = None
|
||||
for key in title_keys_priority:
|
||||
title_candidate = api_def.get(key)
|
||||
if isinstance(title_candidate, str) and title_candidate.strip():
|
||||
api_title = title_candidate.strip()
|
||||
break
|
||||
|
||||
if not api_title:
|
||||
self.logger.debug(f"'{self.id}': API definition (method: {api_def.get('method')}, path: {api_def.get('path')}) has no suitable title/summary for matching. Skipping.")
|
||||
continue
|
||||
|
||||
api_method_path_log = f"(Method: {api_def.get('method', 'N/A')}, Path: {api_def.get('path', 'N/A')}, Title: '{api_title}')"
|
||||
|
||||
for op_type, keywords in self._operation_keywords.items():
|
||||
if op_type in self._matched_endpoints: # 如果该操作类型已经匹配到了,则跳过 (每个操作类型只取第一个匹配项)
|
||||
continue
|
||||
|
||||
for keyword in keywords:
|
||||
if keyword.lower() in api_title.lower():
|
||||
self._matched_endpoints[op_type] = api_def
|
||||
self.logger.info(f"'{self.id}': Matched API {api_method_path_log} to operation type '{op_type}' based on keyword '{keyword}'.")
|
||||
break # 当前操作类型的关键字匹配成功,跳到下一个操作类型
|
||||
if op_type in self._matched_endpoints: # 再次检查,因为可能在内层循环break
|
||||
continue # 跳到下一个操作类型
|
||||
|
||||
self.logger.debug(f"'{self.id}': Finished API endpoint matching. Matched operations: {list(self._matched_endpoints.keys())}")
|
||||
if not self._matched_endpoints:
|
||||
self.logger.warning(f"'{self.id}': No API endpoints were matched to any operation type within the group '{self.current_api_group_metadata.get('name', 'N/A')}'. Stage execution might fail if steps rely on matched operations.")
|
||||
|
||||
|
||||
def get_api_spec_for_operation(self, lookup_key: str, global_api_spec: ParsedAPISpec, api_group_name: Optional[str] = None, required: bool = True) -> Optional[APIOperationSpec]:
|
||||
"""
|
||||
获取为指定查找键 (API标题、操作ID或 "METHOD /path") 匹配到的API端点定义。
|
||||
此方法现在直接从 global_api_spec.endpoints 中查找。
|
||||
|
||||
Args:
|
||||
lookup_key: API的查找键 (通常是标题、操作ID, 或 "METHOD /path" 字符串)。
|
||||
global_api_spec: 已解析的完整API规范对象。
|
||||
api_group_name: 当前API分组的名称 (可选, 主要用于日志)。
|
||||
required: 如果为True且未找到匹配的API,则记录错误。
|
||||
|
||||
Returns:
|
||||
APIOperationSpec 实例,如果未找到则为None。
|
||||
"""
|
||||
if not global_api_spec or not global_api_spec.endpoints:
|
||||
self.logger.error(f"'{self.id}': global_api_spec 或其端点列表为空,无法查找操作 '{lookup_key}'。")
|
||||
if required:
|
||||
self.logger.error(f"'{self.id}': 未找到必需的操作 '{lookup_key}',因为API规范为空或无端点。")
|
||||
return None
|
||||
|
||||
self.logger.info(f"'{self.id}': 正在从 global_api_spec 中查找操作,键: '{lookup_key}', API组: '{api_group_name}'")
|
||||
|
||||
for endpoint_obj in global_api_spec.endpoints:
|
||||
# endpoint_obj is YAPIEndpoint or SwaggerEndpoint
|
||||
match_found = False
|
||||
# 1. Check by "METHOD /path" string
|
||||
method_path_key = f"{endpoint_obj.method.upper()} {endpoint_obj.path}"
|
||||
if method_path_key == lookup_key:
|
||||
match_found = True
|
||||
|
||||
# 2. Check by title
|
||||
if not match_found and hasattr(endpoint_obj, 'title') and endpoint_obj.title == lookup_key:
|
||||
match_found = True
|
||||
|
||||
# 3. Check by operationId
|
||||
if not match_found and hasattr(endpoint_obj, 'operation_id') and endpoint_obj.operation_id == lookup_key:
|
||||
match_found = True
|
||||
|
||||
if match_found:
|
||||
self.logger.info(f"'{self.id}': 找到匹配操作 '{lookup_key}' -> Method: {endpoint_obj.method}, Path: {endpoint_obj.path}")
|
||||
|
||||
raw_spec_dict: Dict[str, Any] = {}
|
||||
if hasattr(endpoint_obj, 'to_dict') and callable(endpoint_obj.to_dict):
|
||||
raw_spec_dict = endpoint_obj.to_dict()
|
||||
elif isinstance(endpoint_obj, dict): # Should not happen if global_api_spec.endpoints are objects
|
||||
raw_spec_dict = endpoint_obj
|
||||
else:
|
||||
self.logger.warning(f"'{self.id}': 匹配的端点对象 '{lookup_key}' (类型: {type(endpoint_obj)}) 缺少 to_dict() 方法且不是字典,无法获取完整规格。")
|
||||
# Fallback: construct spec from known attributes if possible
|
||||
raw_spec_dict = {
|
||||
"method": endpoint_obj.method,
|
||||
"path": endpoint_obj.path,
|
||||
"title": getattr(endpoint_obj, 'title', None),
|
||||
"summary": getattr(endpoint_obj, 'summary', None),
|
||||
"description": getattr(endpoint_obj, 'description', None),
|
||||
"operationId": getattr(endpoint_obj, 'operation_id', None),
|
||||
"parameters": getattr(endpoint_obj, 'parameters', []) if hasattr(endpoint_obj, 'parameters') else [],
|
||||
"requestBody": getattr(endpoint_obj, 'request_body', None) if hasattr(endpoint_obj, 'request_body') else None,
|
||||
"responses": getattr(endpoint_obj, 'responses', {}) if hasattr(endpoint_obj, 'responses') else {}
|
||||
}
|
||||
|
||||
|
||||
return APIOperationSpec(
|
||||
method=endpoint_obj.method,
|
||||
path=endpoint_obj.path,
|
||||
spec=raw_spec_dict,
|
||||
operation_id=getattr(endpoint_obj, 'operation_id', None),
|
||||
summary=getattr(endpoint_obj, 'summary', None) or getattr(endpoint_obj, 'title', None), # Prioritize summary
|
||||
description=getattr(endpoint_obj, 'description', None)
|
||||
)
|
||||
|
||||
# If no match found after iterating all endpoints
|
||||
if required:
|
||||
self.logger.error(f"'{self.id}': 在 global_api_spec 中未找到必需的操作,查找键: '{lookup_key}'. (API组: '{api_group_name}')")
|
||||
else:
|
||||
self.logger.debug(f"'{self.id}': 在 global_api_spec 中未找到可选的操作,查找键: '{lookup_key}'. (API组: '{api_group_name}')")
|
||||
return None
|
||||
|
||||
def get_endpoint_lookup_key_for_operation(self, operation_type: str, required: bool = True) -> Optional[Union[str, Dict[str,str]]]:
|
||||
"""
|
||||
辅助方法:为指定操作类型获取可以直接用于 StageStepDefinition 的 endpoint_spec_lookup_key。
|
||||
注意:此方法依赖于旧的 _matched_endpoints 机制。如果主要查找机制已改为 get_api_spec_for_operation(lookup_key, global_api_spec, ...),
|
||||
则此方法的用处可能有限,除非有Stage明确需要通过通用 operation_type ("add", "delete") 来获取 method/path key。
|
||||
"""
|
||||
# This method implementation relies on self._matched_endpoints which uses generic operation_type keys
|
||||
# It might conflict or be less useful if the primary way to get spec is via specific lookup_key in get_api_spec_for_operation.
|
||||
# For now, keeping its original logic related to self._matched_endpoints.
|
||||
# Consider if this method needs to be aligned with the new get_api_spec_for_operation behavior or deprecated/refactored.
|
||||
|
||||
api_spec_dict = self._matched_endpoints.get(operation_type) # Uses generic keys like "add"
|
||||
|
||||
if api_spec_dict:
|
||||
method = api_spec_dict.get("method")
|
||||
path = api_spec_dict.get("path")
|
||||
if method and path:
|
||||
return f"{str(method).upper()} {str(path)}"
|
||||
else:
|
||||
self.logger.error(f"'{self.id}': 从 _matched_endpoints 获取的操作 '{operation_type}' 的规格缺少 'method' 或 'path'. 规格: {api_spec_dict}")
|
||||
if required:
|
||||
raise ValueError(f"为 '{operation_type}' 匹配到的API规格无效 (缺少 method/path)。")
|
||||
return None
|
||||
else: # Not found in self._matched_endpoints
|
||||
if required:
|
||||
self.logger.error(f"'{self.id}': 在 _matched_endpoints 中未找到必需的操作类型 '{operation_type}'。")
|
||||
return None
|
||||
|
||||
# --- Stage Lifecycle Hooks ---
|
||||
def before_stage(self, stage_context: Dict[str, Any], global_api_spec: Optional[ParsedAPISpec] = None, api_group_name: Optional[str] = None):
|
||||
"""在阶段所有步骤执行之前调用。"""
|
||||
self.logger.debug(f"Executing before_stage for '{self.id}'")
|
||||
|
||||
def after_stage(self, stage_result: 'ExecutedStageResult', stage_context: Dict[str, Any], global_api_spec: Optional[ParsedAPISpec] = None, api_group_name: Optional[str] = None):
|
||||
"""在阶段所有步骤执行完毕后调用(无论成功、失败或错误)。"""
|
||||
self.logger.debug(f"Executing after_stage for '{self.id}'")
|
||||
|
||||
def before_step(self, step: StageStepDefinition, stage_context: Dict[str, Any], global_api_spec: Optional[ParsedAPISpec] = None, api_group_name: Optional[str] = None):
|
||||
"""在每个步骤执行之前调用。"""
|
||||
self.logger.debug(f"Executing before_step for step '{step.name}' in stage '{self.id}'")
|
||||
|
||||
def after_step(self, step: StageStepDefinition, step_result: 'ExecutedStageStepResult', stage_context: Dict[str, Any], global_api_spec: Optional[ParsedAPISpec] = None, api_group_name: Optional[str] = None):
|
||||
"""在每个步骤执行之后调用。"""
|
||||
self.logger.debug(f"Executing after_step for step '{step.name}' in stage '{self.id}'")
|
||||
|
||||
|
||||
class ExecutedStageStepResult:
|
||||
"""存储单个API测试阶段步骤执行后的结果。"""
|
||||
class Status(str, Enum):
|
||||
PASSED = "通过"
|
||||
FAILED = "失败"
|
||||
ERROR = "执行错误"
|
||||
SKIPPED = "跳过"
|
||||
PENDING = "处理中" # 新增:表示步骤正在等待或预处理
|
||||
|
||||
def __init__(self,
|
||||
step_name: str,
|
||||
status: Status,
|
||||
message: str = "",
|
||||
validation_points: Optional[List[ValidationResult]] = None,
|
||||
duration: float = 0.0,
|
||||
api_call_detail: Optional[APICallDetail] = None, # 记录此步骤的API调用详情
|
||||
extracted_outputs: Optional[Dict[str, Any]] = None,
|
||||
description: Optional[str] = None, # <--- 添加 description
|
||||
lookup_key: Optional[Union[str, Dict[str, str]]] = None, # <--- 添加 lookup_key
|
||||
resolved_endpoint: Optional[str] = None, # <--- 添加 resolved_endpoint
|
||||
request_details: Optional[Dict[str, Any]] = None, # <--- 添加 request_details
|
||||
context_after_step: Optional[Dict[str, Any]] = None # <--- 添加 context_after_step
|
||||
):
|
||||
self.step_name = step_name
|
||||
self.status = status
|
||||
self.message = message
|
||||
self.validation_points = validation_points or []
|
||||
self.duration = duration
|
||||
self.timestamp = time.time()
|
||||
self.api_call_detail = api_call_detail
|
||||
self.extracted_outputs = extracted_outputs or {}
|
||||
self.description = description # <--- 设置属性
|
||||
self.lookup_key = lookup_key # <--- 设置属性
|
||||
self.resolved_endpoint = resolved_endpoint # <--- 设置属性
|
||||
self.request_details = request_details # <--- 设置属性
|
||||
self.context_after_step = context_after_step # <--- 设置属性
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
vps_details = []
|
||||
if self.validation_points:
|
||||
for vp in self.validation_points:
|
||||
if vp.details and isinstance(vp.details, dict):
|
||||
# 尝试序列化 details,如果包含复杂对象
|
||||
try:
|
||||
# 只取部分关键信息或确保可序列化
|
||||
serializable_details = {"passed": vp.passed, "message": vp.message}
|
||||
if "status_code" in vp.details: serializable_details["status_code"] = vp.details["status_code"]
|
||||
# 不直接序列化整个 response body 以免过大
|
||||
vps_details.append(serializable_details)
|
||||
except TypeError:
|
||||
vps_details.append({"passed": vp.passed, "message": f"{vp.message} (Details not serializable)"})
|
||||
else:
|
||||
vps_details.append({"passed": vp.passed, "message": vp.message})
|
||||
|
||||
|
||||
return {
|
||||
"step_name": self.step_name,
|
||||
"description": self.description, # <--- 添加到输出
|
||||
"lookup_key": self.lookup_key if isinstance(self.lookup_key, str) else str(self.lookup_key), # <--- 添加到输出 (确保字符串化)
|
||||
"resolved_endpoint": self.resolved_endpoint, # <--- 添加到输出
|
||||
"status": self.status.value,
|
||||
"message": self.message or "; ".join([vp.message for vp in self.validation_points if not vp.passed]),
|
||||
"duration_seconds": f"{self.duration:.4f}",
|
||||
"timestamp": time.strftime('%Y-%m-%dT%H:%M:%S%z', time.localtime(self.timestamp)),
|
||||
"validation_points": vps_details,
|
||||
"api_call_curl": self.api_call_detail.curl_command if self.api_call_detail else None,
|
||||
"request_details": self.request_details, # <--- 添加到输出
|
||||
"extracted_outputs": {k: str(v)[:200] + '...' if isinstance(v, (str, bytes)) and len(v) > 200 else v
|
||||
for k, v in self.extracted_outputs.items()},
|
||||
"context_after_step_summary": {k: str(v)[:50] + '...' if isinstance(v, str) and len(v) > 50 else (type(v).__name__ if not isinstance(v, (str, int, float, bool, list, dict)) else v) for k,v in (self.context_after_step or {}).items()} # <--- 添加到输出 (摘要)
|
||||
}
|
||||
|
||||
|
||||
class ExecutedStageResult:
|
||||
"""存储整个API测试阶段执行后的结果。"""
|
||||
class Status(str, Enum):
|
||||
PASSED = "通过"
|
||||
FAILED = "失败"
|
||||
SKIPPED = "跳过" # 如果整个阶段因is_applicable_to_api_group返回False或其他原因被跳过
|
||||
PENDING = "处理中" # 新增状态:表示阶段正在处理中
|
||||
ERROR = "执行错误" # <--- 新增 ERROR 状态
|
||||
|
||||
def __init__(self,
|
||||
stage_id: str,
|
||||
stage_name: str,
|
||||
api_group_metadata: Optional[Dict[str, Any]] = None,
|
||||
description: Optional[str] = None): # <--- 添加 description 参数
|
||||
self.stage_id = stage_id
|
||||
self.stage_name = stage_name
|
||||
self.description = description # <--- 存储 description
|
||||
self.api_group_metadata = api_group_metadata or {}
|
||||
self.overall_status: ExecutedStageResult.Status = ExecutedStageResult.Status.PENDING # 默认为 PENDING
|
||||
self.executed_steps: List[ExecutedStageStepResult] = []
|
||||
self.start_time: float = time.time()
|
||||
self.end_time: Optional[float] = None
|
||||
self.duration: float = 0.0
|
||||
self.message: str = "" # 整个阶段的总结性消息,例如跳过原因或关键失败点
|
||||
self.final_stage_context: Optional[Dict[str, Any]] = None # 最终的 stage_context 内容 (敏感数据需谨慎处理)
|
||||
|
||||
def add_step_result(self, step_result: ExecutedStageStepResult):
|
||||
self.executed_steps.append(step_result)
|
||||
|
||||
def finalize_stage_result(self, final_context: Optional[Dict[str, Any]] = None):
|
||||
self.end_time = time.time()
|
||||
self.duration = self.end_time - self.start_time
|
||||
self.final_stage_context = final_context
|
||||
|
||||
if not self.executed_steps and self.overall_status == ExecutedStageResult.Status.SKIPPED:
|
||||
# 如果没有执行任何步骤且状态是初始的 SKIPPED,则保持
|
||||
if not self.message: self.message = "此阶段没有执行任何步骤,被跳过。"
|
||||
elif any(step.status == ExecutedStageStepResult.Status.ERROR for step in self.executed_steps):
|
||||
self.overall_status = ExecutedStageResult.Status.FAILED # 步骤执行错误导致阶段失败
|
||||
if not self.message: self.message = "一个或多个步骤执行时发生内部错误。"
|
||||
elif any(step.status == ExecutedStageStepResult.Status.FAILED for step in self.executed_steps):
|
||||
self.overall_status = ExecutedStageResult.Status.FAILED
|
||||
if not self.message: self.message = "一个或多个步骤验证失败。"
|
||||
elif all(step.status == ExecutedStageStepResult.Status.SKIPPED for step in self.executed_steps) and self.executed_steps:
|
||||
self.overall_status = ExecutedStageResult.Status.SKIPPED # 所有步骤都跳过了
|
||||
if not self.message: self.message = "所有步骤均被跳过。"
|
||||
elif all(step.status == ExecutedStageStepResult.Status.PASSED or step.status == ExecutedStageStepResult.Status.SKIPPED for step in self.executed_steps) and \
|
||||
any(step.status == ExecutedStageStepResult.Status.PASSED for step in self.executed_steps) :
|
||||
self.overall_status = ExecutedStageResult.Status.PASSED # 至少一个通过,其他是跳过或通过
|
||||
if not self.message: self.message = "阶段执行成功。"
|
||||
else: # 其他情况,例如没有步骤但状态不是 SKIPPED (不应发生),或者混合状态未被明确处理
|
||||
if self.executed_steps: # 如果有步骤,但没有明确成功或失败
|
||||
self.overall_status = ExecutedStageResult.Status.FAILED
|
||||
self.message = self.message or "阶段执行结果不明确,默认标记为失败。"
|
||||
# else: 状态保持为初始的 SKIPPED,message也应该在之前设置了
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
# 对 final_stage_context 进行处理,避免过大或敏感信息直接输出
|
||||
processed_context = {}
|
||||
if self.final_stage_context:
|
||||
for k, v in self.final_stage_context.items():
|
||||
if isinstance(v, (str, bytes)) and len(v) > 200: # 截断长字符串
|
||||
processed_context[k] = str(v)[:200] + '...'
|
||||
elif isinstance(v, (dict, list)): # 对于字典和列表,只显示键或少量元素
|
||||
processed_context[k] = f"Type: {type(v).__name__}, Keys/Count: {len(v)}"
|
||||
else:
|
||||
processed_context[k] = v
|
||||
|
||||
return {
|
||||
"stage_id": self.stage_id,
|
||||
"stage_name": self.stage_name,
|
||||
"description": self.description, # <--- 添加 description 到输出
|
||||
"api_group_name": self.api_group_metadata.get("name", "N/A"),
|
||||
"overall_status": self.overall_status.value,
|
||||
"duration_seconds": f"{self.duration:.2f}",
|
||||
"start_time": time.strftime('%Y-%m-%dT%H:%M:%S%z', time.localtime(self.start_time)),
|
||||
"end_time": time.strftime('%Y-%m-%dT%H:%M:%S%z', time.localtime(self.end_time)) if self.end_time else None,
|
||||
"message": self.message,
|
||||
"executed_steps_count": len(self.executed_steps),
|
||||
"executed_steps": [step.to_dict() for step in self.executed_steps],
|
||||
# "final_stage_context_summary": processed_context # 可选: 输出处理后的上下文摘要
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
API 测试阶段 (Stage) 注册表模块
|
||||
"""
|
||||
import os
|
||||
import importlib.util
|
||||
import inspect
|
||||
import logging
|
||||
from typing import List, Type, Dict, Optional
|
||||
|
||||
from .stage_framework import BaseAPIStage # 导入新的 BaseAPIStage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class StageRegistry:
|
||||
"""
|
||||
负责发现、加载和管理 BaseAPIStage 子类。
|
||||
"""
|
||||
def __init__(self, stages_dir: Optional[str] = None):
|
||||
"""
|
||||
初始化 StageRegistry。
|
||||
Args:
|
||||
stages_dir: 存放自定义 BaseAPIStage Python文件的目录路径。
|
||||
如果为 None 或无效路径,则不会加载任何自定义阶段。
|
||||
"""
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.stages_dir = stages_dir
|
||||
self._stages: Dict[str, Type[BaseAPIStage]] = {}
|
||||
self._errors: List[str] = []
|
||||
|
||||
if self.stages_dir and os.path.isdir(self.stages_dir):
|
||||
self.logger.info(f"StageRegistry: 开始从目录 '{self.stages_dir}' 加载测试阶段...")
|
||||
self._discover_and_load_stages()
|
||||
if self._errors:
|
||||
for error in self._errors:
|
||||
self.logger.error(f"StageRegistry: 加载阶段时发生错误: {error}")
|
||||
self.logger.info(f"StageRegistry: 加载完成。共加载 {len(self._stages)} 个测试阶段。")
|
||||
elif stages_dir: # 如果提供了stages_dir但不是有效目录
|
||||
self.logger.warning(f"StageRegistry: 提供的阶段目录 '{stages_dir}' 无效或不存在。将不会加载任何自定义阶段。")
|
||||
else: # 如果 stages_dir 未提供
|
||||
self.logger.info("StageRegistry: 未提供阶段目录,将不会加载任何自定义阶段。")
|
||||
|
||||
def _discover_and_load_stages(self):
|
||||
"""发现并加载指定目录下的所有 BaseAPIStage 子类。"""
|
||||
if not self.stages_dir or not os.path.isdir(self.stages_dir):
|
||||
self.logger.warning(f"StageRegistry: 阶段目录 '{self.stages_dir}' 无效或不存在,无法发现阶段。")
|
||||
return
|
||||
|
||||
self.logger.info(f"StageRegistry: 开始从目录 '{self.stages_dir}' 及其子目录发现测试阶段...")
|
||||
found_count = 0
|
||||
# 使用 os.walk 进行递归扫描
|
||||
for root_dir, _, files in os.walk(self.stages_dir):
|
||||
for filename in files:
|
||||
if filename.endswith(".py") and not filename.startswith("__"):
|
||||
module_name = filename[:-3]
|
||||
module_path = os.path.join(root_dir, filename) # 使用 root_dir
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
||||
if spec and spec.loader:
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
self.logger.debug(f"StageRegistry: 成功导入模块: {module_name} 从 {module_path}")
|
||||
for name, cls in inspect.getmembers(module, inspect.isclass):
|
||||
if issubclass(cls, BaseAPIStage) and cls is not BaseAPIStage:
|
||||
if not hasattr(cls, 'id') or not cls.id: # 检查id属性是否存在且不为空
|
||||
stage_id = cls.__name__
|
||||
self.logger.warning(f"测试阶段类 '{cls.__name__}' (在模块 '{module_name}' 来自路径 '{module_path}') 未定义有效 'id' 属性,将使用类名 '{stage_id}' 作为其ID。建议为每个阶段设置唯一的 'id'。")
|
||||
else:
|
||||
stage_id = cls.id
|
||||
|
||||
if stage_id in self._stages:
|
||||
self.logger.warning(f"重复的测试阶段ID '{stage_id}' (来自类 '{cls.__name__}' 在模块 '{module_name}' 来自路径 '{module_path}')。之前的定义将被覆盖。请确保阶段ID唯一。")
|
||||
self._stages[stage_id] = cls
|
||||
found_count +=1
|
||||
self.logger.info(f"StageRegistry: 已注册测试阶段: '{stage_id}' (类: {cls.__name__}) 从模块 '{module_name}' (路径: {module_path})")
|
||||
else:
|
||||
self._errors.append(f"无法为文件 '{module_path}' 创建模块规范。")
|
||||
self.logger.error(f"StageRegistry: 无法为文件 '{module_path}' 创建模块规范。")
|
||||
except ImportError as e:
|
||||
error_msg = f"导入模块 '{module_name}' (从 '{module_path}') 失败: {e}"
|
||||
self._errors.append(error_msg)
|
||||
self.logger.error(f"StageRegistry: {error_msg}", exc_info=True)
|
||||
except Exception as e:
|
||||
error_msg = f"加载或检查模块 '{module_name}' (从 '{module_path}') 时发生未知错误: {e}"
|
||||
self._errors.append(error_msg)
|
||||
self.logger.error(f"StageRegistry: {error_msg}", exc_info=True)
|
||||
|
||||
if found_count == 0 and not self._errors:
|
||||
self.logger.info(f"StageRegistry: 在 '{self.stages_dir}' 及其子目录中未找到符合条件的测试阶段文件或类。")
|
||||
elif self._errors:
|
||||
self.logger.warning(f"StageRegistry: 测试阶段发现过程中遇到 {len(self._errors)} 个错误。请检查日志。")
|
||||
|
||||
# 注意:StageRegistry 目前没有像 TestCaseRegistry 那样的排序逻辑,
|
||||
# 如果需要按特定顺序执行 Stages (独立于 API group),未来可以添加。
|
||||
|
||||
def get_stage_class_by_id(self, stage_id: str) -> Optional[Type[BaseAPIStage]]:
|
||||
"""根据ID获取已注册的测试阶段类。"""
|
||||
return self._stages.get(stage_id)
|
||||
|
||||
def get_all_stage_classes(self) -> List[Type[BaseAPIStage]]:
|
||||
"""获取所有已注册的测试阶段类的列表。"""
|
||||
return list(self._stages.values())
|
||||
|
||||
def get_load_errors(self) -> List[str]:
|
||||
"""获取加载过程中发生的错误信息列表。"""
|
||||
return self._errors
|
||||
|
||||
def reload_stages(self):
|
||||
"""
|
||||
重新加载所有测试阶段。会清空当前已加载的阶段和错误记录。
|
||||
"""
|
||||
self.logger.info(f"StageRegistry: 正在从目录 '{self.stages_dir}' 重新加载所有测试阶段...")
|
||||
self._stages.clear()
|
||||
self._errors.clear()
|
||||
if self.stages_dir and os.path.isdir(self.stages_dir):
|
||||
self._discover_and_load_stages()
|
||||
if self._errors:
|
||||
for error in self._errors:
|
||||
self.logger.error(f"StageRegistry (重载时): 加载阶段时发生错误: {error}")
|
||||
self.logger.info(f"StageRegistry: 重新加载完成。共加载 {len(self._stages)} 个测试阶段。")
|
||||
elif self.stages_dir:
|
||||
self.logger.warning(f"StageRegistry (重载时): 提供的阶段目录 '{self.stages_dir}' 无效或不存在。没有加载任何自定义阶段。")
|
||||
else:
|
||||
self.logger.info("StageRegistry (重载时): 未配置阶段目录,没有加载任何自定义阶段。")
|
||||
@@ -18,6 +18,14 @@ class ValidationResult:
|
||||
self.message = message # 验证结果的描述信息
|
||||
self.details = details or {} # 其他详细信息,如实际值、期望值等
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""将 ValidationResult 对象转换为字典。"""
|
||||
return {
|
||||
"passed": self.passed,
|
||||
"message": self.message,
|
||||
"details": self.details
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f"ValidationResult(passed={self.passed}, message='{self.message}')"
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ from pydantic import BaseModel, Field, create_model, HttpUrl # Added HttpUrl for
|
||||
from pydantic.networks import EmailStr
|
||||
from pydantic.types import Literal # Explicitly import Literal
|
||||
|
||||
from .input_parser.parser import InputParser, YAPIEndpoint, SwaggerEndpoint, ParsedYAPISpec, ParsedSwaggerSpec
|
||||
from .input_parser.parser import InputParser, YAPIEndpoint, SwaggerEndpoint, ParsedYAPISpec, ParsedSwaggerSpec, ParsedAPISpec
|
||||
from .api_caller.caller import APICaller, APIRequest, APIResponse, APICallDetail # Ensure APICallDetail is imported
|
||||
from .json_schema_validator.validator import JSONSchemaValidator
|
||||
from .test_framework_core import ValidationResult, TestSeverity, APIRequestContext, APIResponseContext, BaseAPITestCase
|
||||
@@ -29,6 +29,12 @@ from .test_case_registry import TestCaseRegistry
|
||||
from .utils import schema_utils
|
||||
from .utils.common_utils import format_url_with_path_params
|
||||
|
||||
# 新增导入
|
||||
from .stage_framework import BaseAPIStage, ExecutedStageResult, ExecutedStageStepResult, StageStepDefinition
|
||||
from .stage_registry import StageRegistry
|
||||
from .scenario_framework import BaseAPIScenario # ScenarioRegistry was incorrectly imported from here
|
||||
from .scenario_registry import ScenarioRegistry # Corrected import for ScenarioRegistry
|
||||
|
||||
try:
|
||||
from .llm_utils.llm_service import LLMService
|
||||
except ImportError:
|
||||
@@ -184,6 +190,14 @@ class TestSummary:
|
||||
self.test_cases_error: int = 0 # 测试用例代码本身出错
|
||||
self.test_cases_skipped_in_endpoint: int = 0 # 测试用例在端点执行中被跳过
|
||||
|
||||
# 新增:场景测试统计 -> 修改为 Stage 统计
|
||||
self.total_stages_defined: int = 0
|
||||
self.total_stages_executed: int = 0
|
||||
self.stages_passed: int = 0
|
||||
self.stages_failed: int = 0
|
||||
self.stages_skipped: int = 0 # 如果 Stage 因为 is_applicable 返回 False 或其他原因被跳过
|
||||
self.detailed_stage_results: List[ExecutedStageResult] = []
|
||||
|
||||
self.start_time = datetime.datetime.now()
|
||||
self.end_time: Optional[datetime.datetime] = None
|
||||
self.detailed_results: List[TestResult] = [] # 将存储新的 TestResult (EndpointExecutionResult) 对象
|
||||
@@ -223,6 +237,24 @@ class TestSummary:
|
||||
def set_total_test_cases_applicable(self, count: int):
|
||||
self.total_test_cases_applicable = count
|
||||
|
||||
# 新增:用于场景统计的方法 -> 修改为 Stage 统计方法
|
||||
def add_stage_result(self, result: ExecutedStageResult):
|
||||
self.detailed_stage_results.append(result)
|
||||
# 只有实际执行的 Stage 才会计入 executed 计数器
|
||||
# 如果一个Stage因为is_applicable=False而被跳过,它的executed_steps会是空的
|
||||
if result.overall_status != ExecutedStageResult.Status.SKIPPED or result.executed_steps:
|
||||
self.total_stages_executed += 1
|
||||
|
||||
if result.overall_status == ExecutedStageResult.Status.PASSED:
|
||||
self.stages_passed += 1
|
||||
elif result.overall_status == ExecutedStageResult.Status.FAILED:
|
||||
self.stages_failed += 1
|
||||
elif result.overall_status == ExecutedStageResult.Status.SKIPPED:
|
||||
self.stages_skipped +=1
|
||||
|
||||
def set_total_stages_defined(self, count: int):
|
||||
self.total_stages_defined = count
|
||||
|
||||
def finalize_summary(self):
|
||||
self.end_time = datetime.datetime.now()
|
||||
|
||||
@@ -246,7 +278,7 @@ class TestSummary:
|
||||
return (self.test_cases_passed / self.total_test_cases_executed) * 100
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
data = {
|
||||
"summary_metadata": {
|
||||
"start_time": self.start_time.isoformat(),
|
||||
"end_time": self.end_time.isoformat() if self.end_time else None,
|
||||
@@ -274,6 +306,17 @@ class TestSummary:
|
||||
},
|
||||
"detailed_results": [result.to_dict() for result in self.detailed_results]
|
||||
}
|
||||
# 新增:将场景测试结果添加到字典 -> 修改为 Stage 测试结果
|
||||
data["stage_stats"] = {
|
||||
"total_defined": self.total_stages_defined,
|
||||
"total_executed": self.total_stages_executed,
|
||||
"passed": self.stages_passed,
|
||||
"failed": self.stages_failed,
|
||||
"skipped": self.stages_skipped,
|
||||
"success_rate_percentage": f"{(self.stages_passed / self.total_stages_executed * 100) if self.total_stages_executed > 0 else 0:.2f}"
|
||||
}
|
||||
data["detailed_stage_results"] = [res.to_dict() for res in self.detailed_stage_results]
|
||||
return data
|
||||
|
||||
def to_json(self, pretty=True) -> str:
|
||||
indent = 2 if pretty else None
|
||||
@@ -318,13 +361,45 @@ class TestSummary:
|
||||
for vp in tc_res.validation_points:
|
||||
if not vp.passed:
|
||||
print(f" - 验证点: {vp.message}")
|
||||
|
||||
# 新增:打印场景测试摘要 -> 修改为 Stage 测试摘要
|
||||
if self.total_stages_defined > 0 or self.total_stages_executed > 0:
|
||||
print("\n--- API测试阶段 (Stage) 统计 ---")
|
||||
print(f"定义的API阶段总数: {self.total_stages_defined}")
|
||||
print(f"实际执行的API阶段数: {self.total_stages_executed}")
|
||||
print(f" 通过: {self.stages_passed}")
|
||||
print(f" 失败: {self.stages_failed}")
|
||||
print(f" 跳过: {self.stages_skipped}")
|
||||
if self.total_stages_executed > 0:
|
||||
print(f" 阶段通过率: {(self.stages_passed / self.total_stages_executed * 100):.2f}%")
|
||||
|
||||
failed_stages = [res for res in self.detailed_stage_results if res.overall_status == ExecutedStageResult.Status.FAILED]
|
||||
if failed_stages:
|
||||
print("\n--- 失败的API阶段摘要 ---")
|
||||
for st_res in failed_stages:
|
||||
print(f" 阶段: {st_res.stage_id} ({st_res.stage_name}) - 应用于分组: '{st_res.api_group_metadata.get('name', 'N/A')}' - 状态: {st_res.overall_status.value}")
|
||||
for step_res in st_res.executed_steps:
|
||||
if step_res.status == ExecutedStageStepResult.Status.FAILED:
|
||||
print(f" - 步骤失败: {step_res.step_name} - 消息: {step_res.message}")
|
||||
elif step_res.status == ExecutedStageStepResult.Status.ERROR:
|
||||
print(f" - 步骤错误: {step_res.step_name} - 消息: {step_res.message}")
|
||||
|
||||
class APITestOrchestrator:
|
||||
"""
|
||||
测试编排器,负责加载API定义、发现和执行测试用例、生成报告等。
|
||||
测试编排器,负责协调整个API测试流程。
|
||||
包括:
|
||||
1. 解析API定义 (YAPI, Swagger)
|
||||
2. 加载自定义测试用例 (BaseAPITestCase)
|
||||
3. 执行测试用例并收集结果
|
||||
4. 加载和执行API场景 (BaseAPIScenario) - 已实现
|
||||
5. 加载和执行API测试阶段 (BaseAPIStage) - 新增
|
||||
6. 生成测试报告和API调用详情
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str,
|
||||
custom_test_cases_dir: Optional[str] = None,
|
||||
scenarios_dir: Optional[str] = None, # Keep existing scenarios_dir
|
||||
stages_dir: Optional[str] = None, # New: Directory for stages
|
||||
llm_api_key: Optional[str] = None,
|
||||
llm_base_url: Optional[str] = None,
|
||||
llm_model_name: Optional[str] = None,
|
||||
@@ -332,19 +407,53 @@ class APITestOrchestrator:
|
||||
use_llm_for_path_params: bool = False,
|
||||
use_llm_for_query_params: bool = False,
|
||||
use_llm_for_headers: bool = False,
|
||||
output_dir: Optional[str] = None # output_dir is now optional and not used for saving API call details internally
|
||||
output_dir: Optional[str] = None
|
||||
):
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.parser = InputParser()
|
||||
self.api_caller = APICaller()
|
||||
self.schema_validator = JSONSchemaValidator()
|
||||
self.test_case_registry = TestCaseRegistry(custom_test_cases_dir)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
# self.output_dir is kept if other parts of the orchestrator might use it,
|
||||
# but it's no longer used by the removed _save_api_call_details
|
||||
self.output_dir_param = output_dir
|
||||
self.parser = InputParser() # Initialize the parser
|
||||
self.api_caller = APICaller() # APICaller does not take base_url in __init__
|
||||
self.json_validator = JSONSchemaValidator()
|
||||
|
||||
self.test_case_registry: Optional[TestCaseRegistry] = None # Initialize as Optional
|
||||
if custom_test_cases_dir:
|
||||
self.logger.info(f"正在从目录加载自定义测试用例: {custom_test_cases_dir}")
|
||||
try:
|
||||
self.test_case_registry = TestCaseRegistry(test_cases_dir=custom_test_cases_dir)
|
||||
self.logger.info(f"加载了 {len(self.test_case_registry.get_all_test_case_classes())} 个自定义测试用例。")
|
||||
except Exception as e:
|
||||
self.logger.error(f"初始化 TestCaseRegistry 或加载测试用例失败: {e}", exc_info=True)
|
||||
self.test_case_registry = None #确保在出错时 registry 为 None
|
||||
else:
|
||||
self.logger.info("未提供自定义测试用例目录,跳过加载自定义测试用例。")
|
||||
|
||||
self.api_call_details_log: List[APICallDetail] = []
|
||||
# Scenario Registry (existing)
|
||||
self.scenario_registry: Optional[ScenarioRegistry] = None # Initialize as Optional
|
||||
if scenarios_dir:
|
||||
self.logger.info(f"正在从目录加载API场景: {scenarios_dir}")
|
||||
self.scenario_registry = ScenarioRegistry()
|
||||
self.scenario_registry.discover_and_load_scenarios(scenarios_dir)
|
||||
self.logger.info(f"加载了 {len(self.scenario_registry.get_all_scenario_classes())} 个API场景。")
|
||||
else:
|
||||
self.logger.info("未提供API场景目录,跳过加载场景测试。")
|
||||
|
||||
# Stage Registry (New)
|
||||
self.stage_registry: Optional[StageRegistry] = None
|
||||
if stages_dir:
|
||||
self.logger.info(f"APITestOrchestrator: 尝试从目录加载API测试阶段: {stages_dir}")
|
||||
try:
|
||||
self.stage_registry = StageRegistry(stages_dir=stages_dir) # Pass stages_dir to constructor
|
||||
# Discovery now happens within StageRegistry.__init__
|
||||
self.logger.info(f"APITestOrchestrator: StageRegistry 初始化完毕。加载的API测试阶段数量: {len(self.stage_registry.get_all_stage_classes())}")
|
||||
load_errors = self.stage_registry.get_load_errors()
|
||||
if load_errors:
|
||||
for err in load_errors:
|
||||
self.logger.error(f"APITestOrchestrator: StageRegistry 加载错误: {err}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"APITestOrchestrator: 初始化 StageRegistry 时发生错误: {e}", exc_info=True)
|
||||
self.stage_registry = None # 确保出错时为 None
|
||||
else:
|
||||
self.logger.info("APITestOrchestrator: 未提供API测试阶段目录,跳过加载阶段测试。")
|
||||
|
||||
# LLM Service Initialization
|
||||
self.llm_service: Optional[LLMService] = None
|
||||
@@ -844,7 +953,7 @@ class APITestOrchestrator:
|
||||
test_case_instance = test_case_class(
|
||||
endpoint_spec=endpoint_spec_dict,
|
||||
global_api_spec=global_spec_dict,
|
||||
json_schema_validator=self.schema_validator,
|
||||
json_schema_validator=self.json_validator,
|
||||
llm_service=self.llm_service # Pass the orchestrator's LLM service instance
|
||||
)
|
||||
self.logger.info(f"开始执行测试用例 '{test_case_instance.id}' ({test_case_instance.name}) for endpoint '{endpoint_spec_dict.get('method', 'N/A')} {endpoint_spec_dict.get('path', 'N/A')}'")
|
||||
@@ -1429,100 +1538,52 @@ class APITestOrchestrator:
|
||||
def run_tests_from_yapi(self, yapi_file_path: str,
|
||||
categories: Optional[List[str]] = None,
|
||||
custom_test_cases_dir: Optional[str] = None
|
||||
) -> TestSummary:
|
||||
if custom_test_cases_dir and (not self.test_case_registry or self.test_case_registry.test_cases_dir != custom_test_cases_dir):
|
||||
self.logger.info(f"从 run_tests_from_yapi 使用新的目录重新初始化 TestCaseRegistry: {custom_test_cases_dir}")
|
||||
try:
|
||||
self.test_case_registry = TestCaseRegistry(test_cases_dir=custom_test_cases_dir)
|
||||
self.logger.info(f"TestCaseRegistry (re)initialization complete, found {len(self.test_case_registry.get_all_test_case_classes())} test case classes.")
|
||||
except Exception as e:
|
||||
self.logger.error(f"从 run_tests_from_yapi 重新初始化 TestCaseRegistry 失败: {e}", exc_info=True)
|
||||
) -> Tuple[TestSummary, Optional[ParsedAPISpec]]:
|
||||
self.logger.info(f"准备从YAPI文件运行测试用例: {yapi_file_path}")
|
||||
self.api_call_details_log = [] # 为新的测试用例运行重置API调用日志
|
||||
|
||||
self.logger.info(f"从YAPI文件加载API定义: {yapi_file_path}")
|
||||
self.api_call_details_log = [] # Reset for new run
|
||||
|
||||
parsed_yapi = self.parser.parse_yapi_spec(yapi_file_path) # Corrected: self.parser
|
||||
parsed_yapi = self.parser.parse_yapi_spec(yapi_file_path)
|
||||
summary = TestSummary()
|
||||
|
||||
if not parsed_yapi:
|
||||
self.logger.error(f"解析YAPI文件失败: {yapi_file_path}")
|
||||
summary.finalize_summary()
|
||||
# No longer calls _save_api_call_details here
|
||||
return summary
|
||||
summary.finalize_summary() # 即使失败也最终化摘要
|
||||
return summary, None
|
||||
|
||||
endpoints_to_test = parsed_yapi.endpoints
|
||||
if categories:
|
||||
endpoints_to_test = [ep for ep in endpoints_to_test if ep.category_name in categories]
|
||||
|
||||
summary.set_total_endpoints_defined(len(endpoints_to_test))
|
||||
|
||||
total_applicable_tcs = 0
|
||||
if self.test_case_registry:
|
||||
for endpoint_spec in endpoints_to_test:
|
||||
total_applicable_tcs += len(
|
||||
self.test_case_registry.get_applicable_test_cases(
|
||||
endpoint_spec.method.upper(), endpoint_spec.path
|
||||
)
|
||||
)
|
||||
summary.set_total_test_cases_applicable(total_applicable_tcs)
|
||||
|
||||
for endpoint in endpoints_to_test:
|
||||
result = self.run_test_for_endpoint(endpoint, global_api_spec=parsed_yapi)
|
||||
summary.add_endpoint_result(result)
|
||||
|
||||
summary.finalize_summary()
|
||||
# No longer calls _save_api_call_details here
|
||||
summary.print_summary_to_console() # Keep console print
|
||||
return summary
|
||||
# 调用内部执行方法来执行测试用例
|
||||
self._execute_tests_from_parsed_spec(
|
||||
parsed_spec=parsed_yapi,
|
||||
summary=summary,
|
||||
categories=categories,
|
||||
custom_test_cases_dir=custom_test_cases_dir
|
||||
)
|
||||
# finalize_summary 和 print_summary_to_console 将在 run_api_tests.py 中进行
|
||||
return summary, parsed_yapi
|
||||
|
||||
def run_tests_from_swagger(self, swagger_file_path: str,
|
||||
tags: Optional[List[str]] = None,
|
||||
custom_test_cases_dir: Optional[str] = None
|
||||
) -> TestSummary:
|
||||
if custom_test_cases_dir and (not self.test_case_registry or self.test_case_registry.test_cases_dir != custom_test_cases_dir):
|
||||
self.logger.info(f"从 run_tests_from_swagger 使用新的目录重新初始化 TestCaseRegistry: {custom_test_cases_dir}")
|
||||
try:
|
||||
self.test_case_registry = TestCaseRegistry(test_cases_dir=custom_test_cases_dir)
|
||||
self.logger.info(f"TestCaseRegistry (re)initialization complete, found {len(self.test_case_registry.get_all_test_case_classes())} test case classes.")
|
||||
except Exception as e:
|
||||
self.logger.error(f"从 run_tests_from_swagger 重新初始化 TestCaseRegistry 失败: {e}", exc_info=True)
|
||||
) -> Tuple[TestSummary, Optional[ParsedAPISpec]]:
|
||||
self.logger.info(f"准备从Swagger文件运行测试用例: {swagger_file_path}")
|
||||
self.api_call_details_log = [] # 为新的测试用例运行重置API调用日志
|
||||
|
||||
self.logger.info(f"从Swagger文件加载API定义: {swagger_file_path}")
|
||||
self.api_call_details_log = [] # Reset for new run
|
||||
|
||||
parsed_swagger = self.parser.parse_swagger_spec(swagger_file_path) # Corrected: self.parser
|
||||
parsed_swagger = self.parser.parse_swagger_spec(swagger_file_path)
|
||||
summary = TestSummary()
|
||||
|
||||
if not parsed_swagger:
|
||||
self.logger.error(f"解析Swagger文件失败: {swagger_file_path}")
|
||||
summary.finalize_summary()
|
||||
# No longer calls _save_api_call_details here
|
||||
return summary
|
||||
return summary, None
|
||||
|
||||
endpoints_to_test = parsed_swagger.endpoints
|
||||
if tags:
|
||||
endpoints_to_test = [ep for ep in endpoints_to_test if any(tag in ep.tags for tag in tags)]
|
||||
|
||||
summary.set_total_endpoints_defined(len(endpoints_to_test))
|
||||
|
||||
total_applicable_tcs = 0
|
||||
if self.test_case_registry:
|
||||
for endpoint_spec in endpoints_to_test:
|
||||
total_applicable_tcs += len(
|
||||
self.test_case_registry.get_applicable_test_cases(
|
||||
endpoint_spec.method.upper(), endpoint_spec.path
|
||||
)
|
||||
)
|
||||
summary.set_total_test_cases_applicable(total_applicable_tcs)
|
||||
|
||||
for endpoint in endpoints_to_test:
|
||||
result = self.run_test_for_endpoint(endpoint, global_api_spec=parsed_swagger)
|
||||
summary.add_endpoint_result(result)
|
||||
|
||||
summary.finalize_summary()
|
||||
# No longer calls _save_api_call_details here
|
||||
summary.print_summary_to_console() # Keep console print
|
||||
return summary
|
||||
# 调用内部执行方法来执行测试用例
|
||||
self._execute_tests_from_parsed_spec(
|
||||
parsed_spec=parsed_swagger,
|
||||
summary=summary,
|
||||
tags=tags,
|
||||
custom_test_cases_dir=custom_test_cases_dir
|
||||
)
|
||||
# finalize_summary 和 print_summary_to_console 将在 run_api_tests.py 中进行
|
||||
return summary, parsed_swagger
|
||||
|
||||
def _generate_data_from_schema(self, schema: Dict[str, Any],
|
||||
context_name: Optional[str] = None,
|
||||
@@ -1886,4 +1947,608 @@ class APITestOrchestrator:
|
||||
self.logger.error(f"[Util] _util_remove_value_at_path 未能在循环内按预期返回。路径: {'.'.join(map(str,path))}")
|
||||
return data_container, None, False
|
||||
|
||||
# --- 新增:场景测试执行相关方法 ---
|
||||
def _resolve_value_from_context_or_literal(self, value_template: Any, stage_context: Dict[str, Any], step_name_for_log: str) -> Any:
|
||||
"""
|
||||
解析一个值,如果它是字符串且符合 {{stage_context.变量}} 格式,则从阶段上下文中取值,否则直接返回值。
|
||||
支持从字典和列表中深入取值,例如 {{stage_context.user.id}} 或 {{stage_context.items[0].name}}。
|
||||
"""
|
||||
if isinstance(value_template, str):
|
||||
match = re.fullmatch(r"\{\{\s*stage_context\.([a-zA-Z0-9_\.\[\]]+)\s*\}\}", value_template)
|
||||
if match:
|
||||
path_expression = match.group(1)
|
||||
self.logger.debug(f"[阶段步骤 '{step_name_for_log}'] 解析上下文路径: '{path_expression}' 来自模板 '{value_template}'")
|
||||
try:
|
||||
current_value = stage_context
|
||||
parts = re.split(r'\.(?![^\[]*\])|\[|\]', path_expression)
|
||||
parts = [p for p in parts if p] # 移除空字符串
|
||||
|
||||
for part in parts:
|
||||
if isinstance(current_value, dict):
|
||||
current_value = current_value[part]
|
||||
elif isinstance(current_value, list) and part.isdigit():
|
||||
current_value = current_value[int(part)]
|
||||
else:
|
||||
raise KeyError(f"路径部分 '{part}' 无法从当前值类型 {type(current_value)} 中解析")
|
||||
self.logger.info(f"[测试阶段步骤 '{step_name_for_log}'] 从上下文解析到值 '{current_value}' (路径: '{path_expression}')")
|
||||
return current_value
|
||||
except (KeyError, IndexError, TypeError) as e:
|
||||
self.logger.error(f"[测试阶段步骤 '{step_name_for_log}'] 从阶段上下文解析路径 '{path_expression}' 失败: {e}", exc_info=True)
|
||||
return None # 或抛出错误,或返回原始模板以提示错误
|
||||
return value_template # 不是有效的占位符,返回原始字符串
|
||||
elif isinstance(value_template, list):
|
||||
return [self._resolve_value_from_context_or_literal(item, stage_context, step_name_for_log) for item in value_template]
|
||||
elif isinstance(value_template, dict):
|
||||
return {k: self._resolve_value_from_context_or_literal(v, stage_context, step_name_for_log) for k, v in value_template.items()} # Corrected scenario_context to stage_context
|
||||
else:
|
||||
return value_template # 其他类型直接返回
|
||||
|
||||
def _extract_outputs_to_context(self, response_content: Any, outputs_map: Dict[str, str], stage_context: Dict[str, Any], step_name_for_log: str):
|
||||
"""
|
||||
根据 outputs_map 从API响应中提取值并存入 stage_context。
|
||||
Args:
|
||||
response_content: API响应的内容 (通常是解析后的JSON字典)。
|
||||
outputs_map: 定义如何提取的字典,例如 {"user_id": "data.id", "token": "header.X-Auth-Token"}。
|
||||
支持 "body.", "header.", "status_code" 作为路径前缀。
|
||||
stage_context: 要更新的阶段上下文。
|
||||
step_name_for_log: 当前步骤名称,用于日志。
|
||||
"""
|
||||
if not outputs_map or response_content is None:
|
||||
return
|
||||
|
||||
for context_var_name, extraction_path in outputs_map.items():
|
||||
self.logger.debug(f"[阶段步骤 '{step_name_for_log}'] 尝试提取 '{extraction_path}' 到上下文变量 '{context_var_name}'")
|
||||
value_to_extract = None
|
||||
try:
|
||||
current_data = response_content
|
||||
path_parts = extraction_path.split('.')
|
||||
|
||||
source_type = path_parts[0].lower()
|
||||
actual_path_parts = path_parts[1:]
|
||||
|
||||
if source_type == "body":
|
||||
target_obj = current_data.get('json_content')
|
||||
elif source_type == "header":
|
||||
target_obj = current_data.get('headers')
|
||||
elif source_type == "status_code":
|
||||
if not actual_path_parts:
|
||||
value_to_extract = current_data.get('status_code')
|
||||
stage_context[context_var_name] = value_to_extract
|
||||
self.logger.info(f"[阶段步骤 '{step_name_for_log}'] 提取到 '{context_var_name}': {value_to_extract}")
|
||||
continue
|
||||
else:
|
||||
self.logger.warning(f"[阶段步骤 '{step_name_for_log}'] status_code 不支持进一步的路径提取: '{extraction_path}'")
|
||||
continue
|
||||
else:
|
||||
self.logger.warning(f"[阶段步骤 '{step_name_for_log}'] 未知的提取源类型 '{source_type}' in path '{extraction_path}'")
|
||||
continue
|
||||
|
||||
if target_obj is None and source_type != "status_code":
|
||||
self.logger.warning(f"[阶段步骤 '{step_name_for_log}'] 提取源 '{source_type}' 为空或不存在。")
|
||||
continue
|
||||
|
||||
temp_val = target_obj
|
||||
for part in actual_path_parts:
|
||||
if isinstance(temp_val, dict):
|
||||
temp_val = temp_val.get(part)
|
||||
elif isinstance(temp_val, list) and part.isdigit():
|
||||
idx = int(part)
|
||||
if 0 <= idx < len(temp_val):
|
||||
temp_val = temp_val[idx]
|
||||
else:
|
||||
temp_val = None; break
|
||||
else:
|
||||
temp_val = None; break
|
||||
if temp_val is None: break
|
||||
|
||||
value_to_extract = temp_val
|
||||
|
||||
if value_to_extract is not None:
|
||||
stage_context[context_var_name] = value_to_extract
|
||||
self.logger.info(f"[阶段步骤 '{step_name_for_log}'] 提取到上下文 '{context_var_name}': {str(value_to_extract)[:100]}...")
|
||||
else:
|
||||
self.logger.warning(f"[阶段步骤 '{step_name_for_log}'] 未能从路径 '{extraction_path}' 提取到值。")
|
||||
except Exception as e:
|
||||
self.logger.error(f"[阶段步骤 '{step_name_for_log}'] 从路径 '{extraction_path}' 提取值时出错: {e}", exc_info=True)
|
||||
|
||||
def execute_single_stage(self,
|
||||
stage_instance: BaseAPIStage,
|
||||
parsed_spec: ParsedAPISpec,
|
||||
api_group_name: Optional[str]
|
||||
) -> ExecutedStageResult:
|
||||
stage_start_time = datetime.datetime.now()
|
||||
stage_context: Dict[str, Any] = {}
|
||||
executed_steps_results: List[ExecutedStageStepResult] = []
|
||||
|
||||
stage_result = ExecutedStageResult(
|
||||
stage_id=stage_instance.id,
|
||||
stage_name=stage_instance.name,
|
||||
description=stage_instance.description,
|
||||
api_group_metadata={"name": api_group_name} if api_group_name else None # <--- 修改参数名并包装为字典
|
||||
# overall_status is set by default in __init__
|
||||
)
|
||||
|
||||
try:
|
||||
self.logger.debug(f"Calling before_stage for stage '{stage_instance.id}'. Context: {stage_context}")
|
||||
# Ensure all parameters passed to before_stage are accepted by its definition
|
||||
stage_instance.before_stage(stage_context=stage_context, global_api_spec=parsed_spec, api_group_name=api_group_name)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in before_stage for stage '{stage_instance.id}': {e}", exc_info=True)
|
||||
# stage_result.overall_status = ExecutedStageResult.Status.ERROR # This is already set if ERROR is present in Enum
|
||||
# The following line should correctly set the status if an error occurs in before_stage
|
||||
if hasattr(ExecutedStageResult.Status, 'ERROR'):
|
||||
stage_result.overall_status = ExecutedStageResult.Status.ERROR
|
||||
else: # Fallback if somehow ERROR is still not in the enum (should not happen now)
|
||||
stage_result.overall_status = ExecutedStageResult.Status.FAILED
|
||||
stage_result.message = f"before_stage hook failed: {e}"
|
||||
stage_result.finalize_stage_result(final_context=stage_context) # <--- 更正方法名, 移除多余参数
|
||||
return stage_result
|
||||
|
||||
# Assume PASSED, will be changed on any step failure/error not overridden by continue_on_failure logic
|
||||
# The final status is determined more comprehensively at the end.
|
||||
# stage_result.overall_status = ExecutedStageResult.Status.PASSED
|
||||
|
||||
for step_index, step_definition in enumerate(stage_instance.steps):
|
||||
step_start_time = datetime.datetime.now()
|
||||
step_name = step_definition.name or f"Step {step_index + 1}"
|
||||
step_message = ""
|
||||
step_validation_points: List[ValidationResult] = []
|
||||
|
||||
current_step_result = ExecutedStageStepResult(
|
||||
step_name=step_name,
|
||||
description=getattr(step_definition, 'description', None), # <--- 使用 getattr
|
||||
lookup_key=step_definition.endpoint_spec_lookup_key,
|
||||
status=ExecutedStageStepResult.Status.PENDING
|
||||
# 其他参数 (如 resolved_endpoint, request_details, api_call_details, context_after_step)
|
||||
# 会在步骤执行过程中或之后被填充到 current_step_result 对象上
|
||||
)
|
||||
|
||||
try:
|
||||
self.logger.debug(f"Calling before_step for stage '{stage_instance.id}', step '{step_name}'. Context: {stage_context}")
|
||||
# Corrected: 'step' instead of 'step_definition' to match method signature
|
||||
# Ensure all parameters passed are accepted by before_step definition
|
||||
stage_instance.before_step(step=step_definition, stage_context=stage_context, global_api_spec=parsed_spec, api_group_name=api_group_name)
|
||||
|
||||
self.logger.info(f"Stage '{stage_instance.id}', Step '{step_name}': Looking up endpoint key='{step_definition.endpoint_spec_lookup_key}', group='{api_group_name}'")
|
||||
api_op_spec: Optional[APIOperationSpec] = stage_instance.get_api_spec_for_operation(
|
||||
lookup_key=step_definition.endpoint_spec_lookup_key, # Pass lookup_key by name
|
||||
global_api_spec=parsed_spec, # Pass global_api_spec by name
|
||||
api_group_name=api_group_name # Pass api_group_name by name
|
||||
)
|
||||
|
||||
if not api_op_spec or not api_op_spec.spec:
|
||||
current_step_result.status = ExecutedStageStepResult.Status.ERROR
|
||||
current_step_result.message = f"Could not find API operation for key '{step_definition.endpoint_spec_lookup_key}' and group '{api_group_name}'."
|
||||
self.logger.error(f"Stage '{stage_instance.id}', Step '{step_name}': {current_step_result.message}")
|
||||
else:
|
||||
actual_endpoint_spec_dict = api_op_spec.spec
|
||||
current_step_result.resolved_endpoint = f"{api_op_spec.method.upper()} {api_op_spec.path}" if api_op_spec.method and api_op_spec.path else "Unknown Endpoint"
|
||||
current_step_result.status = ExecutedStageStepResult.Status.PASSED # Assume pass, changed on failure
|
||||
|
||||
self.logger.debug(f"Stage '{stage_instance.id}', Step '{step_name}': Preparing request data for resolved endpoint: {current_step_result.resolved_endpoint}")
|
||||
base_request_context: APIRequestContext = self._prepare_initial_request_data(actual_endpoint_spec_dict, None)
|
||||
|
||||
final_path_params = copy.deepcopy(base_request_context.path_params)
|
||||
final_query_params = copy.deepcopy(base_request_context.query_params)
|
||||
final_headers = copy.deepcopy(base_request_context.headers)
|
||||
final_body = base_request_context.body
|
||||
|
||||
# Default Content-Type for JSON body if body is overridden and Content-Type not in headers
|
||||
if "body" in step_definition.request_overrides:
|
||||
temp_resolved_body_val = self._resolve_value_from_context_or_literal(
|
||||
step_definition.request_overrides["body"], stage_context, step_name
|
||||
)
|
||||
# Check if Content-Type is already being set by header overrides
|
||||
is_content_type_in_header_override = False
|
||||
if "headers" in step_definition.request_overrides:
|
||||
resolved_header_overrides = self._resolve_value_from_context_or_literal(
|
||||
step_definition.request_overrides["headers"], stage_context, step_name
|
||||
)
|
||||
if isinstance(resolved_header_overrides, dict):
|
||||
for h_key in resolved_header_overrides.keys():
|
||||
if h_key.lower() == 'content-type':
|
||||
is_content_type_in_header_override = True; break
|
||||
|
||||
# Check Content-Type in base_request_context.headers (after _prepare_initial_request_data)
|
||||
is_content_type_in_final_headers = any(h_key.lower() == 'content-type' for h_key in final_headers.keys())
|
||||
|
||||
if isinstance(temp_resolved_body_val, (dict, list)) and not is_content_type_in_header_override and not is_content_type_in_final_headers:
|
||||
final_headers['Content-Type'] = 'application/json'
|
||||
self.logger.debug(f"Stage '{stage_instance.id}', Step '{step_name}': Defaulted Content-Type to application/json for overridden body.")
|
||||
|
||||
|
||||
for key, value_template in step_definition.request_overrides.items():
|
||||
resolved_value = self._resolve_value_from_context_or_literal(value_template, stage_context, step_name)
|
||||
if key == "path_params":
|
||||
if isinstance(resolved_value, dict): final_path_params.update(resolved_value)
|
||||
else: self.logger.warning(f"Step '{step_name}': path_params override was not a dict (type: {type(resolved_value)}).")
|
||||
elif key == "query_params":
|
||||
if isinstance(resolved_value, dict): final_query_params.update(resolved_value)
|
||||
else: self.logger.warning(f"Step '{step_name}': query_params override was not a dict (type: {type(resolved_value)}).")
|
||||
elif key == "headers":
|
||||
if isinstance(resolved_value, dict): final_headers.update(resolved_value) # Allows case-sensitive overrides if needed by server
|
||||
else: self.logger.warning(f"Step '{step_name}': headers override was not a dict (type: {type(resolved_value)}).")
|
||||
elif key == "body":
|
||||
final_body = resolved_value
|
||||
else:
|
||||
self.logger.warning(f"Stage '{stage_instance.id}', Step '{step_name}': Unknown request override key '{key}'.")
|
||||
|
||||
# 构建完整的请求 URL
|
||||
full_request_url = self._format_url_with_path_params(
|
||||
path_template=api_op_spec.path,
|
||||
path_params=final_path_params
|
||||
)
|
||||
self.logger.debug(f"Stage '{stage_instance.id}', Step '{step_name}': Constructed full_request_url: {full_request_url}")
|
||||
|
||||
api_request = APIRequest(
|
||||
method=api_op_spec.method,
|
||||
url=full_request_url, # <--- 使用构建好的完整 URL
|
||||
params=final_query_params, # <--- query_params 对应 APIRequest 中的 params
|
||||
headers=final_headers,
|
||||
body=final_body # APIRequest 会通过 model_post_init 将 body 赋给 json_data
|
||||
)
|
||||
current_step_result.request_details = api_request.model_dump() # Use model_dump for Pydantic v2
|
||||
|
||||
self.logger.info(f"Stage '{stage_instance.id}', Step '{step_name}': Executing API call {api_request.method} {api_request.url}") # Log the full URL
|
||||
api_response, api_call_detail = self.api_caller.call_api(api_request) # APICaller.call_api expects APIRequest
|
||||
current_step_result.api_call_details = api_call_detail.model_dump() # Use model_dump for Pydantic v2
|
||||
|
||||
self.logger.debug(f"Stage '{stage_instance.id}', Step '{step_name}': Validating response. Status: {api_response.status_code}")
|
||||
|
||||
# Create APIResponseContext with the *actual* request sent
|
||||
actual_request_context = APIRequestContext(
|
||||
method=api_request.method,
|
||||
url=str(api_request.url), # Pass the full URL string
|
||||
path_params=final_path_params, # Keep for context, though URL is final
|
||||
query_params=api_request.params, # query_params from APIRequest
|
||||
headers=api_request.headers,
|
||||
body=api_request.json_data, # body from APIRequest (after potential alias)
|
||||
endpoint_spec=actual_endpoint_spec_dict # Ensure endpoint_spec is passed
|
||||
# Removed full_url=str(api_call_detail.request_url) as it's redundant with 'url'
|
||||
)
|
||||
response_context = APIResponseContext(
|
||||
request_context=actual_request_context,
|
||||
status_code=api_response.status_code,
|
||||
headers=api_response.headers,
|
||||
json_content=api_response.json_content,
|
||||
text_content=api_response.content.decode('utf-8', errors='replace') if api_response.content else None,
|
||||
elapsed_time=api_response.elapsed_time,
|
||||
original_response= getattr(api_response, 'raw_response', None)
|
||||
# Removed endpoint_spec=actual_endpoint_spec_dict as it's part of actual_request_context
|
||||
)
|
||||
|
||||
if step_definition.expected_status_codes and api_response.status_code not in step_definition.expected_status_codes: # Check against list
|
||||
msg = f"Expected status code in {step_definition.expected_status_codes}, got {api_response.status_code}."
|
||||
step_validation_points.append(ValidationResult(passed=False, message=msg, details={"expected": step_definition.expected_status_codes, "actual": api_response.status_code}))
|
||||
current_step_result.status = ExecutedStageStepResult.Status.FAILED
|
||||
step_message += msg + " "
|
||||
self.logger.warning(f"Stage '{stage_instance.id}', Step '{step_name}': {msg}")
|
||||
elif step_definition.expected_status_codes and api_response.status_code in step_definition.expected_status_codes:
|
||||
step_validation_points.append(ValidationResult(passed=True, message=f"Status code matched ({api_response.status_code})."))
|
||||
|
||||
for i, assertion_func in enumerate(step_definition.response_assertions): # <--- Corrected: custom_assertions to response_assertions
|
||||
assertion_name = getattr(assertion_func, '__name__', f"custom_assertion_{i+1}")
|
||||
try:
|
||||
self.logger.debug(f"Stage '{stage_instance.id}', Step '{step_name}': Running assertion '{assertion_name}'")
|
||||
val_res = assertion_func(response_context, stage_context)
|
||||
step_validation_points.append(val_res)
|
||||
if not val_res.passed:
|
||||
current_step_result.status = ExecutedStageStepResult.Status.FAILED
|
||||
step_message += f"Assertion '{assertion_name}' failed: {val_res.message}. "
|
||||
self.logger.warning(f"Stage '{stage_instance.id}', Step '{step_name}': Assertion '{assertion_name}' failed: {val_res.message}")
|
||||
except Exception as assert_exc:
|
||||
current_step_result.status = ExecutedStageStepResult.Status.ERROR
|
||||
errMsg = f"Assertion '{assertion_name}' execution error: {assert_exc}"
|
||||
step_message += errMsg + " "
|
||||
step_validation_points.append(ValidationResult(passed=False, message=errMsg, details={"error": str(assert_exc)}))
|
||||
self.logger.error(f"Stage '{stage_instance.id}', Step '{step_name}': {errMsg}", exc_info=True)
|
||||
|
||||
if current_step_result.status != ExecutedStageStepResult.Status.ERROR:
|
||||
self.logger.debug(f"Stage '{stage_instance.id}', Step '{step_name}': Extracting outputs. Map: {step_definition.outputs_to_context}")
|
||||
response_data_for_extraction = {
|
||||
"json_content": api_response.json_content,
|
||||
"headers": api_response.headers,
|
||||
"status_code": api_response.status_code
|
||||
}
|
||||
self._extract_outputs_to_context(
|
||||
response_data_for_extraction, step_definition.outputs_to_context,
|
||||
stage_context, f"Stage '{stage_instance.id}', Step '{step_name}'"
|
||||
)
|
||||
current_step_result.context_after_step = copy.deepcopy(stage_context)
|
||||
|
||||
except Exception as step_exec_exc:
|
||||
current_step_result.status = ExecutedStageStepResult.Status.ERROR
|
||||
current_step_result.message = (current_step_result.message + f" | Unexpected error during step execution: {step_exec_exc}").strip()
|
||||
self.logger.error(f"Stage '{stage_instance.id}', Step '{step_name}': {current_step_result.message}", exc_info=True)
|
||||
|
||||
finally:
|
||||
current_step_result.duration_seconds = (datetime.datetime.now() - step_start_time).total_seconds()
|
||||
current_step_result.message = (current_step_result.message + " " + step_message).strip()
|
||||
current_step_result.validation_points = [vp.to_dict() for vp in step_validation_points]
|
||||
|
||||
try:
|
||||
self.logger.debug(f"Calling after_step for stage '{stage_instance.id}', step '{step_name}'.")
|
||||
# Corrected: Pass arguments by keyword to match signature and avoid positional errors
|
||||
stage_instance.after_step(
|
||||
step=step_definition,
|
||||
step_result=current_step_result,
|
||||
stage_context=stage_context,
|
||||
global_api_spec=parsed_spec,
|
||||
api_group_name=api_group_name
|
||||
)
|
||||
except Exception as e_as:
|
||||
self.logger.error(f"Error in after_step for stage '{stage_instance.id}', step '{step_name}': {e_as}", exc_info=True)
|
||||
if current_step_result.status == ExecutedStageStepResult.Status.PASSED:
|
||||
current_step_result.status = ExecutedStageStepResult.Status.ERROR
|
||||
current_step_result.message = (current_step_result.message + f" | after_step hook failed: {e_as}").strip()
|
||||
elif current_step_result.message: current_step_result.message += f" | after_step hook failed: {e_as}"
|
||||
else: current_step_result.message = f"after_step hook failed: {e_as}"
|
||||
|
||||
executed_steps_results.append(current_step_result)
|
||||
|
||||
if current_step_result.status != ExecutedStageStepResult.Status.PASSED:
|
||||
if not stage_instance.continue_on_failure:
|
||||
self.logger.warning(f"Stage '{stage_instance.id}', Step '{step_name}' status {current_step_result.status.value}, continue_on_failure=False. Aborting stage.")
|
||||
# Update stage_result's overall status and message pre-emptively if aborting
|
||||
if current_step_result.status == ExecutedStageStepResult.Status.ERROR:
|
||||
stage_result.overall_status = ExecutedStageResult.Status.ERROR
|
||||
stage_result.message = stage_result.message or f"Stage aborted due to error in step '{step_name}'."
|
||||
elif current_step_result.status == ExecutedStageStepResult.Status.FAILED and stage_result.overall_status != ExecutedStageResult.Status.ERROR:
|
||||
stage_result.overall_status = ExecutedStageResult.Status.FAILED
|
||||
stage_result.message = stage_result.message or f"Stage aborted due to failure in step '{step_name}'."
|
||||
break
|
||||
|
||||
# Determine final stage status
|
||||
if stage_result.overall_status == ExecutedStageResult.Status.PENDING: # If not set by before_stage error or early abort
|
||||
if not executed_steps_results and stage_instance.steps: # Steps defined but loop didn't run/finish (e.g. continue_on_failure issue)
|
||||
stage_result.overall_status = ExecutedStageResult.Status.SKIPPED
|
||||
stage_result.message = stage_result.message or "No steps were effectively executed."
|
||||
elif not stage_instance.steps: # No steps defined for the stage
|
||||
stage_result.overall_status = ExecutedStageResult.Status.PASSED # Considered PASSED if before_stage was OK
|
||||
stage_result.message = stage_result.message or "Stage has no steps."
|
||||
elif any(s.status == ExecutedStageStepResult.Status.ERROR for s in executed_steps_results):
|
||||
stage_result.overall_status = ExecutedStageResult.Status.ERROR
|
||||
stage_result.message = stage_result.message or "One or more steps encountered an error."
|
||||
elif any(s.status == ExecutedStageStepResult.Status.FAILED for s in executed_steps_results):
|
||||
stage_result.overall_status = ExecutedStageResult.Status.FAILED
|
||||
stage_result.message = stage_result.message or "One or more steps failed."
|
||||
elif all(s.status == ExecutedStageStepResult.Status.PASSED for s in executed_steps_results if executed_steps_results): # all steps passed
|
||||
stage_result.overall_status = ExecutedStageResult.Status.PASSED
|
||||
elif all(s.status == ExecutedStageStepResult.Status.SKIPPED for s in executed_steps_results if executed_steps_results): # all steps skipped
|
||||
stage_result.overall_status = ExecutedStageResult.Status.SKIPPED
|
||||
stage_result.message = stage_result.message or "All steps were skipped."
|
||||
else: # Mix of PASSED, SKIPPED, etc., but no FAILED or ERROR that wasn't handled by continue_on_failure
|
||||
# This case implies successful completion if no explicit FAILED/ERROR states propagated.
|
||||
# If there are executed steps, and none failed or errored, it's a pass.
|
||||
# If all executed steps passed or were skipped, and at least one passed: PASSED
|
||||
# If all executed steps were skipped: SKIPPED
|
||||
has_passed_step = any(s.status == ExecutedStageStepResult.Status.PASSED for s in executed_steps_results)
|
||||
if has_passed_step:
|
||||
stage_result.overall_status = ExecutedStageResult.Status.PASSED
|
||||
else: # No errors, no failures, no passes, implies all skipped or pending (which shouldn't happen for completed steps)
|
||||
stage_result.overall_status = ExecutedStageResult.Status.SKIPPED
|
||||
stage_result.message = stage_result.message or "Steps completed without explicit pass, fail, or error."
|
||||
|
||||
|
||||
try:
|
||||
self.logger.debug(f"Calling after_stage for stage '{stage_instance.id}'.")
|
||||
stage_instance.after_stage(stage_result=stage_result, stage_context=stage_context, global_api_spec=parsed_spec, api_group_name=api_group_name)
|
||||
except Exception as e_asg:
|
||||
self.logger.error(f"Error in after_stage for stage '{stage_instance.id}': {e_asg}", exc_info=True)
|
||||
if stage_result.overall_status not in [ExecutedStageResult.Status.ERROR]: # Don't override a more severe status
|
||||
original_status_msg = f"(Original status: {stage_result.overall_status.value})" if stage_result.overall_status != ExecutedStageResult.Status.PASSED else ""
|
||||
stage_result.overall_status = ExecutedStageResult.Status.ERROR
|
||||
current_msg = stage_result.message if stage_result.message else ""
|
||||
stage_result.message = f"{current_msg} after_stage hook failed: {e_asg} {original_status_msg}".strip()
|
||||
elif stage_result.message: stage_result.message += f" | after_stage hook failed: {e_asg}"
|
||||
else: stage_result.message = f"after_stage hook failed: {e_asg}"
|
||||
|
||||
stage_result.finalize_stage_result(final_context=stage_context)
|
||||
self.logger.info(f"Stage '{stage_instance.id}' execution finished. API Group: '{api_group_name}', Final Status: {stage_result.overall_status.value}, Duration: {stage_result.duration:.2f}s") # Corrected duration_seconds to duration
|
||||
return stage_result
|
||||
|
||||
def run_stages_from_spec(self,
|
||||
parsed_spec: ParsedAPISpec,
|
||||
summary: TestSummary):
|
||||
self.logger.info("Starting API Test Stage execution...")
|
||||
if not self.stage_registry or not self.stage_registry.get_all_stage_classes():
|
||||
self.logger.info("No API Test Stages loaded. Skipping stage execution.")
|
||||
return
|
||||
|
||||
stage_classes = self.stage_registry.get_all_stage_classes()
|
||||
summary.set_total_stages_defined(len(stage_classes))
|
||||
|
||||
api_groups: List[Optional[str]] = []
|
||||
if isinstance(parsed_spec, ParsedYAPISpec):
|
||||
# Use parsed_spec.categories which are List[Dict[str, str]] from YAPIInput
|
||||
if parsed_spec.categories:
|
||||
api_groups.extend([cat.get('name') for cat in parsed_spec.categories if cat.get('name')])
|
||||
# If categories list exists but all are unnamed or empty, or if no categories attribute
|
||||
if not api_groups and (not hasattr(parsed_spec, 'categories') or parsed_spec.categories is not None):
|
||||
self.logger.info("YAPI spec: No named categories found or categories attribute missing/empty. Applying stages to the whole spec (api_group_name=None).")
|
||||
api_groups.append(None)
|
||||
elif isinstance(parsed_spec, ParsedSwaggerSpec):
|
||||
# Use parsed_spec.tags which are List[Dict[str, str]]
|
||||
if parsed_spec.tags:
|
||||
api_groups.extend([tag.get('name') for tag in parsed_spec.tags if tag.get('name')])
|
||||
if not api_groups and (not hasattr(parsed_spec, 'tags') or parsed_spec.tags is not None):
|
||||
self.logger.info("Swagger spec: No named tags found or tags attribute missing/empty. Applying stages to the whole spec (api_group_name=None).")
|
||||
api_groups.append(None)
|
||||
|
||||
if not api_groups: # Default for other spec types or if above logic resulted in empty list
|
||||
self.logger.info("No specific API groups (categories/tags) identified. Applying stages to the whole spec (api_group_name=None).")
|
||||
api_groups.append(None)
|
||||
|
||||
self.logger.info(f"Evaluating test stages against {len(api_groups)} API group(s): {api_groups}")
|
||||
|
||||
total_stages_considered_for_execution = 0
|
||||
|
||||
for stage_class in stage_classes:
|
||||
# For template instance, provide default/empty metadata and api lists
|
||||
# as it's mainly used for accessing static-like properties before group-specific execution.
|
||||
default_group_meta = {"name": "Template Group", "description": "Used for stage template loading"}
|
||||
stage_instance_template = stage_class(
|
||||
api_group_metadata=default_group_meta,
|
||||
apis_in_group=[],
|
||||
llm_service=self.llm_service,
|
||||
global_api_spec=parsed_spec # Template might still need global spec for some pre-checks
|
||||
)
|
||||
self.logger.info(f"Processing Test Stage definition: ID='{stage_instance_template.id}', Name='{stage_instance_template.name}'")
|
||||
|
||||
was_applicable_and_executed_at_least_once = False
|
||||
|
||||
for api_group_name in api_groups:
|
||||
# Prepare api_group_metadata and apis_in_group for the current group
|
||||
current_group_metadata: Dict[str, Any] = {}
|
||||
current_apis_in_group_dicts: List[Dict[str, Any]] = []
|
||||
|
||||
if api_group_name:
|
||||
if isinstance(parsed_spec, ParsedYAPISpec) and parsed_spec.spec and 'categories' in parsed_spec.spec:
|
||||
category_details_list = parsed_spec.spec.get('categories', [])
|
||||
category_detail = next((cat for cat in category_details_list if cat.get('name') == api_group_name), None)
|
||||
if category_detail:
|
||||
current_group_metadata = {"name": api_group_name,
|
||||
"description": category_detail.get("desc"),
|
||||
"id": category_detail.get("_id")}
|
||||
category_id_to_match = category_detail.get("_id")
|
||||
# Filter endpoints for this category by id
|
||||
current_apis_in_group_dicts = [
|
||||
ep.to_dict() for ep in parsed_spec.endpoints
|
||||
if hasattr(ep, 'category_id') and ep.category_id == category_id_to_match
|
||||
]
|
||||
else:
|
||||
self.logger.warning(f"Could not find details for YAPI category: {api_group_name} in parsed_spec.spec.categories. API list for this group might be empty.")
|
||||
current_group_metadata = {"name": api_group_name, "description": "Details not found in spec top-level categories"}
|
||||
current_apis_in_group_dicts = [] # Or could attempt to filter by name if IDs are unreliable
|
||||
|
||||
elif isinstance(parsed_spec, ParsedSwaggerSpec):
|
||||
# For Swagger, tags on operations are primary; global tags are for definition.
|
||||
current_group_metadata = {"name": api_group_name}
|
||||
if parsed_spec.spec and 'tags' in parsed_spec.spec:
|
||||
tag_detail = next((tag for tag in parsed_spec.spec.get('tags', []) if tag.get('name') == api_group_name), None)
|
||||
if tag_detail:
|
||||
current_group_metadata["description"] = tag_detail.get("description")
|
||||
else: # Tag name exists (from api_groups list) but not in the top-level tags definitions
|
||||
current_group_metadata["description"] = "Tag defined on operation, not in global tags list."
|
||||
|
||||
# Filter endpoints for this tag
|
||||
current_apis_in_group_dicts = [
|
||||
ep.to_dict() for ep in parsed_spec.endpoints
|
||||
if hasattr(ep, 'tags') and isinstance(ep.tags, list) and api_group_name in ep.tags
|
||||
]
|
||||
else:
|
||||
self.logger.warning(f"API group '{api_group_name}' provided, but cannot determine group details or filter APIs for spec type {type(parsed_spec)}.")
|
||||
current_group_metadata = {"name": api_group_name, "description": "Unknown group type or spec structure"}
|
||||
current_apis_in_group_dicts = [ep.to_dict() for ep in parsed_spec.endpoints]
|
||||
|
||||
else: # api_group_name is None (global scope)
|
||||
current_group_metadata = {"name": "Global (All APIs)", "description": "Applies to all APIs in the spec"}
|
||||
current_apis_in_group_dicts = [ep.to_dict() for ep in parsed_spec.endpoints]
|
||||
|
||||
# Instantiate the stage with the prepared context for the current API group
|
||||
stage_instance = stage_class(
|
||||
api_group_metadata=current_group_metadata,
|
||||
apis_in_group=current_apis_in_group_dicts,
|
||||
llm_service=self.llm_service,
|
||||
global_api_spec=parsed_spec
|
||||
)
|
||||
total_stages_considered_for_execution += 1
|
||||
|
||||
try:
|
||||
self.logger.debug(f"Checking applicability of stage '{stage_instance.id}' for API group '{api_group_name}'...")
|
||||
applicable = stage_instance.is_applicable_to_api_group(
|
||||
api_group_name=api_group_name,
|
||||
global_api_spec=parsed_spec
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error checking applicability of stage '{stage_instance.id}' for group '{api_group_name}': {e}", exc_info=True)
|
||||
error_result = ExecutedStageResult(
|
||||
stage_id=stage_instance.id, stage_name=stage_instance.name, api_group=api_group_name,
|
||||
overall_status=ExecutedStageResult.Status.ERROR, message=f"Error during applicability check: {e}"
|
||||
)
|
||||
error_result.finalize_result(datetime.datetime.now(), [], {})
|
||||
summary.add_stage_result(error_result)
|
||||
was_applicable_and_executed_at_least_once = True # Considered an attempt
|
||||
continue
|
||||
|
||||
if applicable:
|
||||
self.logger.info(f"Test Stage '{stage_instance.id}' is APPLICABLE to API group '{api_group_name}'. Executing...")
|
||||
stage_execution_result = self.execute_single_stage(stage_instance, parsed_spec, api_group_name)
|
||||
summary.add_stage_result(stage_execution_result)
|
||||
was_applicable_and_executed_at_least_once = True
|
||||
else:
|
||||
self.logger.info(f"Test Stage '{stage_instance.id}' is NOT APPLICABLE to API group '{api_group_name}'. Skipping for this group.")
|
||||
|
||||
if not was_applicable_and_executed_at_least_once and stage_instance_template.fail_if_not_applicable_to_any_group:
|
||||
self.logger.warning(f"Test Stage '{stage_instance_template.id}' was not applicable to any API group and 'fail_if_not_applicable_to_any_group' is True.")
|
||||
failure_result = ExecutedStageResult(
|
||||
stage_id=stage_instance_template.id, stage_name=stage_instance_template.name, api_group=None,
|
||||
overall_status=ExecutedStageResult.Status.FAILED,
|
||||
message=f"Stage marked as 'must apply' but was not applicable to any of the evaluated groups: {api_groups}."
|
||||
)
|
||||
failure_result.finalize_result(datetime.datetime.now(), [], {})
|
||||
summary.add_stage_result(failure_result)
|
||||
|
||||
self.logger.info(f"API Test Stage execution processed. Considered {total_stages_considered_for_execution} (stage_definition x api_group) combinations.")
|
||||
|
||||
def _execute_tests_from_parsed_spec(self,
|
||||
parsed_spec: ParsedAPISpec,
|
||||
summary: TestSummary,
|
||||
categories: Optional[List[str]] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
custom_test_cases_dir: Optional[str] = None
|
||||
) -> TestSummary:
|
||||
"""基于已解析的API规范对象执行测试用例。"""
|
||||
# Restore the original start of the method body, the rest of the method should be intact from before.
|
||||
if custom_test_cases_dir and (not self.test_case_registry or not hasattr(self.test_case_registry, 'test_cases_dir') or self.test_case_registry.test_cases_dir != custom_test_cases_dir):
|
||||
self.logger.info(f"Re-initializing TestCaseRegistry from _execute_tests_from_parsed_spec with new directory: {custom_test_cases_dir}")
|
||||
try:
|
||||
# Assuming TestCaseRegistry can be re-initialized or its directory updated.
|
||||
# If TestCaseRegistry is loaded in __init__, this might need adjustment
|
||||
# For now, let's assume direct re-init is possible if dir changes.
|
||||
self.test_case_registry = TestCaseRegistry()
|
||||
self.test_case_registry.discover_and_load_test_cases(custom_test_cases_dir)
|
||||
self.logger.info(f"TestCaseRegistry (re)initialized, found {len(self.test_case_registry.get_all_test_case_classes())} test case classes.")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to re-initialize TestCaseRegistry from _execute_tests_from_parsed_spec: {e}", exc_info=True)
|
||||
# summary.finalize_summary() # Finalize might be premature here
|
||||
return summary # Early exit if registry fails
|
||||
|
||||
endpoints_to_test: List[Union[YAPIEndpoint, SwaggerEndpoint]] = []
|
||||
if isinstance(parsed_spec, ParsedYAPISpec):
|
||||
endpoints_to_test = parsed_spec.endpoints
|
||||
if categories:
|
||||
# Ensure YAPIEndpoint has 'category_name' if this filter is used.
|
||||
endpoints_to_test = [ep for ep in endpoints_to_test if hasattr(ep, 'category_name') and ep.category_name in categories]
|
||||
elif isinstance(parsed_spec, ParsedSwaggerSpec):
|
||||
endpoints_to_test = parsed_spec.endpoints
|
||||
if tags:
|
||||
# Ensure SwaggerEndpoint has 'tags' attribute for this filter.
|
||||
endpoints_to_test = [ep for ep in endpoints_to_test if hasattr(ep, 'tags') and isinstance(ep.tags, list) and any(tag in ep.tags for tag in tags)]
|
||||
else:
|
||||
self.logger.warning(f"Unknown parsed_spec type: {type(parsed_spec)}. Cannot filter endpoints.")
|
||||
# summary.finalize_summary() # Finalize might be premature
|
||||
return summary
|
||||
|
||||
current_total_defined = summary.total_endpoints_defined
|
||||
summary.set_total_endpoints_defined(current_total_defined + len(endpoints_to_test))
|
||||
|
||||
total_applicable_tcs_for_this_run = 0
|
||||
if self.test_case_registry:
|
||||
for endpoint_spec_obj in endpoints_to_test:
|
||||
total_applicable_tcs_for_this_run += len(
|
||||
self.test_case_registry.get_applicable_test_cases(
|
||||
endpoint_spec_obj.method.upper(), endpoint_spec_obj.path
|
||||
)
|
||||
)
|
||||
current_total_applicable = summary.total_test_cases_applicable
|
||||
summary.set_total_test_cases_applicable(current_total_applicable + total_applicable_tcs_for_this_run)
|
||||
|
||||
for endpoint in endpoints_to_test:
|
||||
# global_api_spec 应该是包含完整定义的 ParsedYAPISpec/ParsedSwaggerSpec 对象
|
||||
# 而不是其内部的 .spec 字典,因为 _execute_single_test_case 需要这个对象
|
||||
result = self.run_test_for_endpoint(endpoint, global_api_spec=parsed_spec)
|
||||
summary.add_endpoint_result(result)
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
Reference in New Issue
Block a user