# -*- coding: utf-8 -*- """assets/actions.json 结构与 CLI 映射守护(通用模板,不含具体业务技能名称)。""" from __future__ import annotations import json import os import re import unittest from _support import 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"}) _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 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_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", {})) if __name__ == "__main__": unittest.main()