step half finish

This commit is contained in:
gongwenxin
2025-06-05 15:17:51 +08:00
parent e23f2856d6
commit 7333cc8a2a
58 changed files with 11351 additions and 4788 deletions
+228
View File
@@ -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) # 返回副本
+454
View File
@@ -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 # 可选: 输出处理后的上下文摘要
}
+123
View File
@@ -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}')"
+755 -90
View File
@@ -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