Files
skill-template/tests/test_actions_manifest.py
chendelian af7a43a702
All checks were successful
技能自动化发布 / release (push) Successful in 5s
chore: auto release commit (2026-07-14 11:56:45)
2026-07-14 11:56:46 +08:00

496 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""assets/actions.json 结构与 CLI 映射守护(通用模板,不含具体业务技能名称)。"""
from __future__ import annotations
import json
import os
import re
import sqlite3
import unittest
from _support import IsolatedDataRoot, get_skill_root
from cli.app import build_parser
from util.constants import SKILL_SLUG
_TEMPLATE_PLACEHOLDER_SLUG = "your-skill-slug"
_ALLOWED_PLACEMENTS = frozenset({"toolbar", "row", "batch", "cron", "agent", "skill-detail"})
_RESERVED_PLACEMENTS = frozenset({"row", "batch"})
_RESERVED_ACTION_FIELDS = frozenset({"concurrency", "locks"})
_ALLOWED_EXECUTION_PROFILES = frozenset({"sync", "async"})
_REQUIRED_MANIFEST_KEYS = frozenset({"schemaVersion", "skill", "actions"})
_REQUIRED_ACTION_KEYS = frozenset(
{"id", "label", "description", "placements", "entrypoint", "executionProfile"}
)
_TABLE_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$")
_FORBIDDEN_BUSINESS_TERMS = (
"wechat",
"微信",
"contacts",
"wxid",
"account_wxid",
"contact.db",
"scrape-contacts",
"douyin",
"抖音",
"xiaohongshu",
"小红书",
"公众号",
"logistics",
"物流",
)
_TEMPLATE_ARG_PATTERN = re.compile(r"\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}")
def _load_actions_manifest() -> dict:
path = os.path.join(get_skill_root(), "assets", "actions.json")
with open(path, encoding="utf-8") as f:
return json.load(f)
def _cli_subcommands() -> set[str]:
parser = build_parser()
subs = parser._subparsers._group_actions[0].choices # type: ignore[attr-defined]
return set(subs.keys())
def _collect_template_vars(args: list) -> set[str]:
found: set[str] = set()
for item in args:
if not isinstance(item, str):
continue
for match in _TEMPLATE_ARG_PATTERN.finditer(item):
found.add(match.group(1))
return found
def _load_actions_schema() -> dict:
path = os.path.join(get_skill_root(), "assets", "schemas", "skill-actions.schema.json")
with open(path, encoding="utf-8") as f:
return json.load(f)
def _validate_bind_tables(action_id: str, bind: object) -> list[str]:
errors: list[str] = []
if not isinstance(bind, dict):
return [f"{action_id}: bind must be object"]
if set(bind.keys()) - {"tables"}:
errors.append(f"{action_id}: bind only allows tables, got {sorted(bind.keys())}")
tables = bind.get("tables")
if not isinstance(tables, list) or len(tables) < 1:
errors.append(f"{action_id}: bind.tables must be non-empty array")
return errors
seen: set[str] = set()
for table in tables:
if not isinstance(table, str) or not table.strip():
errors.append(f"{action_id}: bind.tables entries must be non-empty strings")
continue
if table != table.strip() or not _TABLE_NAME_PATTERN.fullmatch(table):
errors.append(f"{action_id}: bind.tables entry {table!r} must be snake_case")
if table in seen:
errors.append(f"{action_id}: bind.tables duplicate {table!r}")
seen.add(table)
return errors
def _load_bindable_business_tables() -> set[str]:
"""在隔离临时数据根下 init_db返回可绑定到 toolbar 的业务表集合。
表必须同时满足:
- SQLite 物理表存在(排除 sqlite_* 与 _jiangchang_* 元数据表)
- ``_jiangchang_tables`` 中有对应行且 ``visible=1``
失败时抛出 RuntimeError带诊断不得静默返回空集供测试 skip。
"""
from db.connection import init_db
from db.display_metadata import METADATA_TABLES
from util.runtime_paths import get_db_path
physical: set[str] = set()
visible_meta: set[str] = set()
try:
with IsolatedDataRoot() as data_root:
init_db()
db_path = get_db_path()
if not os.path.isfile(db_path):
raise RuntimeError(
f"init_db did not create database file under isolated root "
f"{data_root!r}: expected {db_path}"
)
conn = sqlite3.connect(db_path)
try:
cur = conn.cursor()
cur.execute(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name NOT LIKE 'sqlite_%'"
)
physical = {
str(row[0])
for row in cur.fetchall()
if str(row[0]) not in METADATA_TABLES
}
cur.execute(
"SELECT table_name FROM _jiangchang_tables WHERE visible = 1"
)
visible_meta = {str(row[0]) for row in cur.fetchall()}
finally:
conn.close()
bindable = physical & visible_meta
if not bindable:
raise RuntimeError(
"isolated init_db produced no bindable tables "
f"(physical={sorted(physical)}, visible_meta={sorted(visible_meta)}, "
f"db_path={db_path!r})"
)
return bindable
except RuntimeError:
raise
except Exception as exc:
raise RuntimeError(
"failed to load bindable business tables via isolated init_db: "
f"{type(exc).__name__}: {exc}"
) from exc
class TestActionsManifest(unittest.TestCase):
def test_manifest_exists_and_schema(self) -> None:
manifest = _load_actions_manifest()
self.assertEqual(manifest.get("schemaVersion"), 1)
skill = manifest.get("skill")
self.assertIn(skill, (SKILL_SLUG, _TEMPLATE_PLACEHOLDER_SLUG))
self.assertIsInstance(manifest.get("actions"), list)
self.assertGreater(len(manifest["actions"]), 0)
def test_action_ids_unique_and_cli_valid(self) -> None:
manifest = _load_actions_manifest()
subs = _cli_subcommands()
seen: set[str] = set()
for action in manifest["actions"]:
action_id = action["id"]
self.assertNotIn(action_id, seen, msg=f"duplicate action id: {action_id}")
seen.add(action_id)
self.assertTrue(str(action.get("label", "")).strip(), msg=f"{action_id} label empty")
self.assertTrue(str(action.get("description", "")).strip(), msg=f"{action_id} description empty")
entry = action["entrypoint"]
self.assertEqual(entry["type"], "cli")
self.assertIn(entry["command"], subs, msg=f"{action_id} command not in CLI")
args = entry.get("args")
self.assertIsInstance(args, list, msg=f"{action_id} args must be array")
for item in args:
self.assertIsInstance(
item,
(str, int, float, bool),
msg=f"{action_id} arg item must be scalar",
)
self.assertNotIsInstance(item, dict, msg=f"{action_id} arg must not be object")
self.assertIsNotNone(item, msg=f"{action_id} arg must not be null")
placements = action.get("placements")
self.assertIsInstance(placements, list, msg=f"{action_id} placements must be array")
self.assertGreater(len(placements), 0, msg=f"{action_id} placements empty")
for placement in placements:
self.assertIn(placement, _ALLOWED_PLACEMENTS, msg=f"{action_id} invalid placement {placement!r}")
def test_every_action_declares_execution_profile(self) -> None:
manifest = _load_actions_manifest()
for action in manifest["actions"]:
profile = action.get("executionProfile")
self.assertIn(
profile,
_ALLOWED_EXECUTION_PROFILES,
msg=f"{action['id']} executionProfile must be sync|async, got {profile!r}",
)
def test_schema_requires_execution_profile_and_defines_bind(self) -> None:
schema = _load_actions_schema()
action_def = schema["$defs"]["action"]
self.assertIn("executionProfile", action_def.get("required") or [])
profile = action_def["properties"]["executionProfile"]
self.assertEqual(set(profile.get("enum") or []), {"sync", "async"})
self.assertIn("bind", schema["$defs"])
self.assertIn("bind", action_def["properties"])
bind_def = schema["$defs"]["bind"]
self.assertEqual(bind_def.get("required"), ["tables"])
self.assertIs(bind_def.get("additionalProperties"), False)
def test_manifest_validates_against_json_schema(self) -> None:
try:
import jsonschema
except ImportError:
self.skipTest("jsonschema not installed")
schema = _load_actions_schema()
manifest = _load_actions_manifest()
jsonschema.validate(instance=manifest, schema=schema)
def test_bind_structure_when_present(self) -> None:
manifest = _load_actions_manifest()
errors: list[str] = []
for action in manifest["actions"]:
if "bind" not in action:
continue
errors.extend(_validate_bind_tables(action["id"], action["bind"]))
self.assertEqual(errors, [], msg="\n".join(errors))
def test_toolbar_actions_require_bind_tables(self) -> None:
manifest = _load_actions_manifest()
errors: list[str] = []
for action in manifest["actions"]:
placements = action.get("placements") or []
if "toolbar" not in placements:
continue
bind = action.get("bind")
if bind is None:
errors.append(f"{action['id']}: toolbar placement requires bind.tables")
continue
errors.extend(_validate_bind_tables(action["id"], bind))
self.assertEqual(errors, [], msg="\n".join(errors))
def test_bindable_business_tables_helper_loads_template_db(self) -> None:
"""helper 本身必须可用:即便默认 manifest 无 toolbar bind也要验证隔离库可读。"""
bindable = _load_bindable_business_tables()
self.assertIn("task_logs", bindable)
self.assertNotIn("_jiangchang_tables", bindable)
self.assertNotIn("_jiangchang_columns", bindable)
def test_bind_tables_exist_in_physical_and_visible_metadata(self) -> None:
bindable = _load_bindable_business_tables()
self.assertTrue(bindable, msg="bindable table set must be non-empty after init_db")
manifest = _load_actions_manifest()
missing: list[str] = []
for action in manifest["actions"]:
bind = action.get("bind")
if not isinstance(bind, dict):
continue
for table in bind.get("tables") or []:
if table not in bindable:
missing.append(
f"{action['id']}: bind.tables entry {table!r} must exist as "
f"physical table AND _jiangchang_tables.visible=1 "
f"(bindable={sorted(bindable)})"
)
self.assertEqual(missing, [], msg="\n".join(missing))
def test_schema_allows_all_placement_execution_profile_combinations(self) -> None:
"""POLICY-SKILL-ACTION-0044 placements × 2 profiles 必须全部通过 Schema。"""
try:
import jsonschema
except ImportError:
self.fail("jsonschema is required to validate placement×executionProfile orthogonality")
schema = _load_actions_schema()
placements = ("toolbar", "cron", "agent", "skill-detail")
profiles = ("sync", "async")
for placement in placements:
for profile in profiles:
action: dict = {
"id": "probe-action",
"label": "正交探测",
"description": "验证 placements 与 executionProfile 无条件耦合",
"placements": [placement],
"executionProfile": profile,
"entrypoint": {"type": "cli", "command": "health", "args": []},
}
if placement == "toolbar":
action["bind"] = {"tables": ["task_logs"]}
manifest = {
"schemaVersion": 1,
"skill": "your-skill-slug",
"actions": [action],
}
with self.subTest(placement=placement, executionProfile=profile):
jsonschema.validate(instance=manifest, schema=schema)
if placement != "toolbar":
self.assertNotIn("bind", action)
def test_schema_rejects_toolbar_without_bind(self) -> None:
try:
import jsonschema
from jsonschema import ValidationError
except ImportError:
self.fail("jsonschema is required")
schema = _load_actions_schema()
manifest = {
"schemaVersion": 1,
"skill": "your-skill-slug",
"actions": [
{
"id": "probe-toolbar",
"label": "缺 bind 探测",
"description": "toolbar 无 bind 应被 Schema 拒绝",
"placements": ["toolbar"],
"executionProfile": "sync",
"entrypoint": {"type": "cli", "command": "health", "args": []},
}
],
}
with self.assertRaises(ValidationError):
jsonschema.validate(instance=manifest, schema=schema)
def test_no_execution_profile_placement_coupling_in_tests(self) -> None:
"""守护:本文件不得引入入口位置与 sync/async 的错误硬耦合断言。"""
path = os.path.join(get_skill_root(), "tests", "test_actions_manifest.py")
with open(path, encoding="utf-8") as f:
text = f.read()
# 拆开拼接,避免本测试源码自身触发字符串扫描。
forbidden_snippets = (
"toolbar " + "只能 async",
"toolbar only " + "async",
'toolbar"' + " and profile != " + '"async"',
'toolbar")' + " and profile != " + '"async"',
"toolbar must be " + "async",
"executionProfile" + " == " + '"async"' + " and " + '"toolbar"',
)
for snippet in forbidden_snippets:
self.assertNotIn(snippet, text)
def test_template_example_does_not_use_reserved_placements(self) -> None:
manifest = _load_actions_manifest()
for action in manifest["actions"]:
placements = set(action.get("placements") or [])
overlap = placements & _RESERVED_PLACEMENTS
self.assertFalse(
overlap,
msg=f"{action['id']} uses reserved placements not yet supported in template: {overlap}",
)
def test_template_variables_have_input_schema(self) -> None:
manifest = _load_actions_manifest()
for action in manifest["actions"]:
args = action.get("entrypoint", {}).get("args") or []
template_vars = _collect_template_vars(args)
if not template_vars:
continue
schema = action.get("inputSchema") or {}
properties = schema.get("properties") or {}
for var in template_vars:
self.assertIn(
var,
properties,
msg=f"{action['id']} uses {{{{{var}}}}} but inputSchema.properties missing it",
)
def test_required_fields_exist_in_properties(self) -> None:
manifest = _load_actions_manifest()
for action in manifest["actions"]:
schema = action.get("inputSchema") or {}
required = schema.get("required") or []
properties = schema.get("properties") or {}
for field in required:
self.assertIn(
field,
properties,
msg=f"{action['id']} required field {field!r} missing from properties",
)
def test_sensitive_params_not_on_toolbar(self) -> None:
manifest = _load_actions_manifest()
for action in manifest["actions"]:
props = (action.get("inputSchema") or {}).get("properties") or {}
has_sensitive = any(p.get("sensitive") for p in props.values())
if has_sensitive and "toolbar" in (action.get("placements") or []):
self.fail(f"{action['id']} has sensitive input but is on toolbar")
def test_confirmation_message_structure(self) -> None:
manifest = _load_actions_manifest()
for action in manifest["actions"]:
confirmation = action.get("confirmation")
if confirmation is None:
continue
self.assertIsInstance(confirmation, dict)
message = confirmation.get("message")
self.assertTrue(str(message or "").strip(), msg=f"{action['id']} confirmation.message empty")
def test_manifest_has_no_specific_business_skill_names(self) -> None:
path = os.path.join(get_skill_root(), "assets", "actions.json")
with open(path, encoding="utf-8") as f:
raw = f.read().lower()
for term in _FORBIDDEN_BUSINESS_TERMS:
self.assertNotIn(term.lower(), raw, msg=f"actions.json must not contain {term!r}")
def test_actions_md_exists_and_links_manifest(self) -> None:
path = os.path.join(get_skill_root(), "references", "ACTIONS.md")
self.assertTrue(os.path.isfile(path))
with open(path, encoding="utf-8") as f:
text = f.read()
self.assertIn("actions.json", text)
self.assertIn("schemaVersion", text)
for term in _FORBIDDEN_BUSINESS_TERMS:
self.assertNotIn(term.lower(), text.lower(), msg=f"ACTIONS.md must not contain {term!r}")
def test_skill_actions_schema_file_exists(self) -> None:
path = os.path.join(get_skill_root(), "assets", "schemas", "skill-actions.schema.json")
self.assertTrue(os.path.isfile(path))
with open(path, encoding="utf-8") as f:
schema = json.load(f)
self.assertEqual(schema.get("title"), "SkillActionManifest")
self.assertIn("actions", schema.get("properties", {}))
def test_skill_actions_schema_json_parses(self) -> None:
schema = _load_actions_schema()
self.assertEqual(schema["properties"]["schemaVersion"]["const"], 1)
self.assertTrue(schema.get("additionalProperties") is False)
self.assertIn("action", schema.get("$defs", {}))
def test_template_manifest_matches_schema_required_keys(self) -> None:
manifest = _load_actions_manifest()
schema = _load_actions_schema()
for key in _REQUIRED_MANIFEST_KEYS:
self.assertIn(key, manifest)
self.assertIn(key, schema.get("properties", {}))
for action in manifest["actions"]:
for key in _REQUIRED_ACTION_KEYS:
self.assertIn(key, action)
def test_template_example_does_not_use_reserved_action_fields(self) -> None:
manifest = _load_actions_manifest()
for action in manifest["actions"]:
overlap = set(action.keys()) & _RESERVED_ACTION_FIELDS
self.assertFalse(
overlap,
msg=f"{action['id']} uses reserved action fields not supported in template Phase 1: {overlap}",
)
def test_default_manifest_does_not_place_health_on_toolbar(self) -> None:
"""脚手架默认健康类 Action 不应出现在数据管理 toolbar。"""
manifest = _load_actions_manifest()
for action in manifest["actions"]:
if action["id"] in {"health", "version", "config-path"}:
self.assertNotIn(
"toolbar",
action.get("placements") or [],
msg=f"{action['id']} must not be auto-placed on toolbar",
)
def test_actions_md_documents_strict_vs_host_boundary(self) -> None:
path = os.path.join(get_skill_root(), "references", "ACTIONS.md")
with open(path, encoding="utf-8") as f:
text = f.read()
for marker in (
"严格规范",
"向后兼容",
"Phase 1",
"bind.tables",
"concurrency",
"locks",
"row",
"batch",
"additionalProperties",
"placements",
"executionProfile",
):
self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}")
for forbidden in (
"数据管理 toolbar " + "只挂",
"toolbar " + "只放 async",
"toolbar" + "只挂",
"只挂 **" + "async**",
):
self.assertNotIn(forbidden, text, msg=f"ACTIONS.md must not contain deprecated coupling: {forbidden!r}")
if __name__ == "__main__":
unittest.main()