fix:stage_group
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.
@@ -1,228 +0,0 @@
|
||||
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)
|
||||
@@ -1,90 +0,0 @@
|
||||
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) # 返回副本
|
||||
@@ -6,6 +6,7 @@ import time
|
||||
from typing import List, Dict, Any, Callable, Optional, Union
|
||||
from enum import Enum
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# Add Pydantic BaseModel for APIOperationSpec
|
||||
from pydantic import BaseModel
|
||||
@@ -25,14 +26,145 @@ except ImportError:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 定义 APIOperationSpec
|
||||
class APIOperationSpec(BaseModel):
|
||||
# Forward declaration for type hinting if ParsedAPISpec is used directly
|
||||
ParsedAPISpec = "ParsedAPISpec"
|
||||
|
||||
@dataclass
|
||||
class APIOperationSpec:
|
||||
"""封装了从全局API规范中解析出来的特定API操作的详细信息"""
|
||||
method: str
|
||||
path: str
|
||||
spec: Dict[str, Any] # 原始API端点定义字典
|
||||
spec: Dict[str, Any] # 该API操作的完整OpenAPI/Swagger规范字典部分
|
||||
operation_id: Optional[str] = None
|
||||
summary: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StageStepDefinition:
|
||||
"""定义测试阶段中的单个步骤"""
|
||||
name: str
|
||||
endpoint_spec_lookup_key: str # 用于在API规范中查找此步骤对应的端点
|
||||
request_overrides: Dict[str, Any] = field(default_factory=dict)
|
||||
expected_status_codes: List[int] = field(default_factory=lambda: [200, 201])
|
||||
# Corrected type hint for response_assertions
|
||||
response_assertions: List[Callable[[Any, Dict[str, Any]], ValidationResult]] = field(default_factory=list) # response_context, stage_context -> ValidationResult
|
||||
outputs_to_context: Dict[str, str] = field(default_factory=dict) # 从响应提取到阶段上下文的映射
|
||||
description: Optional[str] = None
|
||||
order: int = 0 # ADDED order attribute
|
||||
|
||||
|
||||
class ExecutedStageStepResult:
|
||||
"""存储单个测试阶段步骤的执行结果"""
|
||||
class Status(str, Enum):
|
||||
PENDING = "待定"
|
||||
PASSED = "通过"
|
||||
FAILED = "失败"
|
||||
ERROR = "执行错误"
|
||||
SKIPPED = "跳过"
|
||||
|
||||
def __init__(self,
|
||||
step_name: str,
|
||||
status: Status = Status.PENDING,
|
||||
message: str = "",
|
||||
lookup_key: Optional[str] = None,
|
||||
resolved_endpoint: Optional[str] = None,
|
||||
request_details: Optional[Dict[str, Any]] = None,
|
||||
api_call_details: Optional[Dict[str, Any]] = None, # 存储 APICallDetail.to_dict() 的结果
|
||||
validation_points: Optional[List[Dict[str, Any]]] = None, # 存储 ValidationResult.to_dict() 的结果列表
|
||||
duration_seconds: float = 0.0,
|
||||
context_after_step: Optional[Dict[str, Any]] = None,
|
||||
description: Optional[str] = None):
|
||||
self.step_name = step_name
|
||||
self.description = description
|
||||
self.status = status
|
||||
self.message = message
|
||||
self.lookup_key = lookup_key
|
||||
self.resolved_endpoint = resolved_endpoint
|
||||
self.request_details = request_details if request_details is not None else {}
|
||||
self.api_call_details = api_call_details if api_call_details is not None else {}
|
||||
self.validation_points = validation_points if validation_points is not None else []
|
||||
self.duration_seconds = duration_seconds
|
||||
self.context_after_step = context_after_step if context_after_step is not None else {}
|
||||
self.timestamp: datetime = datetime.now()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"step_name": self.step_name,
|
||||
"description": self.description,
|
||||
"status": self.status.value,
|
||||
"message": self.message,
|
||||
"lookup_key": self.lookup_key,
|
||||
"resolved_endpoint": self.resolved_endpoint,
|
||||
"duration_seconds": f"{self.duration_seconds:.4f}",
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
"request_details": self.request_details,
|
||||
"api_call_details": self.api_call_details,
|
||||
"validation_points": self.validation_points,
|
||||
"context_snapshot_after_step": self.context_after_step # 可以选择是否完整记录上下文
|
||||
}
|
||||
|
||||
|
||||
class ExecutedStageResult:
|
||||
"""存储整个测试阶段的执行结果"""
|
||||
class Status(str, Enum):
|
||||
PENDING = "待定"
|
||||
PASSED = "通过"
|
||||
FAILED = "失败"
|
||||
ERROR = "执行错误"
|
||||
SKIPPED = "跳过"
|
||||
|
||||
def __init__(self,
|
||||
stage_id: str,
|
||||
stage_name: str,
|
||||
description: Optional[str] = None,
|
||||
api_group_metadata: Optional[Dict[str, Any]] = None, # 存储阶段应用到的API分组元数据
|
||||
tags: Optional[List[str]] = None, # <-- Added tags parameter
|
||||
overall_status: Status = Status.PENDING,
|
||||
message: str = ""):
|
||||
self.stage_id = stage_id
|
||||
self.stage_name = stage_name
|
||||
self.description = description
|
||||
self.api_group_metadata = api_group_metadata
|
||||
self.tags = tags if tags is not None else [] # <-- Store tags
|
||||
self.overall_status = overall_status
|
||||
self.message = message
|
||||
self.executed_steps: List[ExecutedStageStepResult] = []
|
||||
self.final_context_snapshot: Optional[Dict[str, Any]] = None
|
||||
self.start_time: datetime = datetime.now()
|
||||
self.end_time: Optional[datetime] = None
|
||||
self.duration: float = 0.0
|
||||
|
||||
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): # Renamed from finalize_result
|
||||
self.end_time = datetime.now()
|
||||
self.duration = (self.end_time - self.start_time).total_seconds()
|
||||
self.final_context_snapshot = final_context if final_context is not None else {}
|
||||
|
||||
# 确定最终状态的逻辑可以放在这里,或者由编排器在调用后设置
|
||||
if not self.executed_steps and self.overall_status == ExecutedStageResult.Status.PENDING:
|
||||
self.overall_status = ExecutedStageResult.Status.SKIPPED # 如果没有步骤执行,且状态未被其他方式设置
|
||||
self.message = self.message or "阶段中没有步骤被执行或所有步骤被跳过。"
|
||||
# 更复杂的最终状态判定可能需要编排器逻辑
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"stage_id": self.stage_id,
|
||||
"stage_name": self.stage_name,
|
||||
"description": self.description,
|
||||
"api_group_metadata": self.api_group_metadata,
|
||||
"tags": self.tags, # <-- Added tags to output
|
||||
"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],
|
||||
"final_context_snapshot": self.final_context_snapshot
|
||||
}
|
||||
|
||||
# 默认的操作类型关键字映射
|
||||
# 键是标准化的操作类型,值是可能出现在API标题中的关键字列表
|
||||
@@ -45,31 +177,6 @@ DEFAULT_OPERATION_KEYWORDS: Dict[str, List[str]] = {
|
||||
# 可以根据需要添加更多通用操作类型
|
||||
}
|
||||
|
||||
|
||||
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测试阶段的基类。
|
||||
@@ -84,22 +191,33 @@ class BaseAPIStage:
|
||||
# 由子类定义,表示此Stage中的API调用步骤
|
||||
steps: List[StageStepDefinition] = []
|
||||
|
||||
# 新增: 控制当此阶段不适用于任何API分组时,是否标记为失败
|
||||
fail_if_not_applicable_to_any_group: bool = False
|
||||
|
||||
# 新增: 控制当一个步骤失败/错误时,是否继续执行后续步骤
|
||||
continue_on_failure: bool = False
|
||||
|
||||
def __init__(self,
|
||||
api_group_metadata: Dict[str, Any],
|
||||
apis_in_group: List[Dict[str, Any]], # 当前分组内所有API的定义列表 (字典格式)
|
||||
apis_in_group: List[Union[YAPIEndpoint, SwaggerEndpoint]], # MODIFIED TYPE HINT
|
||||
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.api_group_metadata = api_group_metadata # Let's ensure this common name is also available.
|
||||
self.apis_in_group = apis_in_group # CORRECTED attribute name
|
||||
self.global_api_spec: ParsedAPISpec = global_api_spec
|
||||
self.llm_service = llm_service
|
||||
self.global_api_spec = global_api_spec # 保留 Optional[ParsedAPISpec]
|
||||
|
||||
self._operation_keywords = operation_keywords or DEFAULT_OPERATION_KEYWORDS
|
||||
self._operation_keywords = operation_keywords if operation_keywords is not None else {} # CORRECTED assignment
|
||||
self._matched_endpoints: Dict[str, Dict[str, Any]] = {} # 存储按操作类型匹配到的端点定义
|
||||
self._match_api_endpoints_in_group() # 初始化时自动匹配
|
||||
|
||||
# Calling _match_api_endpoints_in_group() here might be problematic if it relies on
|
||||
# subclass-specific setup or if it's intended to be called by the subclass.
|
||||
# For now, let's comment it out from the base __init__ to avoid potential errors
|
||||
# until its role and safety here are confirmed.
|
||||
# self._match_api_endpoints_in_group()
|
||||
|
||||
# 确保子类定义了ID
|
||||
if not self.id:
|
||||
@@ -107,22 +225,127 @@ class BaseAPIStage:
|
||||
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)
|
||||
if self.steps: # Ensure steps exist before trying to sort
|
||||
self.steps = sorted(self.steps, key=lambda s: s.order)
|
||||
else:
|
||||
self.steps = []
|
||||
|
||||
def get_api_spec_for_operation(self,
|
||||
lookup_key: str,
|
||||
global_api_spec: ParsedAPISpec, # 确保类型正确
|
||||
api_group_name: Optional[str] = None
|
||||
) -> Optional[APIOperationSpec]:
|
||||
"""
|
||||
根据查找键从提供的API规范中获取特定API操作的详细信息。
|
||||
这个方法需要子类根据其查找逻辑来实现,或者提供一个通用的基于operationId或path+method的查找。
|
||||
|
||||
def is_applicable_to_api_group(self, api_group_metadata: Dict[str, Any], apis_in_group: List[Dict[str, Any]]) -> bool:
|
||||
Args:
|
||||
lookup_key: 用于查找API操作的键 (例如 operationId, "METHOD /path", 或自定义键)。
|
||||
global_api_spec: 完整的已解析API规范对象。
|
||||
api_group_name: (可选) 当前API分组的名称,用于更精确的查找或作用域限定。
|
||||
|
||||
Returns:
|
||||
APIOperationSpec 对象如果找到,否则 None。
|
||||
"""
|
||||
self.logger.debug(f"Attempting to find API spec for lookup_key='{lookup_key}', api_group='{api_group_name}'")
|
||||
|
||||
# 尝试基于 operationId 查找 (如果 lookup_key 看起来像 operationId)
|
||||
# 简单的启发式:不包含空格和斜杠的键可能是 operationId
|
||||
if ' ' not in lookup_key and '/' not in lookup_key:
|
||||
for endpoint in global_api_spec.endpoints:
|
||||
endpoint_dict = endpoint.to_dict() # 将YAPIEndpoint或SwaggerEndpoint转换为标准化字典
|
||||
op_id = endpoint_dict.get('operationId')
|
||||
if op_id == lookup_key:
|
||||
# 检查此端点是否属于当前API分组 (如果提供了api_group_name)
|
||||
if api_group_name:
|
||||
tags = endpoint_dict.get('tags', [])
|
||||
if api_group_name not in tags:
|
||||
self.logger.debug(f"Endpoint with operationId '{lookup_key}' found, but not in group '{api_group_name}'. Tags: {tags}")
|
||||
continue # 不在当前组,继续查找
|
||||
|
||||
self.logger.info(f"Found API for operationId '{lookup_key}' (group: {api_group_name}). Path: {endpoint_dict.get('path')}")
|
||||
return APIOperationSpec(
|
||||
method=endpoint_dict.get('method','').upper(),
|
||||
path=endpoint_dict.get('path',''),
|
||||
spec=endpoint_dict, # 传递整个端点字典作为规范
|
||||
operation_id=op_id,
|
||||
summary=endpoint_dict.get('summary'),
|
||||
description=endpoint_dict.get('description'),
|
||||
tags=endpoint_dict.get('tags', [])
|
||||
)
|
||||
|
||||
# 尝试基于 "METHOD /path" 格式的 lookup_key 查找
|
||||
# (这是一个更通用的查找方式,但可能需要更仔细的路径匹配逻辑)
|
||||
parts = lookup_key.split(' ', 1)
|
||||
if len(parts) == 2:
|
||||
method_to_find = parts[0].upper()
|
||||
path_to_find = parts[1]
|
||||
for endpoint in global_api_spec.endpoints:
|
||||
endpoint_dict = endpoint.to_dict()
|
||||
if endpoint_dict.get('method','').upper() == method_to_find and endpoint_dict.get('path','') == path_to_find:
|
||||
if api_group_name:
|
||||
tags = endpoint_dict.get('tags', [])
|
||||
if api_group_name not in tags:
|
||||
self.logger.debug(f"Endpoint '{lookup_key}' found, but not in group '{api_group_name}'. Tags: {tags}")
|
||||
continue
|
||||
self.logger.info(f"Found API for method/path '{lookup_key}' (group: {api_group_name}).")
|
||||
return APIOperationSpec(
|
||||
method=method_to_find,
|
||||
path=path_to_find,
|
||||
spec=endpoint_dict,
|
||||
operation_id=endpoint_dict.get('operationId'),
|
||||
summary=endpoint_dict.get('summary'),
|
||||
description=endpoint_dict.get('description'),
|
||||
tags=endpoint_dict.get('tags', [])
|
||||
)
|
||||
|
||||
self.logger.warning(f"Could not find API operation spec for lookup_key: '{lookup_key}' (api_group: '{api_group_name}') using default search logic (operationId or METHOD /path). Consider overriding get_api_spec_for_operation in your Stage class for custom lookup logic.")
|
||||
return None
|
||||
|
||||
# --- 生命周期钩子 ---
|
||||
def before_stage(self, stage_context: Dict[str, Any], global_api_spec: ParsedAPISpec, api_group_name: Optional[str]):
|
||||
"""在测试阶段所有步骤执行之前调用。"""
|
||||
self.logger.debug(f"Executing before_stage for stage '{self.id}', group '{api_group_name}'. Initial context: {stage_context}")
|
||||
pass
|
||||
|
||||
def after_stage(self, stage_result: ExecutedStageResult, stage_context: Dict[str, Any], global_api_spec: ParsedAPISpec, api_group_name: Optional[str]):
|
||||
"""在测试阶段所有步骤执行完毕之后调用(无论成功、失败或错误)。"""
|
||||
self.logger.debug(f"Executing after_stage for stage '{self.id}', group '{api_group_name}'. Result status: {stage_result.overall_status.value}. Final context: {stage_context}")
|
||||
pass
|
||||
|
||||
def before_step(self, step: StageStepDefinition, stage_context: Dict[str, Any], global_api_spec: ParsedAPISpec, api_group_name: Optional[str]):
|
||||
"""在每个测试步骤执行之前调用。"""
|
||||
self.logger.debug(f"Executing before_step for stage '{self.id}', step '{step.name}', group '{api_group_name}'. Current context: {stage_context}")
|
||||
pass
|
||||
|
||||
def after_step(self, step: StageStepDefinition, step_result: ExecutedStageStepResult, stage_context: Dict[str, Any], global_api_spec: ParsedAPISpec, api_group_name: Optional[str]):
|
||||
"""在每个测试步骤执行之后调用(无论成功、失败或错误)。"""
|
||||
self.logger.debug(f"Executing after_step for stage '{self.id}', step '{step.name}', group '{api_group_name}'. Step status: {step_result.status.value}. Context after step: {stage_context}")
|
||||
pass
|
||||
|
||||
def is_applicable_to_api_group(self, api_group_name: Optional[str], global_api_spec: ParsedAPISpec) -> bool:
|
||||
"""
|
||||
判断此测试阶段是否适用于给定的API分组。
|
||||
子类可以重写此方法以实现更复杂的适用性逻辑。
|
||||
|
||||
Args:
|
||||
api_group_metadata: API分组的元数据 (例如 YAPI category name/id 或 Swagger tag name/description)。
|
||||
apis_in_group: 该分组内的API端点定义列表。
|
||||
|
||||
Returns:
|
||||
True 如果此阶段适用于该API分组,否则 False。
|
||||
默认情况下,如果阶段没有定义特定的适用性逻辑(例如,通过`tags`属性或覆盖此方法),
|
||||
它将适用于所有API分组(如果 `api_group_name` 是 `None`,表示全局应用)或者
|
||||
适用于名称与阶段 `tags` 中任何一个匹配的API分组。
|
||||
子类可以覆盖此方法以实现更复杂的适用性判断逻辑。
|
||||
"""
|
||||
return True # 默认应用于所有分组
|
||||
if not self.tags: # 如果阶段没有定义任何标签
|
||||
self.logger.debug(f"Stage '{self.id}' has no specific tags defined. Applying to group '{api_group_name}' by default (true if group is None, or if specific logic isn't overridden to be more restrictive).")
|
||||
return True # 默认适用于所有分组或全局
|
||||
|
||||
if api_group_name is None:
|
||||
# 如果阶段有标签,但当前评估的是全局应用场景 (api_group_name is None),
|
||||
# 那么它通常不应该在全局应用,除非设计者明确希望如此。
|
||||
# 这里的行为可以调整:如果阶段有标签,它是否还应该应用于"全局"?
|
||||
# 当前逻辑:如果阶段有标签,它只应用于匹配这些标签的分组。
|
||||
self.logger.debug(f"Stage '{self.id}' has tags {self.tags}, but is being checked against global scope (api_group_name is None). Defaulting to not applicable to global if tags are present.")
|
||||
return False
|
||||
|
||||
applicable = api_group_name in self.tags
|
||||
self.logger.debug(f"Stage '{self.id}' (tags: {self.tags}) applicability to API group '{api_group_name}': {applicable}")
|
||||
return applicable
|
||||
|
||||
def _match_api_endpoints_in_group(self):
|
||||
"""
|
||||
@@ -212,6 +435,10 @@ class BaseAPIStage:
|
||||
if not match_found and hasattr(endpoint_obj, 'operation_id') and endpoint_obj.operation_id == lookup_key:
|
||||
match_found = True
|
||||
|
||||
# 4. Check by YAPI _id (convert to string for safe comparison)
|
||||
if not match_found and hasattr(endpoint_obj, '_id') and endpoint_obj._id is not None and str(endpoint_obj._id) == str(lookup_key):
|
||||
match_found = True
|
||||
|
||||
if match_found:
|
||||
self.logger.info(f"'{self.id}': 找到匹配操作 '{lookup_key}' -> Method: {endpoint_obj.method}, Path: {endpoint_obj.path}")
|
||||
|
||||
@@ -305,7 +532,7 @@ class ExecutedStageStepResult:
|
||||
FAILED = "失败"
|
||||
ERROR = "执行错误"
|
||||
SKIPPED = "跳过"
|
||||
PENDING = "处理中" # 新增:表示步骤正在等待或预处理
|
||||
PENDING = "处理中"
|
||||
|
||||
def __init__(self,
|
||||
step_name: str,
|
||||
@@ -313,14 +540,14 @@ class ExecutedStageStepResult:
|
||||
message: str = "",
|
||||
validation_points: Optional[List[ValidationResult]] = None,
|
||||
duration: float = 0.0,
|
||||
api_call_detail: Optional[APICallDetail] = None, # 记录此步骤的API调用详情
|
||||
api_call_detail: Optional[APICallDetail] = None,
|
||||
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
|
||||
):
|
||||
description: Optional[str] = None,
|
||||
lookup_key: Optional[Union[str, Dict[str, str]]] = None,
|
||||
resolved_endpoint: Optional[str] = None,
|
||||
request_details: Optional[Dict[str, Any]] = None,
|
||||
context_after_step: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
self.step_name = step_name
|
||||
self.status = status
|
||||
self.message = message
|
||||
@@ -329,52 +556,77 @@ class ExecutedStageStepResult:
|
||||
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 # <--- 设置属性
|
||||
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 finalize_step_result(self):
|
||||
"""如果步骤的主消息为空,则根据验证点结果更新它。"""
|
||||
if not self.message and hasattr(self, 'validation_points') and self.validation_points:
|
||||
failed_vp_messages = [
|
||||
vp.message for vp in self.validation_points
|
||||
if isinstance(vp, ValidationResult) and not vp.passed and vp.message
|
||||
]
|
||||
if failed_vp_messages:
|
||||
self.message = "; ".join(failed_vp_messages)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
vps_details = []
|
||||
if self.validation_points: # self.validation_points is List[Dict[str, Any]]
|
||||
for vp_dict in self.validation_points: # vp_dict is a Dict from ValidationResult.to_dict()
|
||||
# Access dictionary keys instead of object attributes
|
||||
vp_details_content = vp_dict.get('details')
|
||||
vp_passed = vp_dict.get('passed', False)
|
||||
vp_message = vp_dict.get('message', '')
|
||||
|
||||
processed_detail = {"passed": vp_passed, "message": vp_message}
|
||||
|
||||
if vp_details_content and isinstance(vp_details_content, dict):
|
||||
try:
|
||||
#只取部分关键信息或确保可序列化
|
||||
if "status_code" in vp_details_content:
|
||||
processed_detail["status_code"] = vp_details_content["status_code"]
|
||||
# 不直接序列化整个 response body 以免过大
|
||||
# You might want to add other relevant serializable fields from vp_details_content
|
||||
except TypeError:
|
||||
# Update message if details were not serializable, though this part might be less reachable now
|
||||
processed_detail["message"] = f"{vp_message} (Details not fully serializable)"
|
||||
vps_details_for_output = []
|
||||
if self.validation_points: # self.validation_points is List[ValidationResult]
|
||||
for vp_obj in self.validation_points: # vp_obj is a ValidationResult object
|
||||
if not isinstance(vp_obj, ValidationResult): # Defensive check
|
||||
logger.warning(f"Step '{self.step_name}': Found non-ValidationResult item in validation_points: {type(vp_obj)}")
|
||||
continue
|
||||
|
||||
vps_details.append(processed_detail)
|
||||
processed_detail = {"passed": vp_obj.passed, "message": vp_obj.message}
|
||||
details_content = getattr(vp_obj, 'details', None)
|
||||
|
||||
if details_content and isinstance(details_content, dict):
|
||||
try:
|
||||
# 只取部分关键信息或确保可序列化
|
||||
if "status_code" in details_content:
|
||||
processed_detail["status_code_in_validation"] = details_content["status_code"]
|
||||
# 可以添加其他从 details_content 中提取的可序列化字段
|
||||
except TypeError:
|
||||
# 如果 details 无法完全序列化,更新消息
|
||||
processed_detail["message"] = f"{vp_obj.message} (Details not fully serializable)"
|
||||
elif details_content: # 如果 details 不是字典但存在
|
||||
processed_detail["details_type"] = type(details_content).__name__
|
||||
|
||||
vps_details_for_output.append(processed_detail)
|
||||
|
||||
# 如果 finalize_step_result 已经被调用,self.message 可能已经更新
|
||||
# 否则,这里的逻辑会再次尝试整合 (如果 self.message 仍然为空)
|
||||
current_message = self.message
|
||||
if not current_message and self.validation_points:
|
||||
failed_vp_messages = [
|
||||
vp_obj.message for vp_obj in self.validation_points
|
||||
if isinstance(vp_obj, ValidationResult) and not vp_obj.passed and vp_obj.message
|
||||
]
|
||||
if failed_vp_messages:
|
||||
current_message = "; ".join(failed_vp_messages)
|
||||
|
||||
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, # <--- 添加到输出
|
||||
"description": self.description,
|
||||
"lookup_key": str(self.lookup_key) if self.lookup_key is not None else None,
|
||||
"resolved_endpoint": self.resolved_endpoint,
|
||||
"status": self.status.value,
|
||||
"message": self.message or "; ".join([vp_dict.get('message', '') for vp_dict in self.validation_points if not vp_dict.get('passed')]),
|
||||
"message": current_message, # 使用当前或整合后的消息
|
||||
"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
|
||||
"validation_points": vps_details_for_output,
|
||||
"api_call_curl": getattr(getattr(self, 'api_call_detail', None), 'curl_command', 'N/A'),
|
||||
"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()} # <--- 添加到输出 (摘要)
|
||||
"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, type(None))) else v
|
||||
) for k,v in (self.context_after_step or {}).items()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -391,25 +643,29 @@ class ExecutedStageResult:
|
||||
stage_id: str,
|
||||
stage_name: str,
|
||||
api_group_metadata: Optional[Dict[str, Any]] = None,
|
||||
description: Optional[str] = None): # <--- 添加 description 参数
|
||||
description: Optional[str] = None, # <--- 添加 description 参数
|
||||
tags: Optional[List[str]] = None, # <-- Added tags parameter
|
||||
overall_status: Status = Status.PENDING,
|
||||
message: str = ""):
|
||||
self.stage_id = stage_id
|
||||
self.stage_name = stage_name
|
||||
self.description = description
|
||||
self.api_group_metadata = api_group_metadata
|
||||
self.overall_status: ExecutedStageResult.Status = ExecutedStageResult.Status.PENDING
|
||||
self.start_time: datetime = datetime.now()
|
||||
self.end_time: Optional[datetime] = None
|
||||
self.duration: float = 0.0
|
||||
self.message: Optional[str] = None
|
||||
self.tags = tags if tags is not None else [] # <-- Store tags
|
||||
self.overall_status = overall_status
|
||||
self.message = message
|
||||
self.executed_steps: List[ExecutedStageStepResult] = [] # 确保初始化为空列表
|
||||
self.final_context: Optional[Dict[str, Any]] = None
|
||||
# executed_steps_count 应该是一个属性,或者在 to_dict 中计算
|
||||
self.start_time: datetime = datetime.now() # Corrected
|
||||
self.end_time: Optional[datetime] = None # Corrected type hint
|
||||
self.duration: float = 0.0
|
||||
|
||||
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 = datetime.now()
|
||||
self.end_time = datetime.now() # Corrected
|
||||
self.duration = (self.end_time - self.start_time).total_seconds()
|
||||
self.final_context = final_context
|
||||
|
||||
@@ -452,6 +708,7 @@ class ExecutedStageResult:
|
||||
"stage_name": self.stage_name,
|
||||
"description": self.description, # <--- 添加 description 到输出
|
||||
"api_group_name": self.api_group_metadata.get("name", "N/A"),
|
||||
"tags": self.tags, # <-- Added tags to output
|
||||
"overall_status": self.overall_status.value,
|
||||
"duration_seconds": f"{self.duration:.2f}",
|
||||
"start_time": self.start_time.strftime('%Y-%m-%dT%H:%M:%S%z'),
|
||||
|
||||
@@ -6,6 +6,7 @@ import importlib.util
|
||||
import inspect
|
||||
import logging
|
||||
from typing import List, Type, Dict, Optional
|
||||
from pathlib import Path
|
||||
|
||||
from .stage_framework import BaseAPIStage # 导入新的 BaseAPIStage
|
||||
|
||||
@@ -13,111 +14,105 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class StageRegistry:
|
||||
"""
|
||||
负责发现、加载和管理 BaseAPIStage 子类。
|
||||
负责发现、加载和管理所有API测试阶段 (stages)。
|
||||
"""
|
||||
def __init__(self, stages_dir: Optional[str] = None):
|
||||
"""
|
||||
初始化 StageRegistry。
|
||||
Args:
|
||||
stages_dir: 存放自定义 BaseAPIStage Python文件的目录路径。
|
||||
如果为 None 或无效路径,则不会加载任何自定义阶段。
|
||||
stages_dir: 存放自定义 APIStage Python文件的目录路径。
|
||||
如果为 None,则不会加载任何阶段。
|
||||
"""
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.stages_dir = stages_dir
|
||||
self.stages_dir: Optional[Path] = Path(stages_dir) if stages_dir else None
|
||||
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: 未提供阶段目录,将不会加载任何自定义阶段。")
|
||||
if self.stages_dir:
|
||||
if not self.stages_dir.is_dir():
|
||||
logger.warning(f"指定的阶段目录不存在或不是一个目录: {self.stages_dir}")
|
||||
# Optionally, you could raise an error here or handle it as no stages found.
|
||||
# For now, it will just result in no stages being loaded.
|
||||
self.stages_dir = None # Prevent further processing if dir is invalid
|
||||
else:
|
||||
self.discover_stages()
|
||||
else:
|
||||
logger.info("没有提供阶段目录,将不加载任何自定义测试阶段。")
|
||||
|
||||
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}' 无效或不存在,无法发现阶段。")
|
||||
def discover_stages(self):
|
||||
"""从指定的目录中发现并加载所有 BaseAPIStage 子类。"""
|
||||
if not self.stages_dir or not self.stages_dir.is_dir():
|
||||
logger.debug("阶段目录未设置或无效,跳过阶段发现。")
|
||||
return
|
||||
|
||||
self.logger.info(f"StageRegistry: 开始从目录 '{self.stages_dir}' 及其子目录发现测试阶段...")
|
||||
found_count = 0
|
||||
# 使用 os.walk 进行递归扫描
|
||||
for root_dir, _, files in os.walk(self.stages_dir):
|
||||
self._stages = {}
|
||||
self._errors = []
|
||||
logger.info(f"开始从目录发现测试阶段: {self.stages_dir}")
|
||||
|
||||
for root, _, 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
|
||||
if filename.endswith('.py') and not filename.startswith('_'):
|
||||
file_path = Path(root) / filename
|
||||
module_name = f"ddms_compliance_suite.stages.{file_path.stem}" # 可以根据需要调整模块命名
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
||||
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"StageRegistry: 成功导入模块: {module_name} 从 {module_path}")
|
||||
logger.debug(f"成功加载模块: {module_name} 从 {file_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:
|
||||
if hasattr(cls, 'id') and cls.id:
|
||||
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})")
|
||||
if stage_id in self._stages:
|
||||
warning_msg = f"发现重复的阶段ID '{stage_id}' 在 {file_path}。已加载的阶段来自 {self._stages[stage_id].__module__}。将被忽略。"
|
||||
logger.warning(warning_msg)
|
||||
self._errors.append(warning_msg)
|
||||
else:
|
||||
self._stages[stage_id] = cls
|
||||
logger.info(f"成功注册测试阶段: {stage_id} (来自 {module_name}.{name})")
|
||||
else:
|
||||
warning_msg = f"在 {file_path} 中发现的类 {name} 继承自 BaseAPIStage 但缺少 'id' 属性或ID为空。将被忽略。"
|
||||
logger.warning(warning_msg)
|
||||
self._errors.append(warning_msg)
|
||||
else:
|
||||
self._errors.append(f"无法为文件 '{module_path}' 创建模块规范。")
|
||||
self.logger.error(f"StageRegistry: 无法为文件 '{module_path}' 创建模块规范。")
|
||||
error_msg = f"无法为文件创建模块规范: {file_path}"
|
||||
logger.error(error_msg)
|
||||
self._errors.append(error_msg)
|
||||
|
||||
except ImportError as e:
|
||||
error_msg = f"导入模块 '{module_name}' (从 '{module_path}') 失败: {e}"
|
||||
error_msg = f"导入模块 {module_name} 从 {file_path} 失败: {e}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
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}"
|
||||
error_msg = f"加载或检查文件 {file_path} 时发生未知错误: {e}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
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),未来可以添加。
|
||||
logger.info(f"测试阶段发现完成。共加载 {len(self._stages)} 个阶段。发现 {len(self._errors)} 个错误。")
|
||||
|
||||
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]]:
|
||||
"""获取所有已注册的测试阶段类的列表。"""
|
||||
def get_all_stages(self) -> List[Type[BaseAPIStage]]:
|
||||
"""返回所有已发现和加载的测试阶段类的列表。"""
|
||||
return list(self._stages.values())
|
||||
|
||||
def get_load_errors(self) -> List[str]:
|
||||
"""获取加载过程中发生的错误信息列表。"""
|
||||
def get_stage_by_id(self, stage_id: str) -> Optional[Type[BaseAPIStage]]:
|
||||
"""根据ID获取特定的测试阶段类。"""
|
||||
return self._stages.get(stage_id)
|
||||
|
||||
def get_discovery_errors(self) -> List[str]:
|
||||
"""返回在发现过程中遇到的任何错误信息。"""
|
||||
return self._errors
|
||||
|
||||
def clear_stages(self):
|
||||
"""清空所有已加载的阶段和错误信息。"""
|
||||
self._stages = {}
|
||||
self._errors = []
|
||||
logger.debug("StageRegistry 已清空。")
|
||||
|
||||
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}' 无效或不存在。没有加载任何自定义阶段。")
|
||||
"""清空并重新从目录加载所有阶段。"""
|
||||
self.clear_stages()
|
||||
if self.stages_dir:
|
||||
self.discover_stages()
|
||||
else:
|
||||
self.logger.info("StageRegistry (重载时): 未配置阶段目录,没有加载任何自定义阶段。")
|
||||
logger.info("没有配置阶段目录,无法重新加载阶段。")
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user