342 lines
13 KiB
Python
342 lines
13 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""文档与 pytest 配置守护:防止模板标准退化。"""
|
|
from __future__ import annotations
|
|
|
|
import configparser
|
|
import os
|
|
import re
|
|
import unittest
|
|
|
|
from _support import get_skill_root
|
|
|
|
|
|
FORBIDDEN_README_DESCRIPTION_TERMS_EN = (
|
|
"cli",
|
|
"schema",
|
|
"references",
|
|
"development",
|
|
"playwright",
|
|
"python",
|
|
"dom",
|
|
"rpa",
|
|
)
|
|
|
|
FORBIDDEN_README_DESCRIPTION_TERMS_ZH = (
|
|
"Agent 参考索引",
|
|
"契约",
|
|
"数据结构",
|
|
"开发规范",
|
|
)
|
|
|
|
|
|
def _description_has_forbidden_term(description: str) -> str | None:
|
|
lowered = description.lower()
|
|
for term in FORBIDDEN_README_DESCRIPTION_TERMS_EN:
|
|
if term in lowered:
|
|
return term
|
|
for term in FORBIDDEN_README_DESCRIPTION_TERMS_ZH:
|
|
if term in description:
|
|
return term
|
|
return None
|
|
|
|
|
|
def _extract_workflow_uses_line(text: str) -> str | None:
|
|
for line in text.splitlines():
|
|
stripped = line.strip()
|
|
if stripped.startswith("uses:"):
|
|
return stripped
|
|
return None
|
|
|
|
|
|
class TestDocsStandards(unittest.TestCase):
|
|
def _read(self, rel_path: str) -> str:
|
|
path = os.path.join(get_skill_root(), rel_path)
|
|
with open(path, encoding="utf-8") as f:
|
|
return f.read()
|
|
|
|
def _parse_frontmatter(self, text: str) -> str:
|
|
parts = text.split("---", 2)
|
|
if len(parts) < 2:
|
|
return ""
|
|
return parts[1]
|
|
|
|
def test_root_readme_has_yaml_frontmatter(self) -> None:
|
|
text = self._read("README.md")
|
|
self.assertTrue(text.startswith("---"), "README.md must start with YAML frontmatter")
|
|
parts = text.split("---", 2)
|
|
self.assertGreaterEqual(len(parts), 3, "README.md frontmatter must be closed")
|
|
|
|
def test_root_readme_frontmatter_has_description(self) -> None:
|
|
frontmatter = self._parse_frontmatter(self._read("README.md"))
|
|
self.assertIn("description:", frontmatter, "README.md frontmatter must include description")
|
|
|
|
def test_root_readme_description_avoids_technical_index_terms(self) -> None:
|
|
frontmatter = self._parse_frontmatter(self._read("README.md"))
|
|
match = re.search(r"description:\s*(.+)", frontmatter)
|
|
self.assertIsNotNone(match, "README.md description field not found")
|
|
description = match.group(1).strip().strip("\"'")
|
|
forbidden = _description_has_forbidden_term(description)
|
|
self.assertIsNone(
|
|
forbidden,
|
|
msg=f"README.md description must not contain technical index term {forbidden!r}",
|
|
)
|
|
|
|
def test_description_forbidden_terms_are_case_insensitive_for_english(self) -> None:
|
|
fail_cases = (
|
|
"CLI 契约说明",
|
|
"cli 工具入口",
|
|
"SCHEMA 定义",
|
|
"Schema 映射",
|
|
"schema 字段",
|
|
"Playwright 自动化",
|
|
"python 脚本",
|
|
"RPA 流程",
|
|
)
|
|
pass_cases = (
|
|
"用用户能理解的方式说明这个技能能做什么、适合什么场景,以及如何开始使用。",
|
|
"帮你自动整理订单并生成处理报告",
|
|
"适合每天需要重复核对的业务场景",
|
|
)
|
|
for description in fail_cases:
|
|
with self.subTest(description=description):
|
|
self.assertIsNotNone(
|
|
_description_has_forbidden_term(description),
|
|
msg=f"expected forbidden term in {description!r}",
|
|
)
|
|
for description in pass_cases:
|
|
with self.subTest(description=description):
|
|
self.assertIsNone(
|
|
_description_has_forbidden_term(description),
|
|
msg=f"expected no forbidden term in {description!r}",
|
|
)
|
|
|
|
def test_references_readme_has_no_yaml_frontmatter(self) -> None:
|
|
text = self._read("references/README.md")
|
|
self.assertFalse(
|
|
text.lstrip().startswith("---"),
|
|
"references/README.md must not contain YAML frontmatter",
|
|
)
|
|
|
|
def test_references_readme_has_no_description_field(self) -> None:
|
|
text = self._read("references/README.md")
|
|
self.assertNotRegex(
|
|
text,
|
|
r"(?m)^description\s*:",
|
|
msg="references/README.md must not contain description field",
|
|
)
|
|
|
|
def test_skill_md_requires_read_readme_first_rule(self) -> None:
|
|
text = self._read("SKILL.md")
|
|
self.assertIn("README.md", text)
|
|
self.assertTrue(
|
|
"优先读取" in text or "优先读" in text,
|
|
msg="SKILL.md must require reading README.md first for user-facing questions",
|
|
)
|
|
|
|
def test_skill_md_states_readme_is_for_users(self) -> None:
|
|
text = self._read("SKILL.md")
|
|
self.assertIn("README.md", text)
|
|
self.assertTrue(
|
|
any(marker in text for marker in ("面向用户", "普通用户", "用户市场")),
|
|
msg="SKILL.md must state README.md is user-facing documentation",
|
|
)
|
|
|
|
def test_release_workflow_uses_main_channel(self) -> None:
|
|
text = self._read(".github/workflows/release_skill.yaml")
|
|
uses_line = _extract_workflow_uses_line(text)
|
|
self.assertIsNotNone(uses_line, "release_skill.yaml must contain a uses: line")
|
|
self.assertIn(
|
|
"client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml",
|
|
uses_line,
|
|
)
|
|
self.assertRegex(
|
|
uses_line,
|
|
r"reusable-release-skill\.yaml@main\b",
|
|
msg="workflow uses must reference reusable-release-skill.yaml@main",
|
|
)
|
|
|
|
def test_rpa_md_covers_video_and_playwright_standards(self) -> None:
|
|
text = self._read("development/RPA.md")
|
|
self.assertIn("title", text)
|
|
self.assertIn("closing_title", text)
|
|
self.assertIn("中文", text)
|
|
self.assertIn("--no-sandbox", text)
|
|
self.assertIn("--disable-blink-features=AutomationControlled", text)
|
|
self.assertIn("RpaVideoSession", text)
|
|
self.assertIn("1.0.17", text)
|
|
|
|
def test_adapter_md_covers_four_tiers_and_config_get(self) -> None:
|
|
text = self._read("development/ADAPTER.md")
|
|
for marker in (
|
|
"mock",
|
|
"simulator_rpa",
|
|
"real_api",
|
|
"real_rpa",
|
|
"config.get",
|
|
"sibling_bridge",
|
|
):
|
|
self.assertIn(marker, text, msg=f"ADAPTER.md missing {marker!r}")
|
|
self.assertNotIn("ALLOW_REAL_API", text)
|
|
self.assertNotIn("ALLOW_WRITE_ACTIONS", text)
|
|
|
|
def test_cli_md_mentions_shared_python_runtime(self) -> None:
|
|
text = self._read("references/CLI.md")
|
|
self.assertIn("python-runtime", text)
|
|
self.assertIn("uv run python", text)
|
|
|
|
def test_testing_md_mentions_pytest_txt_collection_guard(self) -> None:
|
|
text = self._read("development/TESTING.md")
|
|
self.assertIn(".txt", text)
|
|
self.assertIn("pytest.ini", text)
|
|
|
|
def test_docs_do_not_claim_1_0_11_as_current_standard(self) -> None:
|
|
for rel in (
|
|
"SKILL.md",
|
|
"development/RUNTIME.md",
|
|
"development/RPA.md",
|
|
"development/CONFIG.md",
|
|
"development/TESTING.md",
|
|
):
|
|
text = self._read(rel)
|
|
self.assertNotIn(
|
|
">=1.0.11",
|
|
text.replace(" ", ""),
|
|
msg=f"{rel} still claims 1.0.11 as current minimum",
|
|
)
|
|
|
|
def test_scripts_has_no_legacy_example_artifacts(self) -> None:
|
|
scripts_dir = os.path.join(get_skill_root(), "scripts")
|
|
service_dir = os.path.join(scripts_dir, "service")
|
|
self.assertFalse(
|
|
os.path.isfile(os.path.join(service_dir, "example_browser_rpa.py")),
|
|
"scripts/service/example_browser_rpa.py must not exist",
|
|
)
|
|
self.assertFalse(
|
|
os.path.isdir(os.path.join(service_dir, "example_adapter")),
|
|
"scripts/service/example_adapter/ must not exist",
|
|
)
|
|
|
|
def test_gitignore_covers_python_runtime_artifacts(self) -> None:
|
|
text = self._read(".gitignore")
|
|
self.assertIn("__pycache__", text)
|
|
self.assertIn(".pytest_cache", text)
|
|
self.assertTrue(
|
|
"py[cod]" in text or ".pyc" in text,
|
|
msg=".gitignore must ignore Python bytecode (e.g. *.py[cod])",
|
|
)
|
|
|
|
def test_scripts_runtime_artifacts_not_tracked_by_git(self) -> None:
|
|
import subprocess
|
|
|
|
try:
|
|
out = subprocess.run(
|
|
["git", "ls-files", "scripts"],
|
|
cwd=get_skill_root(),
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
except (FileNotFoundError, subprocess.CalledProcessError):
|
|
self.skipTest("git not available or not a git repo")
|
|
tracked = out.stdout.splitlines()
|
|
for rel in tracked:
|
|
lowered = rel.replace("\\", "/").lower()
|
|
self.assertNotIn("__pycache__", lowered, msg=f"tracked cache path: {rel}")
|
|
self.assertNotIn(".pytest_cache", lowered, msg=f"tracked cache path: {rel}")
|
|
self.assertFalse(lowered.endswith(".pyc"), msg=f"tracked .pyc file: {rel}")
|
|
|
|
def test_examples_top_level_modes_present(self) -> None:
|
|
examples_dir = os.path.join(get_skill_root(), "examples")
|
|
for mode in ("real_api", "real_browser_rpa", "simulator_api", "simulator_browser_rpa"):
|
|
self.assertTrue(
|
|
os.path.isdir(os.path.join(examples_dir, mode)),
|
|
msg=f"examples/{mode}/ must exist",
|
|
)
|
|
|
|
def test_examples_are_not_full_skill_repos(self) -> None:
|
|
examples_dir = os.path.join(get_skill_root(), "examples")
|
|
for mode in os.listdir(examples_dir):
|
|
mode_path = os.path.join(examples_dir, mode)
|
|
if not os.path.isdir(mode_path):
|
|
continue
|
|
self.assertFalse(
|
|
os.path.isfile(os.path.join(mode_path, "SKILL.md")),
|
|
msg=f"examples/{mode}/SKILL.md must not exist",
|
|
)
|
|
self.assertFalse(
|
|
os.path.isdir(os.path.join(mode_path, "references")),
|
|
msg=f"examples/{mode}/references/ must not exist",
|
|
)
|
|
self.assertFalse(
|
|
os.path.isdir(os.path.join(mode_path, "development")),
|
|
msg=f"examples/{mode}/development/ must not exist",
|
|
)
|
|
self.assertFalse(
|
|
os.path.isdir(os.path.join(mode_path, ".github")),
|
|
msg=f"examples/{mode}/.github/ must not exist",
|
|
)
|
|
|
|
def test_examples_readme_states_copy_boundary(self) -> None:
|
|
text = self._read("examples/README.md")
|
|
self.assertIn("不是完整 skill 仓库", text)
|
|
self.assertIn("核心实现案例", text)
|
|
self.assertTrue(
|
|
"不要整包照搬" in text or "不要整包" in text,
|
|
msg="examples/README.md must warn against copying whole example tree",
|
|
)
|
|
self.assertTrue(
|
|
"scripts" in text and ("复制" in text or "copy map" in text.lower()),
|
|
msg="examples/README.md must describe copying into scripts/",
|
|
)
|
|
|
|
def test_placeholder_api_examples_are_not_implementation_references(self) -> None:
|
|
for rel in ("examples/real_api/README.md", "examples/simulator_api/README.md"):
|
|
text = self._read(rel)
|
|
self.assertTrue(
|
|
"占位" in text or "规划" in text,
|
|
msg=f"{rel} must state placeholder/planned status",
|
|
)
|
|
self.assertTrue(
|
|
"尚未沉淀" in text or "不可作为实现参考" in text or "不可" in text,
|
|
msg=f"{rel} must warn not to use as implementation reference",
|
|
)
|
|
|
|
def test_docs_do_not_reference_scripts_legacy_examples(self) -> None:
|
|
forbidden_fragments = (
|
|
"scripts/service/example_browser_rpa",
|
|
"scripts/service/example_adapter",
|
|
"example_browser_rpa.py",
|
|
"example_adapter/",
|
|
)
|
|
doc_paths = []
|
|
for root, _dirs, files in os.walk(get_skill_root()):
|
|
rel_root = os.path.relpath(root, get_skill_root())
|
|
if rel_root.startswith(".git"):
|
|
continue
|
|
for name in files:
|
|
if name.endswith(".md"):
|
|
doc_paths.append(os.path.join(rel_root, name).replace("\\", "/"))
|
|
for rel in doc_paths:
|
|
text = self._read(rel)
|
|
for fragment in forbidden_fragments:
|
|
self.assertNotIn(
|
|
fragment,
|
|
text,
|
|
msg=f"{rel} must not reference legacy scripts example path {fragment!r}",
|
|
)
|
|
|
|
|
|
class TestPytestIniCollection(unittest.TestCase):
|
|
def test_pytest_ini_exists_and_limits_python_files(self) -> None:
|
|
ini_path = os.path.join(get_skill_root(), "pytest.ini")
|
|
self.assertTrue(os.path.isfile(ini_path), "pytest.ini must exist at repo root")
|
|
parser = configparser.ConfigParser()
|
|
parser.read(ini_path, encoding="utf-8")
|
|
python_files = parser.get("pytest", "python_files", fallback="")
|
|
self.assertIn("test_*.py", python_files)
|
|
self.assertIn("*_test.py", python_files)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|