diff --git a/.github/workflows/test_validate_plugins.py b/.github/workflows/test_validate_plugins.py new file mode 100644 index 0000000..46c0a00 --- /dev/null +++ b/.github/workflows/test_validate_plugins.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import importlib.util +import unittest +from pathlib import Path + + +VALIDATOR_PATH = Path(__file__).with_name("validate-plugins.py") +SPEC = importlib.util.spec_from_file_location("validate_plugins", VALIDATOR_PATH) +assert SPEC is not None and SPEC.loader is not None +validate_plugins = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(validate_plugins) + + +class LauncherPrefixTests(unittest.TestCase): + def validate_prefix(self, prefix: str) -> list[str]: + validator = validate_plugins.Validator(Path("/repo")) + validator.validate_launcher_fields( + Path("/repo/example/plugin.toml"), + "launcher_provider[0]", + {"prefix": prefix}, + ) + return validator.errors + + def test_accepts_lowercase_ascii_letters(self) -> None: + self.assertEqual(self.validate_prefix("bla"), []) + + def test_rejects_leading_symbol(self) -> None: + self.assertNotEqual(self.validate_prefix("/bla"), []) + + def test_rejects_uppercase_letters(self) -> None: + self.assertNotEqual(self.validate_prefix("Bla"), []) + + def test_rejects_digits(self) -> None: + self.assertNotEqual(self.validate_prefix("bla2"), []) + + def test_rejects_other_symbols(self) -> None: + self.assertNotEqual(self.validate_prefix("bla-bla"), []) + + +class AllowedTagsTests(unittest.TestCase): + def validate_tags(self, tags: object) -> list[str]: + validator = validate_plugins.Validator(Path("/repo")) + validator.validate_tags(Path("/repo/example/plugin.toml"), tags) + return validator.errors + + def test_accepts_every_allowed_tag(self) -> None: + self.assertEqual(self.validate_tags(sorted(validate_plugins.ALLOWED_TAGS)), []) + + def test_rejects_unknown_tag(self) -> None: + self.assertEqual( + self.validate_tags(["utility", "unknown"]), + [ + "example/plugin.toml: root: " + "tags[1] 'unknown' is not an allowed tag" + ], + ) + + def test_rejects_wrong_case(self) -> None: + self.assertNotEqual(self.validate_tags(["Utility"]), []) + + def test_retains_string_list_validation(self) -> None: + errors = self.validate_tags(["utility", "utility", ""]) + self.assertTrue(any("duplicate 'utility'" in error for error in errors)) + self.assertTrue(any("tags[2] must be a non-empty string" in error for error in errors)) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/validate-plugins.py b/.github/workflows/validate-plugins.py index f2be61e..654dfb4 100644 --- a/.github/workflows/validate-plugins.py +++ b/.github/workflows/validate-plugins.py @@ -13,6 +13,45 @@ from typing import Any DEFAULT_ROOT = Path(__file__).resolve().parents[2] SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$") +LAUNCHER_PREFIX_RE = re.compile(r"^[a-z]+$") +ALLOWED_TAGS = { + "ai", + "animation", + "audio", + "bar", + "clock", + "countdown", + "demo", + "desktop", + "development", + "emoticon", + "fun", + "gaming", + "hardware", + "hyprland", + "indicator", + "labwc", + "language", + "launcher", + "mangowc", + "media", + "music", + "network", + "niri", + "panel", + "privacy", + "productivity", + "recording", + "service", + "shortcut", + "sway", + "system", + "theming", + "time", + "utility", + "video", + "wallpaper", +} # An id segment must be a lowercase flat identifier: the part after the "/" is also the # plugin's directory here, its export directory on disk, and its slug on the website. @@ -312,6 +351,19 @@ class Validator: self.add_context_error(manifest_path, context, f"{field} contains duplicate '{item}'") seen.add(item) + def validate_tags(self, manifest_path: Path, value: Any) -> None: + self.validate_string_list(manifest_path, "root", "tags", value, allow_empty=False) + if not isinstance(value, list): + return + + for index, tag in enumerate(value): + if is_non_empty_string(tag) and tag not in ALLOWED_TAGS: + self.add_context_error( + manifest_path, + "root", + f"tags[{index}] '{tag}' is not an allowed tag", + ) + def validate_root_fields(self, manifest_path: Path, manifest: dict[str, Any]) -> None: unknown = sorted(set(manifest) - ROOT_FIELDS) for field in unknown: @@ -337,7 +389,7 @@ class Validator: ) if "tags" in manifest: - self.validate_string_list(manifest_path, "root", "tags", manifest["tags"], allow_empty=False) + self.validate_tags(manifest_path, manifest["tags"]) if "deprecated" in manifest and not isinstance(manifest["deprecated"], bool): self.add_error(manifest_path, "root field 'deprecated' must be a bool") @@ -394,6 +446,14 @@ class Validator: if field in entry and not is_non_empty_string(entry[field]): self.add_context_error(manifest_path, context, f"{field} must be a non-empty string") + prefix = entry.get("prefix") + if is_non_empty_string(prefix) and not LAUNCHER_PREFIX_RE.fullmatch(prefix): + self.add_context_error( + manifest_path, + context, + "prefix must contain only lowercase letters (a-z), without a leading symbol", + ) + if "include_in_global_search" in entry and not isinstance(entry["include_in_global_search"], bool): self.add_context_error( manifest_path, diff --git a/.github/workflows/validate-plugins.yml b/.github/workflows/validate-plugins.yml index e55839c..ee3e3e3 100644 --- a/.github/workflows/validate-plugins.yml +++ b/.github/workflows/validate-plugins.yml @@ -23,3 +23,6 @@ jobs: - name: Validate plugin manifests run: python3 .github/workflows/validate-plugins.py + + - name: Test plugin validator + run: python3 -m unittest discover -s .github/workflows -p 'test_*.py'