Skip to content

Commit 0fd923e

Browse files
committed
improve plugin validation diagnostics
1 parent 4ade5f6 commit 0fd923e

3 files changed

Lines changed: 223 additions & 20 deletions

File tree

.github/workflows/validate-plugin-smoke.yml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ jobs:
4646
import json
4747
import os
4848
import subprocess
49+
import sys
4950
from pathlib import Path
5051
5152
base_ref = os.environ["GITHUB_BASE_REF"]
@@ -70,9 +71,11 @@ jobs:
7071
if not isinstance(head, dict):
7172
raise ValueError("plugins.json must contain a JSON object")
7273
except (json.JSONDecodeError, ValueError) as exc:
73-
changed = []
74-
should_validate = False
75-
validation_note = f"Skipping smoke validation because plugins.json is invalid on the PR head: {exc}"
74+
print(
75+
f"plugins.json is invalid on the PR head; smoke validation cannot continue: {exc}",
76+
file=sys.stderr,
77+
)
78+
raise SystemExit(1)
7679
else:
7780
changed = [
7881
name

scripts/validate_plugins/run.py

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -98,13 +98,27 @@ def select_plugins(
9898

9999

100100
def _parse_simple_yaml(path: Path) -> dict:
101+
def parse_value(raw_value: str) -> str:
102+
value = raw_value.strip()
103+
if not value:
104+
return ""
105+
106+
if value[0] in {'"', "'"}:
107+
quote = value[0]
108+
end_index = value.rfind(quote)
109+
if end_index > 0:
110+
return value[1:end_index]
111+
112+
value = re.split(r"\s+#", value, maxsplit=1)[0].rstrip()
113+
return value.strip("\"'")
114+
101115
result = {}
102116
for raw_line in path.read_text(encoding="utf-8").splitlines():
103117
line = raw_line.strip()
104118
if not line or line.startswith("#") or ":" not in line:
105119
continue
106120
key, value = line.split(":", 1)
107-
result[key.strip()] = value.strip().strip("\"'")
121+
result[key.strip()] = parse_value(value)
108122
return result
109123

110124

@@ -219,11 +233,17 @@ def load_plugins_index(path: Path) -> dict[str, dict]:
219233
data = json.loads(path.read_text(encoding="utf-8"))
220234
if not isinstance(data, dict):
221235
raise ValueError("plugins.json must contain a JSON object")
222-
result = {}
236+
223237
for key, value in data.items():
224-
if isinstance(key, str) and isinstance(value, dict):
225-
result[key] = value
226-
return result
238+
if not isinstance(key, str):
239+
raise ValueError(
240+
f"plugins.json keys must be strings, got {type(key).__name__!r}: {key!r}"
241+
)
242+
if not isinstance(value, dict):
243+
raise ValueError(
244+
f"plugins.json values must be objects/dicts; key {key!r} has {type(value).__name__!r}"
245+
)
246+
return data
227247

228248

229249
def combine_requested_names(
@@ -431,6 +451,39 @@ def validate_plugin(
431451
)
432452

433453

454+
def validate_selected_plugins(
455+
*,
456+
selected: list[tuple[str, dict]],
457+
astrbot_path: Path,
458+
script_path: Path,
459+
work_dir: Path,
460+
clone_timeout: int,
461+
load_timeout: int,
462+
) -> list[dict]:
463+
results = []
464+
total = len(selected)
465+
466+
for index, (plugin, plugin_data) in enumerate(selected, start=1):
467+
print(f"[{index}/{total}] Validating {plugin}", flush=True)
468+
result = validate_plugin(
469+
plugin=plugin,
470+
plugin_data=plugin_data,
471+
astrbot_path=astrbot_path,
472+
script_path=script_path,
473+
work_dir=work_dir,
474+
clone_timeout=clone_timeout,
475+
load_timeout=load_timeout,
476+
)
477+
results.append(result)
478+
479+
status = "PASS" if result.get("ok") else "FAIL"
480+
stage = result.get("stage", "unknown")
481+
message = result.get("message", "")
482+
print(f"[{index}/{total}] {status} {plugin} [{stage}] {message}", flush=True)
483+
484+
return results
485+
486+
434487
class NullStub:
435488
def __getattr__(self, name: str) -> "NullStub":
436489
del name
@@ -646,18 +699,14 @@ def main() -> int:
646699
work_dir.mkdir(parents=True, exist_ok=True)
647700

648701
try:
649-
results = [
650-
validate_plugin(
651-
plugin=plugin,
652-
plugin_data=plugin_data,
653-
astrbot_path=Path(args.astrbot_path).resolve(),
654-
script_path=Path(__file__).resolve(),
655-
work_dir=work_dir,
656-
clone_timeout=args.clone_timeout,
657-
load_timeout=args.load_timeout,
658-
)
659-
for plugin, plugin_data in selected
660-
]
702+
results = validate_selected_plugins(
703+
selected=selected,
704+
astrbot_path=Path(args.astrbot_path).resolve(),
705+
script_path=Path(__file__).resolve(),
706+
work_dir=work_dir,
707+
clone_timeout=args.clone_timeout,
708+
load_timeout=args.load_timeout,
709+
)
661710
finally:
662711
if temp_dir is not None:
663712
temp_dir.cleanup()

tests/test_validate_plugins.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import importlib.util
2+
import json
3+
import os
24
import subprocess
35
import sys
46
import tempfile
57
import unittest
68
from pathlib import Path
9+
from unittest import mock
710

811

912
ROOT = Path(__file__).resolve().parents[1]
@@ -195,6 +198,154 @@ def test_build_process_output_details_keeps_partial_timeout_logs(self):
195198

196199
self.assertEqual(details, {"stdout": "line one\nline two", "stderr": "warning"})
197200

201+
def test_parse_simple_yaml_handles_comments_quotes_and_whitespace(self):
202+
module = load_validator_module()
203+
204+
with tempfile.NamedTemporaryFile("w", suffix=".yml", delete=False) as handle:
205+
handle.write(
206+
"""
207+
# leading comment
208+
209+
key1: value1 # trailing comment
210+
key2: \" spaced value \"
211+
key3: 'another value'
212+
key4: value-with-#-hash
213+
"""
214+
)
215+
metadata_path = Path(handle.name)
216+
217+
try:
218+
parsed = module._parse_simple_yaml(metadata_path)
219+
finally:
220+
os.remove(metadata_path)
221+
222+
self.assertEqual(parsed["key1"], "value1")
223+
self.assertEqual(parsed["key2"], " spaced value ")
224+
self.assertEqual(parsed["key3"], "another value")
225+
self.assertEqual(parsed["key4"], "value-with-#-hash")
226+
227+
def test_load_metadata_uses_yaml_safe_load_when_available(self):
228+
module = load_validator_module()
229+
230+
with tempfile.NamedTemporaryFile("w", suffix=".yml", delete=False) as handle:
231+
handle.write("name: should-be-overridden\n")
232+
metadata_path = Path(handle.name)
233+
234+
fake_yaml = mock.Mock()
235+
fake_yaml.safe_load.return_value = {"name": "from-yaml", "version": "1.0.0"}
236+
237+
try:
238+
with mock.patch.object(module, "yaml", fake_yaml):
239+
metadata = module.load_metadata(metadata_path)
240+
finally:
241+
os.remove(metadata_path)
242+
243+
self.assertEqual(metadata, {"name": "from-yaml", "version": "1.0.0"})
244+
fake_yaml.safe_load.assert_called_once()
245+
246+
def test_load_metadata_uses_simple_parser_when_yaml_unavailable(self):
247+
module = load_validator_module()
248+
249+
with tempfile.NamedTemporaryFile("w", suffix=".yml", delete=False) as handle:
250+
handle.write(
251+
"""
252+
name: demo-plugin
253+
version: \"0.2.3\"
254+
"""
255+
)
256+
metadata_path = Path(handle.name)
257+
258+
yaml_backup = getattr(module, "yaml", None)
259+
try:
260+
module.yaml = None
261+
metadata = module.load_metadata(metadata_path)
262+
finally:
263+
module.yaml = yaml_backup
264+
os.remove(metadata_path)
265+
266+
self.assertEqual(metadata.get("name"), "demo-plugin")
267+
self.assertEqual(metadata.get("version"), "0.2.3")
268+
269+
def test_load_plugins_index_accepts_valid_object(self):
270+
module = load_validator_module()
271+
272+
index_obj = {
273+
"good-plugin": {"name": "Good Plugin", "repo": "https://github.com/example/good"},
274+
"another-plugin": {"name": "Another Plugin"},
275+
}
276+
277+
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
278+
json.dump(index_obj, handle)
279+
index_path = Path(handle.name)
280+
281+
try:
282+
plugins = module.load_plugins_index(index_path)
283+
finally:
284+
os.remove(index_path)
285+
286+
self.assertEqual(plugins, index_obj)
287+
288+
def test_load_plugins_index_rejects_json_array(self):
289+
module = load_validator_module()
290+
291+
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
292+
json.dump([{"name": "array-entry"}], handle)
293+
index_path = Path(handle.name)
294+
295+
try:
296+
with self.assertRaisesRegex(ValueError, "plugins.json must contain a JSON object"):
297+
module.load_plugins_index(index_path)
298+
finally:
299+
os.remove(index_path)
300+
301+
def test_load_plugins_index_rejects_non_dict_values(self):
302+
module = load_validator_module()
303+
304+
index_obj = {
305+
"valid-plugin": {"name": "Valid Plugin", "repo": "https://github.com/example/valid"},
306+
"not-a-dict": "just-a-string",
307+
}
308+
309+
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
310+
json.dump(index_obj, handle)
311+
index_path = Path(handle.name)
312+
313+
try:
314+
with self.assertRaisesRegex(ValueError, "plugins.json values must be objects/dicts"):
315+
module.load_plugins_index(index_path)
316+
finally:
317+
os.remove(index_path)
318+
319+
320+
class ValidationProgressTests(unittest.TestCase):
321+
def test_validate_selected_plugins_emits_progress_and_result_lines(self):
322+
module = load_validator_module()
323+
selected = [
324+
("plugin-a", {"repo": "https://github.com/example/plugin-a"}),
325+
("plugin-b", {"repo": "https://github.com/example/plugin-b"}),
326+
]
327+
fake_results = [
328+
{"plugin": "plugin-a", "ok": True, "stage": "load", "message": "ok"},
329+
{"plugin": "plugin-b", "ok": False, "stage": "metadata", "message": "invalid metadata.yaml"},
330+
]
331+
332+
with mock.patch.object(module, "validate_plugin", side_effect=fake_results) as validate_mock:
333+
with mock.patch("builtins.print") as print_mock:
334+
results = module.validate_selected_plugins(
335+
selected=selected,
336+
astrbot_path=Path("/tmp/AstrBot"),
337+
script_path=Path("/tmp/run.py"),
338+
work_dir=Path("/tmp/work"),
339+
clone_timeout=60,
340+
load_timeout=300,
341+
)
342+
343+
self.assertEqual(results, fake_results)
344+
self.assertEqual(validate_mock.call_count, 2)
345+
print_mock.assert_any_call("[1/2] Validating plugin-a", flush=True)
346+
print_mock.assert_any_call("[1/2] PASS plugin-a [load] ok", flush=True)
347+
print_mock.assert_any_call("[2/2] FAIL plugin-b [metadata] invalid metadata.yaml", flush=True)
348+
198349

199350
class MetadataValidationTests(unittest.TestCase):
200351
def test_reports_missing_required_metadata_fields(self):

0 commit comments

Comments
 (0)