molecule-core/sdk/python/tests/test_validators.py
Hongming Wang 24fec62d7f initial commit — Molecule AI platform
Forked clean from public hackathon repo (Starfire-AgentTeam, BSL 1.1)
with full rebrand to Molecule AI under github.com/Molecule-AI/molecule-monorepo.

Brand: Starfire → Molecule AI.
Slug: starfire / agent-molecule → molecule.
Env vars: STARFIRE_* → MOLECULE_*.
Go module: github.com/agent-molecule/platform → github.com/Molecule-AI/molecule-monorepo/platform.
Python packages: starfire_plugin → molecule_plugin, starfire_agent → molecule_agent.
DB: agentmolecule → molecule.

History truncated; see public repo for prior commits and contributor
attribution. Verified green: go test -race ./... (platform), pytest
(workspace-template 1129 + sdk 132), vitest (canvas 352), build (mcp).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:55:37 -07:00

319 lines
10 KiB
Python

"""Tests for the SDK's workspace/org/channel validators + CLI dispatch."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
import yaml
from molecule_plugin import (
SUPPORTED_CHANNEL_TYPES,
SUPPORTED_RUNTIMES,
validate_channel_config,
validate_channel_file,
validate_org_template,
validate_workspace_template,
)
from molecule_plugin.__main__ import main as cli_main
# ---------- workspace ----------
def _write_yaml(path: Path, data) -> None:
path.write_text(yaml.safe_dump(data))
def test_workspace_happy(tmp_path: Path):
_write_yaml(
tmp_path / "config.yaml",
{"name": "x", "runtime": "claude-code", "tier": 2,
"runtime_config": {"required_env": ["FOO"], "timeout": 30}},
)
assert validate_workspace_template(tmp_path) == []
def test_workspace_missing_file(tmp_path: Path):
errs = validate_workspace_template(tmp_path)
assert len(errs) == 1 and "missing config.yaml" in errs[0].message
def test_workspace_bad_yaml(tmp_path: Path):
(tmp_path / "config.yaml").write_text("foo: [bar\n")
errs = validate_workspace_template(tmp_path)
assert any("invalid YAML" in e.message for e in errs)
def test_workspace_not_object(tmp_path: Path):
(tmp_path / "config.yaml").write_text("- a\n- b\n")
errs = validate_workspace_template(tmp_path)
assert any("must be a YAML object" in e.message for e in errs)
def test_workspace_validation_errors(tmp_path: Path):
_write_yaml(
tmp_path / "config.yaml",
{"name": "", "runtime": "wat", "tier": 9,
"runtime_config": {"required_env": "nope", "timeout": "soon"}},
)
msgs = [e.message for e in validate_workspace_template(tmp_path)]
assert any("missing required field: name" in m for m in msgs)
assert any("runtime=" in m for m in msgs)
assert any("tier must be 1, 2, or 3" in m for m in msgs)
assert any("required_env" in m for m in msgs)
assert any("timeout" in m for m in msgs)
def test_workspace_runtime_config_not_dict(tmp_path: Path):
_write_yaml(
tmp_path / "config.yaml",
{"name": "x", "runtime": "langgraph", "runtime_config": "nope"},
)
msgs = [e.message for e in validate_workspace_template(tmp_path)]
assert any("runtime_config must be an object" in m for m in msgs)
def test_workspace_runtime_config_none_ok(tmp_path: Path):
_write_yaml(tmp_path / "config.yaml", {"name": "x", "runtime": "langgraph", "runtime_config": None})
assert validate_workspace_template(tmp_path) == []
def test_org_defaults_none_ok(tmp_path: Path):
_write_yaml(tmp_path / "org.yaml", {"name": "T", "defaults": None, "workspaces": [{"name": "a"}]})
assert validate_org_template(tmp_path) == []
def test_supported_runtimes_contains_known():
assert "claude-code" in SUPPORTED_RUNTIMES
assert "deepagents" in SUPPORTED_RUNTIMES
# ---------- org ----------
def test_org_happy(tmp_path: Path):
_write_yaml(
tmp_path / "org.yaml",
{
"name": "T",
"defaults": {"runtime": "claude-code"},
"workspaces": [
{
"name": "PM",
"tier": 3,
"runtime": "claude-code",
"workspace_access": "read_only",
"workspace_dir": "/repo",
"channels": [{"type": "telegram", "config": {"bot_token": "x"}}],
"schedules": [{"cron_expr": "* * * * *", "prompt": "hi"}],
"plugins": ["molecule-dev"],
"children": [{"name": "Dev"}],
}
],
},
)
assert validate_org_template(tmp_path) == []
def test_org_missing_file(tmp_path: Path):
errs = validate_org_template(tmp_path)
assert any("missing org.yaml" in e.message for e in errs)
def test_org_bad_yaml(tmp_path: Path):
(tmp_path / "org.yaml").write_text("foo: [bar\n")
errs = validate_org_template(tmp_path)
assert any("invalid YAML" in e.message for e in errs)
def test_org_not_object(tmp_path: Path):
(tmp_path / "org.yaml").write_text("- a\n")
errs = validate_org_template(tmp_path)
assert any("must be a YAML object" in e.message for e in errs)
def test_org_various_errors(tmp_path: Path):
_write_yaml(
tmp_path / "org.yaml",
{
"defaults": "nope",
"workspaces": [
"notadict",
{
"name": "",
"tier": 8,
"runtime": "wat",
"workspace_access": "invalid",
"channels": "nope",
"schedules": "nope",
"plugins": [1, 2],
"external": True,
},
{
"name": "y",
"workspace_access": "read_write", # but no workspace_dir
"channels": ["bad", {"config": "nope"}],
"schedules": ["bad", {}],
"children": "nope",
},
{
"name": "z",
"children": [{"name": "c"}, "bad"],
},
],
},
)
msgs = [e.message for e in validate_org_template(tmp_path)]
joined = "\n".join(msgs)
assert "missing required field: name" in joined
assert "defaults must be an object" in joined
assert "tier must be 1, 2, or 3" in joined
assert "runtime=" in joined
assert "workspace_access=" in joined
assert "requires workspace_dir" in joined
assert ".channels: must be a list" in joined
assert ".schedules: must be a list" in joined
assert "plugins: must be a list of strings" in joined
assert "external=true requires url" in joined
assert "missing required 'type'" in joined or "must be an object" in joined
assert "missing 'cron_expr'" in joined
assert "missing 'prompt'" in joined
assert ".children: must be a list" in joined
assert "must be an object" in joined
def test_org_missing_workspaces(tmp_path: Path):
_write_yaml(tmp_path / "org.yaml", {"name": "T"})
msgs = [e.message for e in validate_org_template(tmp_path)]
assert any("missing required field: workspaces" in m for m in msgs)
def test_org_workspaces_not_list(tmp_path: Path):
_write_yaml(tmp_path / "org.yaml", {"name": "T", "workspaces": "nope"})
msgs = [e.message for e in validate_org_template(tmp_path)]
assert any("workspaces must be a list" in m for m in msgs)
# ---------- channel ----------
def test_channel_config_happy():
assert validate_channel_config({
"type": "telegram",
"config": {"bot_token": "x"},
"enabled": True,
}) == []
def test_channel_config_missing_type():
errs = validate_channel_config({})
assert any("missing required field: type" in e.message for e in errs)
def test_channel_config_unsupported_type():
errs = validate_channel_config({"type": "fax"})
assert any("must be one of" in e.message for e in errs)
def test_channel_config_bad_config_type():
errs = validate_channel_config({"type": "telegram", "config": "nope"})
assert any("config must be an object" in e.message for e in errs)
def test_channel_config_missing_required_key():
errs = validate_channel_config({"type": "telegram", "config": {}})
assert any("bot_token is required" in e.message for e in errs)
def test_channel_config_bad_enabled():
errs = validate_channel_config({"type": "telegram", "config": {"bot_token": "x"}, "enabled": "yes"})
assert any("enabled must be a boolean" in e.message for e in errs)
def test_channel_file_list(tmp_path: Path):
p = tmp_path / "channels.yaml"
p.write_text(yaml.safe_dump([
{"type": "telegram", "config": {"bot_token": "x"}},
"notadict",
]))
errs = validate_channel_file(p)
assert any("must be an object" in e.message for e in errs)
def test_channel_file_single_dict(tmp_path: Path):
p = tmp_path / "channel.yaml"
p.write_text(yaml.safe_dump({"type": "telegram", "config": {"bot_token": "x"}}))
assert validate_channel_file(p) == []
def test_channel_file_missing():
errs = validate_channel_file(Path("/nonexistent/channel.yaml"))
assert any("file does not exist" in e.message for e in errs)
def test_channel_file_empty(tmp_path: Path):
p = tmp_path / "c.yaml"
p.write_text("")
errs = validate_channel_file(p)
assert any("empty" in e.message for e in errs)
def test_channel_file_bad_yaml(tmp_path: Path):
p = tmp_path / "c.yaml"
p.write_text("foo: [bar\n")
errs = validate_channel_file(p)
assert any("invalid YAML" in e.message for e in errs)
def test_channel_file_wrong_toplevel(tmp_path: Path):
p = tmp_path / "c.yaml"
p.write_text("5\n")
errs = validate_channel_file(p)
assert any("top-level must be" in e.message for e in errs)
def test_channel_types_exports():
assert "telegram" in SUPPORTED_CHANNEL_TYPES
# ---------- CLI ----------
def test_cli_workspace_valid(tmp_path, capsys):
_write_yaml(tmp_path / "config.yaml", {"name": "x", "runtime": "langgraph"})
assert cli_main(["validate", "workspace", str(tmp_path)]) == 0
def test_cli_workspace_invalid(tmp_path, capsys):
_write_yaml(tmp_path / "config.yaml", {"name": "", "runtime": ""})
assert cli_main(["validate", "workspace", str(tmp_path)]) == 1
def test_cli_org_quiet(tmp_path, capsys):
_write_yaml(tmp_path / "org.yaml", {"name": "T", "workspaces": [{"name": "a"}]})
assert cli_main(["validate", "org", str(tmp_path), "-q"]) == 0
out = capsys.readouterr().out
assert out == ""
def test_cli_channel_valid(tmp_path):
p = tmp_path / "c.yaml"
p.write_text(yaml.safe_dump({"type": "telegram", "config": {"bot_token": "x"}}))
assert cli_main(["validate", "channel", str(p)]) == 0
def test_cli_channel_missing(tmp_path):
assert cli_main(["validate", "channel", str(tmp_path / "missing.yaml")]) == 1
def test_cli_missing_path(tmp_path):
assert cli_main(["validate", "workspace", str(tmp_path / "nope")]) == 1
def test_cli_path_not_dir(tmp_path):
p = tmp_path / "file.txt"
p.write_text("hi")
assert cli_main(["validate", "workspace", str(p)]) == 1
def test_cli_plugin_dispatch(tmp_path):
# Plugin dir missing plugin.yaml -> validator returns errors -> exit 1
assert cli_main(["validate", "plugin", str(tmp_path)]) == 1