Files
bot-bottle/tests/unit/test_manifest_extends.py
T
didericis-claude 58d76a50a6
test / unit (pull_request) Successful in 37s
test / integration (pull_request) Successful in 55s
test: update test suite for git-gate manifest redesign (PRD 0047)
- fixtures.py: fixture_with_git_dict uses git-gate.repos + url/identity/host_key
- test_manifest_git: rewrite to use git-gate.repos; replace duplicate-name
  test (names = dict keys, always unique) with two-repos-different-hosts test
- test_manifest_git_user: _manifest → git-gate.user; update error message assertions
- test_manifest_agent_git_user: git → git-gate throughout; repos rejection test
- test_manifest_extends: git.remotes/git.user → git-gate.repos/git-gate.user
- test_provision_git: IP test updated — no host alias, single insteadOf
- test_compose: git.remotes → git-gate.repos + new field names
- test_docker_provision_git_user: git.user → git-gate.user
- test_git_gate: inline manifest dict updated to git-gate.repos
- test_smolmachines_provision: git_json → git_gate_json; remove _remote_host
2026-06-03 03:55:07 +00:00

328 lines
11 KiB
Python

"""Unit: bottle composition via `extends:` (PRD 0025, issue #88).
Each merge rule from the PRD gets a focused case; the resolver's
recursion + cycle / missing-parent / self-reference dies are in
their own tests.
The `Manifest.from_json_obj` path is the test surface — same
resolver runs from `Manifest.from_md_dirs` (md loader) so locking
it here covers both."""
from __future__ import annotations
import unittest
from bot_bottle.manifest import ManifestError, Manifest
def _error_message(callable_, *args, **kwargs) -> str:
"""Run `callable_` expecting a ManifestError; return its message."""
try:
callable_(*args, **kwargs)
except ManifestError as e:
return str(e)
raise AssertionError("expected ManifestError was not raised")
def _build(**bottles) -> Manifest:
"""Build a manifest with the given bottles and one trivial agent
referencing the first bottle (so the manifest is valid)."""
first = next(iter(bottles))
return Manifest.from_json_obj({
"bottles": bottles,
"agents": {
"demo": {"skills": [], "prompt": "", "bottle": first},
},
})
class TestExtendsBasic(unittest.TestCase):
def test_leaf_without_extends_unchanged(self):
# Sanity: existing manifests with no `extends:` parse the
# same way they did before the resolver landed.
m = _build(dev={
"env": {"FOO": "bar"},
"supervise": True,
})
b = m.bottles["dev"]
self.assertEqual({"FOO": "bar"}, dict(b.env))
self.assertTrue(b.supervise)
def test_child_inherits_parent_fields_unchanged(self):
m = _build(
base={
"env": {"BASE": "1"},
"supervise": True,
},
child={"extends": "base"},
)
c = m.bottles["child"]
self.assertEqual({"BASE": "1"}, dict(c.env))
self.assertTrue(c.supervise)
def test_child_overrides_supervise_scalar(self):
m = _build(
base={"supervise": True},
off={"extends": "base", "supervise": False},
)
self.assertTrue(m.bottles["base"].supervise)
self.assertFalse(m.bottles["off"].supervise)
def test_parent_resolved_once_for_multiple_children(self):
# Two children sharing one parent: both inherit; the parent
# is resolved once + cached. (Cache behavior is internal; we
# observe correctness on both children.)
m = _build(
base={"env": {"BASE": "1"}, "supervise": True},
a={"extends": "base", "env": {"A": "1"}},
b={"extends": "base", "env": {"B": "1"}},
)
self.assertEqual({"BASE": "1", "A": "1"}, dict(m.bottles["a"].env))
self.assertEqual({"BASE": "1", "B": "1"}, dict(m.bottles["b"].env))
class TestExtendsEnvMerge(unittest.TestCase):
"""env: dict merge, child wins on key collision."""
def test_disjoint_keys_union(self):
m = _build(
base={"env": {"PARENT_ONLY": "p"}},
child={"extends": "base", "env": {"CHILD_ONLY": "c"}},
)
self.assertEqual(
{"PARENT_ONLY": "p", "CHILD_ONLY": "c"},
dict(m.bottles["child"].env),
)
def test_collision_child_wins(self):
m = _build(
base={"env": {"SHARED": "from-parent"}},
child={"extends": "base", "env": {"SHARED": "from-child"}},
)
self.assertEqual(
{"SHARED": "from-child"},
dict(m.bottles["child"].env),
)
def test_child_omits_env_inherits_full(self):
m = _build(
base={"env": {"A": "1", "B": "2"}},
child={"extends": "base"},
)
self.assertEqual({"A": "1", "B": "2"}, dict(m.bottles["child"].env))
class TestExtendsGitMerge(unittest.TestCase):
"""git-gate.user overlays by field; git-gate.repos merges by upstream
host, with child entries replacing duplicate hosts."""
_GIT_ENTRY_A = {"url": "ssh://git@host-a/a.git", "identity": "/dev/null"}
_GIT_ENTRY_B = {"url": "ssh://git@host-b/b.git", "identity": "/dev/null"}
def test_child_git_repos_merge_with_parent(self):
m = _build(
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
child={
"extends": "base",
"git-gate": {"repos": {"b": self._GIT_ENTRY_B}},
},
)
names = [e.Name for e in m.bottles["child"].git]
self.assertEqual(["a", "b"], names)
def test_child_git_repo_replaces_same_host(self):
replacement = {"url": "ssh://git@host-a/replacement.git", "identity": "/dev/null"}
m = _build(
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
child={
"extends": "base",
"git-gate": {"repos": {"a2": replacement}},
},
)
entries = m.bottles["child"].git
self.assertEqual(1, len(entries))
self.assertEqual("a2", entries[0].Name)
self.assertEqual("replacement.git", entries[0].UpstreamPath)
def test_child_omits_git_gate_inherits_full_list(self):
m = _build(
base={"git-gate": {"repos": {
"a": self._GIT_ENTRY_A,
"b": self._GIT_ENTRY_B,
}}},
child={"extends": "base"},
)
names = [e.Name for e in m.bottles["child"].git]
self.assertEqual(["a", "b"], names)
def test_child_explicit_empty_repos_clears_parent(self):
# `git-gate.repos: {}` is the documented way to say "drop
# the parent's repos" rather than "inherit them".
m = _build(
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
child={"extends": "base", "git-gate": {"repos": {}}},
)
self.assertEqual((), m.bottles["child"].git)
def test_child_git_user_inherits_parent_repos(self):
m = _build(
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
child={"extends": "base", "git-gate": {"user": {"name": "Child"}}},
)
self.assertEqual(["a"], [e.Name for e in m.bottles["child"].git])
self.assertEqual("Child", m.bottles["child"].git_user.name)
class TestExtendsListsFullReplace(unittest.TestCase):
"""egress: remains full-replace when the child declares it."""
def test_child_egress_replaces_parent_entirely(self):
m = _build(
base={"egress": {"routes": [{"host": "a.example.com"}]}},
child={
"extends": "base",
"egress": {"routes": [{"host": "b.example.com"}]},
},
)
hosts = [r.Host for r in m.bottles["child"].egress.routes]
self.assertEqual(["b.example.com"], hosts)
def test_child_omits_egress_inherits(self):
m = _build(
base={"egress": {"routes": [{"host": "a.example.com"}]}},
child={"extends": "base"},
)
hosts = [r.Host for r in m.bottles["child"].egress.routes]
self.assertEqual(["a.example.com"], hosts)
class TestExtendsGitUserOverlay(unittest.TestCase):
"""git-gate.user: per-field overlay. Each non-empty field on child
wins; empties fall through to parent."""
def test_parent_full_child_omits(self):
m = _build(
base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}},
child={"extends": "base"},
)
u = m.bottles["child"].git_user
self.assertEqual("Parent", u.name)
self.assertEqual("p@x", u.email)
def test_child_overrides_both(self):
m = _build(
base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}},
child={
"extends": "base",
"git-gate": {"user": {"name": "Child", "email": "c@x"}},
},
)
u = m.bottles["child"].git_user
self.assertEqual("Child", u.name)
self.assertEqual("c@x", u.email)
def test_child_adds_email_inherits_name(self):
m = _build(
base={"git-gate": {"user": {"name": "Parent"}}},
child={"extends": "base", "git-gate": {"user": {"email": "c@x"}}},
)
u = m.bottles["child"].git_user
self.assertEqual("Parent", u.name)
self.assertEqual("c@x", u.email)
def test_child_overrides_only_email(self):
m = _build(
base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}},
child={"extends": "base", "git-gate": {"user": {"email": "c@x"}}},
)
u = m.bottles["child"].git_user
self.assertEqual("Parent", u.name)
self.assertEqual("c@x", u.email)
class TestExtendsChain(unittest.TestCase):
"""Multi-step inheritance: A extends B extends C."""
def test_three_step_chain(self):
m = _build(
grandparent={
"env": {"GP": "1"},
"supervise": True,
},
parent={
"extends": "grandparent",
"env": {"P": "1"},
},
child={
"extends": "parent",
"env": {"C": "1"},
},
)
self.assertEqual(
{"GP": "1", "P": "1", "C": "1"},
dict(m.bottles["child"].env),
)
# supervise threads through unchanged.
self.assertTrue(m.bottles["child"].supervise)
def test_intermediate_can_override(self):
m = _build(
grandparent={"env": {"X": "from-gp"}},
parent={"extends": "grandparent", "env": {"X": "from-p"}},
child={"extends": "parent"},
)
self.assertEqual("from-p", m.bottles["child"].env["X"])
class TestExtendsErrors(unittest.TestCase):
def test_missing_parent_dies(self):
msg = _error_message(_build, child={"extends": "ghost"})
self.assertIn("extends 'ghost'", msg)
self.assertIn("not defined", msg)
def test_self_extends_dies(self):
msg = _error_message(_build, loop={"extends": "loop"})
self.assertIn("extends itself", msg)
def test_two_node_cycle_dies(self):
msg = _error_message(
_build,
a={"extends": "b"},
b={"extends": "a"},
)
self.assertIn("extends cycle", msg)
# Chain should include both names.
self.assertIn("a", msg)
self.assertIn("b", msg)
def test_three_node_cycle_dies(self):
msg = _error_message(
_build,
a={"extends": "b"},
b={"extends": "c"},
c={"extends": "a"},
)
self.assertIn("extends cycle", msg)
def test_non_string_extends_dies(self):
msg = _error_message(_build, child={"extends": ["base"]})
self.assertIn("extends must be a string", msg)
class TestExtendsAvailableInBottleKeys(unittest.TestCase):
"""`extends` must not trip the unknown-keys check in the md
loader. Verified indirectly via from_json_obj (same resolver)
+ a positive parse here."""
def test_extends_alone_parses(self):
# No other fields; child purely inherits.
m = _build(
base={"env": {"A": "1"}},
child={"extends": "base"},
)
self.assertEqual({"A": "1"}, dict(m.bottles["child"].env))
if __name__ == "__main__":
unittest.main()