Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-backend/cli/runtime/plugin/support.py
T
insistence 2a055ba648 feat: 新增插件系统 (#112)
* feat: 初始化插件系统

* refactor: 收口插件系统运行时重构

* perf: 优化插件系统类型提示

* fix&perf: 修复和优化插件系统

* fix: 修复gitignore规则误忽略插件文件的问题

* fix: 修复运行时插件根路径算错的问题

* fix: 加强插件发现和路由注册的防护措施

* revert: 回滚定时任务白名单

* fix: 移除未使用的应用路由注册探测

* revert: 恢复部分代码

* perf: 优化插件系统

* docs: 新增插件开发文档

* perf: 优化插件管理模块

* perf: 提升插件系统核心能力

* refactor: 重构生命周期 step runner

* fix: 修复lint错误

* test: 清理测试用例

* test: 调整测试目录名称

* fix: 修复前后端目录硬编码的问题

* fix: 修复插件系统安全性缺口

* refactor: 重新设计插件生命周期 Migration 事务与回滚

* perf: 优化插件系统边界问题

* refactor: 重构当前插件系统的依赖体系设计

* perf: 优化代码

* perf: 优化代码

* fix: 修复代码合并问题

* fix: 修复bug

* perf: 优化代码

* perf&fix: 优化代码和修复bug

* docs: 优化文档格式

* feat: 适配Vue2版本

* docs: 更新README文档

* fix: 修复ruff lint错误

* chore: 更新后端依赖文件
2026-07-28 20:35:18 +08:00

1045 lines
33 KiB
Python

import base64
import hashlib
import re
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any
import yaml
from cli.exit_codes import RUNTIME_ERROR, SUCCESS
from plugins.core.utils import validate_plugin_id_value
DEPENDENCY_OPERATOR_PATTERN = re.compile(r'==|!=|>=|<=|=|>|<|\^|~')
PYTHON_PACKAGE_SEPARATOR_PATTERN = re.compile(r'[-_.]+')
NPM_LOCKFILE_INTEGRITY_ALGORITHM = 'sha512'
PLUGIN_DEPENDENCY_ALLOWLIST_EXAMPLE_YAML = """# 插件外部依赖允许列表示例。
#
# 使用方式:
# 1. 将本文件保存为 plugin_dependency_allowlist.yaml。
# 2. 按团队实际批准的 Python/npm 包和版本范围调整。
# 3. 在 .env.* 中配置 PLUGIN_DEPENDENCY_ALLOWLIST 指向该文件。
#
# 版本范围应尽量写成可证明的连续闭合范围,例如 ">=2.0.0,<3.0.0"。
# 未声明上界、通配版本、!=、^、~ 等无法证明完全包含的约束会被策略保守阻断。
python:
openai:
versions:
- ">=2.0.0,<3.0.0"
source: internal-pypi
reason: 示例:仅允许 OpenAI SDK 2.x
requests:
versions:
- ">=2.32.0,<3.0.0"
source: internal-pypi
reason: 示例:允许 requests 2.x 安全维护版本
npm:
dayjs:
versions:
- ">=1.11.0,<2.0.0"
source: internal-npm
reason: 示例:允许 dayjs 1.x
npmDev:
vitest:
versions:
- ">=3.0.0,<4.0.0"
source: internal-npm
reason: 示例:仅允许开发依赖测试工具
"""
@dataclass(frozen=True)
class PluginTestTarget:
"""
插件测试目标。
:param kind: 测试目标类型
:param target_path: 测试目标路径
:param command: 测试执行命令
:param workdir: 命令工作目录
:param timeout: 命令超时时间
"""
kind: str
target_path: Path
command: list[str]
workdir: Path
timeout: int
@dataclass(frozen=True)
class PluginTestCommandResultPayload:
"""
插件测试命令执行结果负载。
"""
completed: Any
def to_payload(self) -> dict[str, Any]:
"""
序列化为既有 CLI payload 契约。
:return: 插件测试命令执行结果负载
"""
return {
'returnCode': self.completed.returncode,
'stdout': self.completed.stdout[-4000:] if self.completed.stdout else '',
'stderr': self.completed.stderr[-4000:] if self.completed.stderr else '',
}
@dataclass(frozen=True)
class PluginTestResultItemPayload:
"""
插件测试单目标执行结果负载。
"""
target: PluginTestTarget
completed: Any
def to_payload(self) -> dict[str, Any]:
"""
序列化为既有 CLI payload 契约。
:return: 插件测试单目标执行结果负载
"""
return {
'kind': self.target.kind,
'target': str(self.target.target_path),
'command': self.target.command,
'workdir': str(self.target.workdir),
'test': PluginTestCommandResultPayload(self.completed).to_payload(),
}
@dataclass(frozen=True)
class PluginTestMissingPayload:
"""
插件测试目标缺失负载。
"""
plugin_id: str
expected_paths: list[Path]
def to_payload(self) -> dict[str, Any]:
"""
序列化为既有 CLI payload 契约。
:return: 插件测试目标缺失负载
"""
return {
'ok': False,
'message': '插件测试目录不存在',
'pluginId': self.plugin_id,
'targets': [str(path) for path in self.expected_paths],
}
@dataclass(frozen=True)
class PluginTestExecutionPayload:
"""
插件测试执行结果负载。
"""
plugin_id: str
keyword: str
maxfail: int
quiet: bool
frontend_build: bool
results: list[dict[str, Any]]
def to_payload(self) -> dict[str, Any]:
"""
序列化为既有 CLI payload 契约。
:return: 插件测试执行结果负载
"""
ok = all(item['test']['returnCode'] == 0 for item in self.results)
return {
'ok': ok,
'message': '插件测试执行完成' if ok else '插件测试执行失败',
'pluginId': self.plugin_id,
'targets': [item['target'] for item in self.results],
'keyword': self.keyword,
'maxfail': self.maxfail,
'quiet': self.quiet,
'frontendBuild': self.frontend_build,
'results': self.results,
'test': self.results[0]['test'] if len(self.results) == 1 else None,
'command': self.results[0]['command'] if len(self.results) == 1 else None,
}
@dataclass(frozen=True)
class CliPluginRuntimeExceptionPayload:
"""
CLI 插件运行时异常负载。
"""
exception_payload: dict[str, Any]
failure_code: int = RUNTIME_ERROR
def to_payload(self) -> dict[str, Any]:
"""
序列化为既有 CLI payload 契约。
:return: CLI 插件运行时异常负载
"""
return {**self.exception_payload, 'exit_code': self.failure_code}
@dataclass(frozen=True)
class CliPluginRuntimeExitCodePayload:
"""
CLI 插件运行时退出码负载。
"""
payload: dict[str, Any]
success_code: int = SUCCESS
failure_code: int = RUNTIME_ERROR
def to_payload(self) -> dict[str, Any]:
"""
序列化为既有 CLI payload 契约。
:return: 带退出码的 CLI 插件运行时负载
"""
return {**self.payload, 'exit_code': self.success_code if self.payload.get('ok') else self.failure_code}
@dataclass(frozen=True)
class PluginDependencyLockfileTemplate:
"""
插件依赖锁文件模板。
:param plugin_id: 插件ID
:param plugin_version: 插件版本
:param generated_at: 生成时间
:param python: Python 依赖锁定项
:param npm: npm 依赖锁定项
:param npm_dev: npmDev 依赖锁定项
:param artifact_count: 已从离线制品反填的依赖项数量
:param warnings: 生成过程告警
"""
plugin_id: str
plugin_version: str
generated_at: str
python: list[dict[str, object]]
npm: list[dict[str, object]]
npm_dev: list[dict[str, object]]
artifact_count: int = 0
warnings: list[str] = field(default_factory=list)
@property
def entry_count(self) -> int:
"""
获取锁文件模板依赖项数量。
:return: 依赖项数量
"""
return len(self.python) + len(self.npm) + len(self.npm_dev)
def to_dict(self) -> dict[str, object]:
"""
序列化为锁文件 YAML 字典。
:return: 锁文件字典
"""
return {
'plugin': self.plugin_id,
'version': self.plugin_version,
'generatedAt': self.generated_at,
'python': self.python,
'npm': self.npm,
'npmDev': self.npm_dev,
}
def to_yaml(self) -> str:
"""
序列化为锁文件 YAML 文本。
:return: YAML 文本
"""
return yaml.safe_dump(self.to_dict(), allow_unicode=True, sort_keys=False)
class PluginDependencyLockfileTemplateBuilder:
"""
插件依赖锁文件模板构建器。
该构建器只根据 manifest 声明生成待补全模板,不联网解析真实版本或哈希。
"""
@classmethod
def build(cls, manifest: Any, *, offline_dir: Path | str | None = None) -> PluginDependencyLockfileTemplate:
"""
根据插件 manifest 构建锁文件模板。
:param manifest: 插件 manifest
:param offline_dir: 离线制品根目录
:return: 锁文件模板
"""
generated_at = datetime.now().astimezone().isoformat()
dependencies = manifest.dependencies
artifact_resolver = PluginDependencyOfflineArtifactResolver(offline_dir)
warnings: list[str] = []
artifact_count = 0
python_entries = []
npm_entries = []
npm_dev_entries = []
for requirement in dependencies.python:
entry, artifact_warning = cls._build_python_entry(requirement, artifact_resolver=artifact_resolver)
python_entries.append(entry)
artifact_count += 1 if entry.get('resolvedVersion') else 0
if artifact_warning:
warnings.append(artifact_warning)
for requirement in dependencies.npm:
entry, artifact_warning = cls._build_npm_entry(
requirement,
kind='npm',
artifact_resolver=artifact_resolver,
)
npm_entries.append(entry)
artifact_count += 1 if entry.get('resolvedVersion') else 0
if artifact_warning:
warnings.append(artifact_warning)
for requirement in dependencies.npm_dev:
entry, artifact_warning = cls._build_npm_entry(
requirement,
kind='npmDev',
artifact_resolver=artifact_resolver,
)
npm_dev_entries.append(entry)
artifact_count += 1 if entry.get('resolvedVersion') else 0
if artifact_warning:
warnings.append(artifact_warning)
return PluginDependencyLockfileTemplate(
plugin_id=manifest.id,
plugin_version=manifest.version,
generated_at=generated_at,
python=python_entries,
npm=npm_entries,
npm_dev=npm_dev_entries,
artifact_count=artifact_count,
warnings=warnings,
)
@staticmethod
def _extract_dependency_name(requirement: str) -> str:
"""
提取依赖包名。
:param requirement: 依赖声明
:return: 依赖包名
"""
from plugins.core.validation.dependencies import DependencyRequirementParser # noqa: PLC0415
normalized_requirement = requirement.strip()
operator_match = DEPENDENCY_OPERATOR_PATTERN.search(normalized_requirement)
if operator_match:
package_name = normalized_requirement[: operator_match.start()].strip()
if package_name:
return package_name
return DependencyRequirementParser.parse(requirement).name
@classmethod
def _build_python_entry(
cls,
requirement: str,
*,
artifact_resolver: 'PluginDependencyOfflineArtifactResolver',
) -> tuple[dict[str, object], str | None]:
"""
构建 Python 锁文件模板项。
:param requirement: Python 依赖声明
:param artifact_resolver: 离线制品解析器
:return: 锁文件模板项和告警
"""
name = cls._extract_dependency_name(requirement)
artifact_match, artifact_warning = artifact_resolver.resolve_python(name, requirement)
return {
'name': name,
'requirement': requirement,
'resolvedVersion': artifact_match.version if artifact_match else '',
'hashes': artifact_match.hashes if artifact_match else [],
}, artifact_warning
@classmethod
def _build_npm_entry(
cls,
requirement: str,
*,
kind: str,
artifact_resolver: 'PluginDependencyOfflineArtifactResolver',
) -> tuple[dict[str, object], str | None]:
"""
构建 npm 锁文件模板项。
:param requirement: npm 依赖声明
:param kind: npm 依赖类型
:param artifact_resolver: 离线制品解析器
:return: 锁文件模板项和告警
"""
name = cls._extract_dependency_name(requirement)
artifact_match, artifact_warning = artifact_resolver.resolve_npm(kind, name, requirement)
return {
'name': name,
'requirement': requirement,
'resolvedVersion': artifact_match.version if artifact_match else '',
'integrity': artifact_match.integrity if artifact_match else '',
}, artifact_warning
@dataclass(frozen=True)
class PluginDependencyOfflineArtifactMatch:
"""
离线制品匹配结果。
:param path: 制品路径
:param version: 制品版本
:param hashes: Python 制品哈希
:param integrity: npm 制品 SRI
"""
path: Path
version: str
hashes: list[str] = field(default_factory=list)
integrity: str = ''
class PluginDependencyOfflineArtifactResolver:
"""
插件离线依赖制品解析器。
"""
def __init__(self, offline_dir: Path | str | None = None) -> None:
"""
初始化离线依赖制品解析器。
:param offline_dir: 离线制品根目录
:return: None
"""
self.offline_dir = Path(offline_dir) if offline_dir else None
def resolve_python(
self,
name: str,
requirement: str,
) -> tuple[PluginDependencyOfflineArtifactMatch | None, str | None]:
"""
匹配 Python 离线制品并生成哈希。
:param name: 依赖包名
:param requirement: 依赖声明
:return: 制品匹配结果和告警
"""
if self.offline_dir is None:
return None, None
artifact_dir = self._artifact_dir('python')
candidates = (
[
(artifact_path, version)
for artifact_path in sorted(artifact_dir.iterdir())
if (version := self._extract_python_artifact_version(artifact_path, name))
]
if artifact_dir is not None
else []
)
return self._build_python_match('python', name, requirement, candidates)
def resolve_npm(
self,
kind: str,
name: str,
requirement: str,
) -> tuple[PluginDependencyOfflineArtifactMatch | None, str | None]:
"""
匹配 npm 离线制品并生成 SRI。
:param kind: 依赖类型
:param name: 依赖包名
:param requirement: 依赖声明
:return: 制品匹配结果和告警
"""
if self.offline_dir is None:
return None, None
artifact_dir = self._artifact_dir('npm')
normalized_name = self._normalize_npm_artifact_name(name)
candidates = (
[
(artifact_path, version)
for artifact_path in sorted(artifact_dir.glob(f'{normalized_name}-*.tgz'))
if (version := self._extract_npm_artifact_version(artifact_path, normalized_name))
]
if artifact_dir is not None
else []
)
return self._build_npm_match(kind, name, requirement, candidates)
def _artifact_dir(self, kind: str) -> Path | None:
"""
获取离线制品分类目录。
:param kind: 制品分类
:return: 制品目录
"""
if self.offline_dir is None:
return None
artifact_dir = self.offline_dir / kind
return artifact_dir if artifact_dir.is_dir() else None
def _build_python_match(
self,
kind: str,
name: str,
requirement: str,
candidates: list[tuple[Path, str]],
) -> tuple[PluginDependencyOfflineArtifactMatch | None, str | None]:
"""
构建 Python 制品匹配结果。
:param kind: 依赖类型
:param name: 依赖包名
:param requirement: 依赖声明
:param candidates: 候选制品
:return: 制品匹配结果和告警
"""
artifact_path, version, warning = self._select_artifact(kind, name, requirement, candidates)
if artifact_path is None or version is None:
return None, warning
artifact_hash = hashlib.sha256(artifact_path.read_bytes()).hexdigest()
return PluginDependencyOfflineArtifactMatch(
path=artifact_path,
version=version,
hashes=[f'sha256:{artifact_hash}'],
), None
def _build_npm_match(
self,
kind: str,
name: str,
requirement: str,
candidates: list[tuple[Path, str]],
) -> tuple[PluginDependencyOfflineArtifactMatch | None, str | None]:
"""
构建 npm 制品匹配结果。
:param kind: 依赖类型
:param name: 依赖包名
:param requirement: 依赖声明
:param candidates: 候选制品
:return: 制品匹配结果和告警
"""
artifact_path, version, warning = self._select_artifact(kind, name, requirement, candidates)
if artifact_path is None or version is None:
return None, warning
integrity_digest = base64.b64encode(
hashlib.new(NPM_LOCKFILE_INTEGRITY_ALGORITHM, artifact_path.read_bytes()).digest()
).decode('ascii')
return PluginDependencyOfflineArtifactMatch(
path=artifact_path,
version=version,
integrity=f'{NPM_LOCKFILE_INTEGRITY_ALGORITHM}-{integrity_digest}',
), None
def _select_artifact(
self,
kind: str,
name: str,
requirement: str,
candidates: list[tuple[Path, str]],
) -> tuple[Path | None, str | None, str | None]:
"""
从候选制品中选择一个满足声明的制品。
:param kind: 依赖类型
:param name: 依赖包名
:param requirement: 依赖声明
:param candidates: 候选制品
:return: 制品路径、版本和告警
"""
if not candidates:
return None, None, f'未找到离线制品:{kind} {name}'
matching_candidates = [
(artifact_path, version)
for artifact_path, version in candidates
if self._artifact_version_satisfies_requirement(version, requirement)
]
if not matching_candidates:
candidate_versions = ', '.join(version for _, version in candidates)
return None, None, f'离线制品版本不满足声明:{kind} {name} {candidate_versions}'
if len(matching_candidates) > 1:
return None, None, f'离线制品不唯一:{kind} {name}'
artifact_path, version = matching_candidates[0]
return artifact_path, version, None
@staticmethod
def _artifact_version_satisfies_requirement(version: str, requirement: str) -> bool:
"""
判断制品版本是否满足 manifest 声明。
:param version: 制品版本
:param requirement: 依赖声明
:return: 是否满足声明
"""
operator_match = DEPENDENCY_OPERATOR_PATTERN.search(requirement.strip())
if operator_match is None:
return True
version_range = requirement[operator_match.start() :]
from plugins.core.validation.dependency_policy import version_satisfies_range # noqa: PLC0415
return version_satisfies_range(version, version_range)
@classmethod
def _extract_python_artifact_version(cls, artifact_path: Path, name: str) -> str | None:
"""
从 Python wheel/sdist 文件名中提取版本。
:param artifact_path: 制品路径
:param name: 依赖包名
:return: 制品版本
"""
if artifact_path.suffix == '.whl':
return cls._extract_python_version_from_parts(artifact_path.stem.split('-'), name)
if artifact_path.name.endswith('.tar.gz'):
return cls._extract_python_version_from_parts(artifact_path.name.removesuffix('.tar.gz').split('-'), name)
return None
@classmethod
def _extract_python_version_from_parts(cls, parts: list[str], name: str) -> str | None:
"""
从 Python 制品文件名片段中提取版本。
:param parts: 文件名按短横线分隔后的片段
:param name: 依赖包名
:return: 制品版本
"""
normalized_name = cls._normalize_python_artifact_name(name)
for index in range(1, len(parts)):
candidate_name = '-'.join(parts[:index])
if cls._normalize_python_artifact_name(candidate_name) == normalized_name:
return parts[index] or None
return None
@staticmethod
def _extract_npm_artifact_version(artifact_path: Path, normalized_name: str) -> str | None:
"""
从 npm tgz 文件名中提取版本。
:param artifact_path: 制品路径
:param normalized_name: 归一化 npm 制品包名
:return: 制品版本
"""
stem = artifact_path.name.removesuffix('.tgz')
expected_prefix = f'{normalized_name}-'
if not stem.startswith(expected_prefix):
return None
return stem.removeprefix(expected_prefix) or None
@staticmethod
def _normalize_python_artifact_name(name: str) -> str:
"""
归一化 Python 制品名。
:param name: 包名或文件名片段
:return: 归一化名称
"""
return PYTHON_PACKAGE_SEPARATOR_PATTERN.sub('-', name.strip()).lower()
@staticmethod
def _normalize_npm_artifact_name(name: str) -> str:
"""
归一化 npm 制品名。
:param name: 包名
:return: 归一化名称
"""
return name.strip().lower().replace('/', '-').lstrip('@')
class PluginDependencyLockPayloadBuilder:
"""
插件依赖锁文件模板 payload 构建器。
"""
@staticmethod
def build_success_payload(
plugin_id: str,
lockfile_template: PluginDependencyLockfileTemplate,
output_path: Path,
*,
dry_run: bool,
written: bool,
overwritten: bool,
) -> dict[str, object]:
"""
构建锁文件模板生成成功 payload。
:param plugin_id: 插件ID
:param lockfile_template: 锁文件模板
:param output_path: 输出路径
:param dry_run: 是否仅预演
:param written: 是否已写入文件
:param overwritten: 是否覆盖了已有文件
:return: payload
"""
return CliPluginRuntimeExitCodePayload(
{
'ok': True,
'message': '插件依赖锁文件模板生成完成',
'pluginId': plugin_id,
'dryRun': dry_run,
'outputFile': str(output_path),
'written': written,
'overwritten': overwritten,
'entryCount': lockfile_template.entry_count,
'artifactCount': lockfile_template.artifact_count,
'lockfile': lockfile_template.to_yaml(),
'warnings': lockfile_template.warnings
or (
[]
if lockfile_template.artifact_count == lockfile_template.entry_count
else [
'锁文件模板尚未包含 resolvedVersion、hashes 或 integrity,需人工或 CI 补全后才能用于 locked/offline 策略。'
]
),
},
success_code=SUCCESS,
failure_code=RUNTIME_ERROR,
).to_payload()
@staticmethod
def build_exists_payload(plugin_id: str, output_path: Path) -> dict[str, object]:
"""
构建锁文件已存在 payload。
:param plugin_id: 插件ID
:param output_path: 输出路径
:return: payload
"""
return CliPluginRuntimeExitCodePayload(
{
'ok': False,
'message': '插件依赖锁文件已存在,请传入 --overwrite 覆盖',
'pluginId': plugin_id,
'dryRun': False,
'outputFile': str(output_path),
'written': False,
},
success_code=SUCCESS,
failure_code=RUNTIME_ERROR,
).to_payload()
@staticmethod
def build_not_found_payload(plugin_id: str) -> dict[str, object]:
"""
构建插件不存在 payload。
:param plugin_id: 插件ID
:return: payload
"""
return CliPluginRuntimeExitCodePayload(
{
'ok': False,
'message': '插件不存在',
'pluginId': plugin_id,
'dryRun': False,
'written': False,
},
success_code=SUCCESS,
failure_code=RUNTIME_ERROR,
).to_payload()
class PluginDependencyAllowlistExamplePayloadBuilder:
"""
插件依赖允许列表示例 payload 构建器。
"""
@staticmethod
def build_success_payload(
output_path: Path,
*,
allowlist_text: str,
dry_run: bool,
written: bool,
overwritten: bool,
) -> dict[str, object]:
"""
构建允许列表示例生成成功 payload。
:param output_path: 输出路径
:param allowlist_text: 允许列表 YAML 文本
:param dry_run: 是否仅预演
:param written: 是否已写入文件
:param overwritten: 是否覆盖了已有文件
:return: payload
"""
return CliPluginRuntimeExitCodePayload(
{
'ok': True,
'message': '插件依赖允许列表示例生成完成',
'dryRun': dry_run,
'outputFile': str(output_path),
'written': written,
'overwritten': overwritten,
'allowlist': allowlist_text,
},
success_code=SUCCESS,
failure_code=RUNTIME_ERROR,
).to_payload()
@staticmethod
def build_exists_payload(output_path: Path) -> dict[str, object]:
"""
构建允许列表示例文件已存在 payload。
:param output_path: 输出路径
:return: payload
"""
return CliPluginRuntimeExitCodePayload(
{
'ok': False,
'message': '插件依赖允许列表文件已存在,请传入 --overwrite 覆盖',
'dryRun': False,
'outputFile': str(output_path),
'written': False,
},
success_code=SUCCESS,
failure_code=RUNTIME_ERROR,
).to_payload()
class PluginTestPlanBuilder:
"""
插件 CLI 测试计划构建器。
使用 Builder 模式发现后端 pytest 与前端 node 测试目标,并生成可执行命令。
"""
def __init__(
self,
*,
backend_root: Path,
frontend_root: Path,
python_executable: str,
node_executable: str = 'node',
timeout: int = 120,
) -> None:
"""
初始化插件 CLI 测试计划构建器。
:param backend_root: 后端项目根目录
:param frontend_root: 前端项目根目录
:param python_executable: Python 解释器
:param node_executable: Node.js 可执行命令
:param timeout: 命令超时时间
:return: None
"""
self.backend_root = backend_root
self.frontend_root = frontend_root
self.python_executable = python_executable
self.node_executable = node_executable
self.timeout = timeout
def build(
self,
plugin_id: str,
*,
keyword: str = '',
maxfail: int = 0,
quiet: bool = False,
frontend_build: bool = False,
) -> list[PluginTestTarget]:
"""
构建插件测试目标列表。
:param plugin_id: 插件ID
:param keyword: pytest `-k` 过滤表达式
:param maxfail: pytest 最大失败数
:param quiet: pytest 是否启用简洁输出
:param frontend_build: 是否追加前端构建验收目标
:return: 插件测试目标列表
"""
validate_plugin_id_value(plugin_id)
targets = []
backend_target = self.backend_root / 'tests' / 'plugins' / plugin_id
if backend_target.exists():
targets.append(
PluginTestTarget(
kind='backend',
target_path=backend_target,
command=self._build_backend_command(
backend_target,
keyword=keyword,
maxfail=maxfail,
quiet=quiet,
),
workdir=self.backend_root,
timeout=self.timeout,
)
)
frontend_target = self.frontend_root / 'tests' / 'plugins' / plugin_id
if frontend_target.exists():
targets.extend(
[
PluginTestTarget(
kind='frontend',
target_path=test_file,
command=[self.node_executable, str(test_file)],
workdir=self.frontend_root,
timeout=self.timeout,
)
for test_file in sorted(frontend_target.glob('*.test.js'))
]
)
if frontend_build and self.frontend_root.exists():
targets.append(
PluginTestTarget(
kind='frontend-build',
target_path=self.frontend_root,
command=['npm', 'run', 'build:stage'],
workdir=self.frontend_root,
timeout=max(self.timeout, 300),
)
)
return targets
def expected_paths(self, plugin_id: str) -> list[Path]:
"""
获取插件测试约定目录列表。
:param plugin_id: 插件ID
:return: 插件测试约定目录列表
"""
return [
self.backend_root / 'tests' / 'plugins' / plugin_id,
self.frontend_root / 'tests' / 'plugins' / plugin_id,
]
def _build_backend_command(
self,
target_path: Path,
*,
keyword: str,
maxfail: int,
quiet: bool,
) -> list[str]:
"""
构建插件 pytest 命令。
:param target_path: 测试目标路径
:param keyword: pytest `-k` 过滤表达式
:param maxfail: 最大失败数
:param quiet: 是否启用简洁输出
:return: pytest 命令参数列表
"""
command = [self.python_executable, '-m', 'pytest']
if quiet:
command.append('-q')
if keyword:
command.extend(['-k', keyword])
if maxfail > 0:
command.append(f'--maxfail={maxfail}')
command.append(str(target_path))
return command
class PluginTestPayloadBuilder:
"""
插件 CLI 测试负载构建器。
使用 Builder 模式将测试目标和命令结果转换为稳定命令负载。
"""
@staticmethod
def build_command_result(completed: Any) -> dict[str, Any]:
"""
构建 CLI 系统命令执行结果负载。
:param completed: 命令执行结果
:return: 系统命令执行结果负载
"""
return PluginTestCommandResultPayload(completed).to_payload()
@staticmethod
def build_result_item(target: PluginTestTarget, completed: Any) -> dict[str, Any]:
"""
构建单个插件测试结果项。
:param target: 插件测试目标
:param completed: 命令执行结果
:return: 插件测试结果项
"""
return PluginTestResultItemPayload(target, completed).to_payload()
@staticmethod
def with_exit_code(
payload: dict[str, Any],
*,
success_code: int = SUCCESS,
failure_code: int = RUNTIME_ERROR,
) -> dict[str, Any]:
"""
为插件测试负载补充退出码。
:param payload: 插件测试负载
:param success_code: 成功退出码
:param failure_code: 失败退出码
:return: 带退出码的插件测试负载
"""
return CliPluginRuntimeExitCodePayload(
payload,
success_code=success_code,
failure_code=failure_code,
).to_payload()
@staticmethod
def build_missing_payload(plugin_id: str, expected_paths: list[Path]) -> dict[str, Any]:
"""
构建插件测试目标缺失负载。
:param plugin_id: 插件ID
:param expected_paths: 约定测试目录列表
:return: 插件测试目标缺失负载
"""
return PluginTestMissingPayload(plugin_id, expected_paths).to_payload()
@staticmethod
def build_execution_payload(
plugin_id: str,
*,
keyword: str,
maxfail: int,
quiet: bool,
frontend_build: bool,
results: list[dict[str, Any]],
) -> dict[str, Any]:
"""
构建插件测试执行结果负载。
:param plugin_id: 插件ID
:param keyword: pytest `-k` 过滤表达式
:param maxfail: 最大失败数
:param quiet: 是否启用简洁输出
:param frontend_build: 是否执行前端构建验收
:param results: 测试结果项列表
:return: 插件测试执行结果负载
"""
return PluginTestExecutionPayload(
plugin_id=plugin_id,
keyword=keyword,
maxfail=maxfail,
quiet=quiet,
frontend_build=frontend_build,
results=results,
).to_payload()