464 lines
22 KiB
Python
464 lines
22 KiB
Python
"""Deploy freshness — the deploy/ scripts must actually ship the latest assets.
|
|
|
|
``deploy/install.sh`` treats only ``.agents/skills/**`` as framework-owned. Every
|
|
other shipped asset (``.agents/hooks.json``, ``.agents/hooks/*.sh``,
|
|
``.agents/MULTI_AGENT_RULES*.md``, ``.agents/INSTALL.md``) takes the
|
|
``elif [ ! -e "$dest" ]`` branch, so it is copied once and never refreshed.
|
|
``deploy/remove.sh``'s manifest-less fallback list is likewise frozen at the
|
|
pre-loop skill set, so it strands those same assets plus the whole
|
|
``multi-agent-mux-loop`` skill.
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
# Framework assets that live outside .agents/skills/ and must still be
|
|
# refreshed. Mapped as {installed path: path in the source tree} because the
|
|
# user manual is installed from deploy/, not from .agents/.
|
|
NON_SKILL_ASSETS = {
|
|
".agents/hooks.json": ".agents/hooks.json",
|
|
".agents/hooks/loop_delegation_guard.sh": ".agents/hooks/loop_delegation_guard.sh",
|
|
".agents/MULTI_AGENT_RULES.md": ".agents/MULTI_AGENT_RULES.md",
|
|
".agents/MULTI_AGENT_RULES.ko.md": ".agents/MULTI_AGENT_RULES.ko.md",
|
|
".agents/INSTALL.md": "deploy/INSTALL.md",
|
|
}
|
|
|
|
MARK = "UPSTREAM-RELEASE-MARKER"
|
|
|
|
|
|
@pytest.fixture
|
|
def src_and_target():
|
|
"""A pristine copy of the working tree plus an empty install target.
|
|
|
|
Runtime directories are excluded so the fixture stays fast and so the
|
|
source can never be confused with an already-installed workspace.
|
|
"""
|
|
tmp = tempfile.mkdtemp(prefix="mam_deploy_fresh_")
|
|
src, tgt = os.path.join(tmp, "src"), os.path.join(tmp, "tgt")
|
|
shutil.copytree(REPO_ROOT, src,
|
|
ignore=shutil.ignore_patterns(".git", ".venv", ".mam",
|
|
".mam_deploy", "__pycache__"),
|
|
symlinks=True)
|
|
os.makedirs(tgt)
|
|
try:
|
|
yield src, tgt
|
|
finally:
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
|
|
|
|
def _install(src, tgt, **extra):
|
|
env = dict(os.environ)
|
|
env.update({"MAM_REPO_URL": src, "MAM_SKIP_VENV": "1",
|
|
"MAM_SKIP_GITIGNORE": "1"})
|
|
env.update(extra)
|
|
return subprocess.run(["bash", os.path.join(src, "deploy", "install.sh"), tgt],
|
|
env=env, capture_output=True, text=True)
|
|
|
|
|
|
def _bump(src, rel):
|
|
"""Simulate an upstream release that changed ``rel``."""
|
|
path = os.path.join(src, rel)
|
|
assert os.path.exists(path), "no such source asset: %s" % rel
|
|
if rel.endswith(".json"):
|
|
with open(path) as f:
|
|
data = json.load(f)
|
|
data["_release"] = MARK
|
|
with open(path, "w") as f:
|
|
json.dump(data, f, indent=2)
|
|
else:
|
|
with open(path, "a") as f:
|
|
f.write("\n# %s\n" % MARK)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-1 — a refresh install must deliver upstream changes to EVERY framework
|
|
# asset, not only to those under .agents/skills/.
|
|
# --------------------------------------------------------------------------
|
|
def test_d1_refresh_updates_non_skill_framework_assets(src_and_target):
|
|
src, tgt = src_and_target
|
|
assert _install(src, tgt).returncode == 0
|
|
|
|
for source_rel in NON_SKILL_ASSETS.values():
|
|
_bump(src, source_rel)
|
|
_bump(src, ".agents/skills/lib.sh") # control: this one is known to work
|
|
|
|
res = _install(src, tgt)
|
|
assert res.returncode == 0, res.stderr
|
|
|
|
with open(os.path.join(tgt, ".agents/skills/lib.sh")) as f:
|
|
assert MARK in f.read(), (
|
|
"control failed: even a skills/ file did not refresh, so this test "
|
|
"is not measuring what it claims")
|
|
|
|
stale = []
|
|
for rel in NON_SKILL_ASSETS:
|
|
with open(os.path.join(tgt, rel)) as f:
|
|
if MARK not in f.read():
|
|
stale.append(rel)
|
|
assert not stale, (
|
|
"install.sh refreshed .agents/skills/ but left these framework assets "
|
|
"at their originally-installed version: %s. The O-3 guard and the "
|
|
"MULTI_AGENT_RULES protocol therefore never reach an existing "
|
|
"workspace." % stale)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-2 — remove.sh's manifest-less fallback must clear every asset the
|
|
# installers write, or the next install is blocked by the survivors
|
|
# (install.sh only copies a non-skill asset when it does not exist).
|
|
# --------------------------------------------------------------------------
|
|
def test_d2_manifestless_removal_strands_no_framework_assets(src_and_target):
|
|
src, tgt = src_and_target
|
|
assert _install(src, tgt).returncode == 0
|
|
|
|
# An install_mam.sh deployment leaves no manifest; force that code path.
|
|
os.remove(os.path.join(tgt, ".mam", "install_manifest.txt"))
|
|
|
|
res = subprocess.run(["bash", os.path.join(tgt, ".mam_deploy", "remove.sh"),
|
|
"--force", tgt], capture_output=True, text=True)
|
|
assert res.returncode == 0, res.stderr
|
|
|
|
survivors = []
|
|
for root, _, files in os.walk(os.path.join(tgt, ".agents")):
|
|
for f in files:
|
|
survivors.append(os.path.relpath(os.path.join(root, f), tgt))
|
|
assert not survivors, (
|
|
"the manifest-less fallback left framework assets behind: %s. Because "
|
|
"install.sh copies a non-skill asset only when it is absent, these "
|
|
"survivors permanently pin the workspace to the old version."
|
|
% sorted(survivors))
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-3 — install_mam.sh must record a manifest, so that remove.sh/update.sh
|
|
# take the exact-reversal path instead of the frozen fallback list.
|
|
# --------------------------------------------------------------------------
|
|
def test_d3_install_mam_records_a_manifest():
|
|
body = open(os.path.join(REPO_ROOT, "deploy", "install_mam.sh")).read()
|
|
assert "install_manifest.txt" in body, (
|
|
"deploy/install_mam.sh writes no .mam/install_manifest.txt, so every "
|
|
"workspace it deploys is uninstalled and updated through remove.sh's "
|
|
"frozen fallback list")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-5 — the fetch-validation gate must cover the assets a broken fetch would
|
|
# plausibly drop, including the O-3 hook pair.
|
|
# --------------------------------------------------------------------------
|
|
def test_d5_asset_presence_gate_covers_the_hook_pair():
|
|
body = open(os.path.join(REPO_ROOT, "deploy", "install.sh")).read()
|
|
start = body.index("check_assets_present()")
|
|
gate = body[start:body.index("}", body.index("return 0", start))]
|
|
for rel in (".agents/hooks.json", ".agents/hooks/loop_delegation_guard.sh"):
|
|
assert rel in gate, (
|
|
"check_assets_present() does not require %s, so a fetch that "
|
|
"silently dropped the O-3 guard still passes validation and "
|
|
"installs a workspace whose guard is inert" % rel)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-6 — asset_hashes.txt drives both the refresh 3-way merge and remove.sh's
|
|
# "preserve modified files" backup. Framework files outside skills/ get
|
|
# neither today.
|
|
# --------------------------------------------------------------------------
|
|
def test_d6_hash_db_covers_non_skill_framework_assets(src_and_target):
|
|
src, tgt = src_and_target
|
|
assert _install(src, tgt).returncode == 0
|
|
|
|
with open(os.path.join(tgt, ".mam", "asset_hashes.txt")) as f:
|
|
db = f.read()
|
|
missing = [rel for rel in NON_SKILL_ASSETS if rel not in db]
|
|
assert not missing, (
|
|
"these framework assets are absent from .mam/asset_hashes.txt: %s. "
|
|
"Local edits to them are silently destroyed by remove.sh instead of "
|
|
"being backed up." % missing)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-7 — every variable the installer writes into a fresh .mam.env must be
|
|
# documented in the committed template.
|
|
# --------------------------------------------------------------------------
|
|
def test_d7_env_template_documents_installer_applied_defaults():
|
|
installer = open(os.path.join(REPO_ROOT, "deploy", "install.sh")).read()
|
|
start_idx = installer.index("cat <<EOF > \"$MAM_ENV\"")
|
|
end_idx = installer.index("EOF", start_idx + 15)
|
|
block = installer[start_idx:end_idx]
|
|
written = [ln.split("=", 1)[0].strip() for ln in block.splitlines()
|
|
if "=" in ln and not ln.strip().startswith("#")]
|
|
|
|
template = open(os.path.join(REPO_ROOT, ".mam.env.example")).read()
|
|
undocumented = [v for v in written if v not in template]
|
|
assert not undocumented, (
|
|
"install.sh seeds %s into every new .mam.env, but .mam.env.example "
|
|
"never mentions them, so users cannot discover or correct them"
|
|
% undocumented)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-8 — CI must lint the shipped hook and run the test suite that every
|
|
# recent feature commit claims as its evidence.
|
|
# --------------------------------------------------------------------------
|
|
def test_d8_ci_lints_the_hook_and_runs_the_tests():
|
|
ci = open(os.path.join(REPO_ROOT, "deploy", "gitea-ci.yml")).read()
|
|
assert ".agents/hooks/loop_delegation_guard.sh" in ci, (
|
|
"gitea-ci.yml shellchecks every other shipped bash entrypoint but not "
|
|
"the O-3 hook")
|
|
assert "pytest" in ci, (
|
|
"gitea-ci.yml runs no pytest job, so none of the 17 files under tests/ "
|
|
"ever gate a release")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-9 — .agents/INSTALL.md is shipped from two different sources: the .agents/
|
|
# walk and the later `cp deploy/INSTALL.md`, whose `[ ! -e ]` guard can
|
|
# therefore never fire. One of the two must not exist, or the herdr
|
|
# migration's doc update silently loses to the tmux-era copy.
|
|
# --------------------------------------------------------------------------
|
|
def test_d9_installed_manual_is_not_the_stale_tmux_era_copy(src_and_target):
|
|
src, tgt = src_and_target
|
|
assert _install(src, tgt).returncode == 0
|
|
|
|
with open(os.path.join(tgt, ".agents", "INSTALL.md")) as f:
|
|
shipped = f.read()
|
|
assert "tmux" not in shipped, (
|
|
"the installed .agents/INSTALL.md is the pre-herdr copy. install.sh "
|
|
"stages .agents/INSTALL.md from the repo first, so its later "
|
|
"`cp deploy/INSTALL.md` -- guarded by `[ ! -e ]` -- never runs and the "
|
|
"up-to-date manual is never delivered")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-10 — a preserved customization must stay preserved across REPEATED
|
|
# refreshes. The hash DB was rebuilt from the working copy, so the
|
|
# second refresh saw target == db, classified the file as unmodified,
|
|
# and overwrote it with no notice.
|
|
# --------------------------------------------------------------------------
|
|
def test_d10_customization_survives_repeated_refresh(src_and_target):
|
|
src, tgt = src_and_target
|
|
assert _install(src, tgt).returncode == 0
|
|
|
|
skill = os.path.join(tgt, ".agents", "skills", "lib.sh")
|
|
with open(skill, "a") as f:
|
|
f.write("\n# MY_LOCAL_TWEAK\n")
|
|
|
|
for n in (2, 3, 4):
|
|
if n == 4: # upstream also moves on
|
|
_bump(src, ".agents/skills/lib.sh")
|
|
res = _install(src, tgt)
|
|
assert res.returncode == 0, res.stderr
|
|
with open(skill) as f:
|
|
body = f.read()
|
|
assert "MY_LOCAL_TWEAK" in body, (
|
|
"the local customization was destroyed on refresh #%d. install.sh "
|
|
"rebuilds .mam/asset_hashes.txt from the working copy, so after "
|
|
"the first PRESERVE the modified hash becomes the canonical one "
|
|
"and the next refresh silently overwrites it." % n)
|
|
assert "Local modification detected" in res.stderr, (
|
|
"refresh #%d overwrote nothing but also reported nothing; the "
|
|
"user gets no signal that their edit is diverging" % n)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-11 — (G-D1) PRIVATE_SERVER.md must only document MQTT_* environment
|
|
# variables that broker_config_from_env() actually parses.
|
|
# --------------------------------------------------------------------------
|
|
def test_d11_private_server_env_names_valid():
|
|
sys.path.insert(0, os.path.join(REPO_ROOT, ".agents", "skills", "multi-agent-mux-delegate-job", "scripts"))
|
|
import mqtt_common
|
|
|
|
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
|
|
assert os.path.exists(doc_path), "PRIVATE_SERVER.md missing"
|
|
with open(doc_path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
# Extract all code blocks
|
|
code_blocks = re.findall(r"```(?:bash|conf|yaml|)(.*?)```", content, re.DOTALL)
|
|
assert code_blocks, "No code blocks found in PRIVATE_SERVER.md"
|
|
|
|
# Known recognized MQTT env vars from broker_config_from_env() and deployment
|
|
valid_mqtt_vars = {
|
|
"MQTT_BROKER", "MQTT_PORT", "MQTT_TLS", "MQTT_USERNAME", "MQTT_PASSWORD",
|
|
"MQTT_CLIENT_ID_PREFIX", "MQTT_CA_CERTS", "MQTT_CERTFILE", "MQTT_KEYFILE",
|
|
"MQTT_KEEPALIVE", "MQTT_BIND"
|
|
}
|
|
|
|
for block in code_blocks:
|
|
found_vars = set(re.findall(r"\b(MQTT_[A-Z0-9_]+)\b", block))
|
|
invalid = found_vars - valid_mqtt_vars
|
|
assert not invalid, f"Invalid or unrecognized MQTT variables in PRIVATE_SERVER.md code blocks: {invalid}"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-12 — (G-D2) PRIVATE_SERVER.md must not contain invalid MAM_MQTT_* in
|
|
# active configuration code blocks.
|
|
# --------------------------------------------------------------------------
|
|
def test_d12_private_server_no_mam_mqtt_in_code_fences():
|
|
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
|
|
with open(doc_path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
code_blocks = re.findall(r"```(?:bash|conf|yaml|)(.*?)```", content, re.DOTALL)
|
|
for i, block in enumerate(code_blocks):
|
|
assert "MAM_MQTT_" not in block, (
|
|
f"Code block #{i+1} in PRIVATE_SERVER.md contains deprecated/invalid 'MAM_MQTT_*' prefix"
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-13 — (G-D3) nats-server launch instructions in PRIVATE_SERVER.md must
|
|
# use valid config blocks (mqtt {) and not HTTP port flag (-m 1883).
|
|
# --------------------------------------------------------------------------
|
|
def test_d13_private_server_nats_config_valid():
|
|
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
|
|
with open(doc_path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
assert "-m 1883" not in content, (
|
|
"PRIVATE_SERVER.md incorrectly contains '-m 1883' (which sets HTTP port, not MQTT port)"
|
|
)
|
|
assert "mqtt {" in content, "PRIVATE_SERVER.md must document 'mqtt {' configuration block for nats-server"
|
|
assert "-c " in content or "-c /" in content, "PRIVATE_SERVER.md must document '-c <config>' for nats-server"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-14 — (G-D4) Verification commands in PRIVATE_SERVER.md must use valid
|
|
# CLI flags matching the actual scripts' argparse parsers.
|
|
# --------------------------------------------------------------------------
|
|
def test_d14_private_server_cli_args_valid():
|
|
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
|
|
with open(doc_path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
# Extract all command invocations for registry.py and publish_event.py
|
|
code_blocks = "\n".join(re.findall(r"```(?:bash|)(.*?)```", content, re.DOTALL))
|
|
|
|
# Assert --job-id is not used with register command (register takes --prompt, not --job-id)
|
|
# and registry.py commands have correct flag formatting
|
|
assert "register --job-id" not in code_blocks, (
|
|
"PRIVATE_SERVER.md contains invalid 'register --job-id' (registry.py register auto-assigns ID and takes no --job-id flag)"
|
|
)
|
|
assert "status --job " in code_blocks, "PRIVATE_SERVER.md must include cleanup step with status --job"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-15 — (G-D5) store_dir in code blocks must be absolute path (/ or $HOME)
|
|
# and heredocs writing it must be unquoted (<<EOF).
|
|
# --------------------------------------------------------------------------
|
|
def test_d15_private_server_store_dir_valid():
|
|
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
|
|
with open(doc_path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
code_blocks = re.findall(r"```(?:bash|conf|yaml|)(.*?)```", content, re.DOTALL)
|
|
for i, block in enumerate(code_blocks):
|
|
for match in re.finditer(r'store_dir:\s*["\']?([^"\'\n]+)["\']?', block):
|
|
val = match.group(1).strip()
|
|
assert val.startswith("/") or val.startswith("$HOME"), (
|
|
f"Block #{i+1} store_dir '{val}' must start with '/' or '$HOME' (no literal ~)"
|
|
)
|
|
if "store_dir:" in block and "cat <<" in block:
|
|
assert "<<'EOF'" not in block, (
|
|
f"Block #{i+1} writes store_dir with quoted heredoc <<'EOF', which prevents $HOME expansion"
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-16 — (G-D6) nats image references in code fences must use pinned alpine
|
|
# --------------------------------------------------------------------------
|
|
def test_d16_private_server_nats_image_alpine_pinned():
|
|
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
|
|
with open(doc_path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
code_blocks = re.findall(r"```(?:bash|conf|yaml|)(.*?)```", content, re.DOTALL)
|
|
for i, block in enumerate(code_blocks):
|
|
nats_refs = re.findall(r'\bnats:([a-zA-Z0-9_.-]+)', block)
|
|
for tag in nats_refs:
|
|
assert tag != "latest", f"Block #{i+1} contains unpinned 'nats:latest'"
|
|
assert "-alpine" in tag or tag.startswith("2."), f"Block #{i+1} nats image '{tag}' must use alpine variant"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-17 — (G-D7) Port 8222 in docker examples must be bound to 127.0.0.1
|
|
# --------------------------------------------------------------------------
|
|
def test_d17_private_server_monitoring_port_localhost_bound():
|
|
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
|
|
with open(doc_path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
code_blocks = re.findall(r"```(?:bash|conf|yaml|)(.*?)```", content, re.DOTALL)
|
|
for i, block in enumerate(code_blocks):
|
|
for match in re.finditer(r'["\']?([0-9a-zA-Z._$:-]*8222:8222)["\']?', block):
|
|
mapping = match.group(1).strip()
|
|
assert "127.0.0.1:8222:8222" in mapping, (
|
|
f"Block #{i+1} port 8222 must be bound to 127.0.0.1, got '{mapping}'"
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-18 — (G-D8) TLS examples must not use IP literals for MQTT_BROKER
|
|
# --------------------------------------------------------------------------
|
|
def test_d18_private_server_tls_examples_use_domain_names():
|
|
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
|
|
with open(doc_path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
code_blocks = re.findall(r"```(?:bash|conf|yaml|)(.*?)```", content, re.DOTALL)
|
|
for i, block in enumerate(code_blocks):
|
|
if "MQTT_TLS=1" in block or "port: 8883" in block:
|
|
for match in re.finditer(r'MQTT_BROKER=["\']?([0-9.]+)', block):
|
|
ip = match.group(1)
|
|
assert False, f"Block #{i+1} uses IP literal '{ip}' with TLS (must use DNS domain name for SAN verification)"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-19 — (G-D9) Subject literals in config examples match DEFAULT_TOPIC_ROOT
|
|
# --------------------------------------------------------------------------
|
|
def test_d19_private_server_subject_literals_match_default_topic_root():
|
|
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
|
|
with open(doc_path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
sys.path.insert(0, os.path.join(REPO_ROOT, ".agents", "skills", "multi-agent-mux-delegate-job", "scripts"))
|
|
import mqtt_common
|
|
expected_prefix = mqtt_common.DEFAULT_TOPIC_ROOT.replace("/", ".")
|
|
matches = re.findall(r'["\'](python\.mqtt\.jobs\.[>*\w.]+)["\']', content)
|
|
assert matches, "Expected subject literals matching DEFAULT_TOPIC_ROOT in PRIVATE_SERVER.md"
|
|
for sub in matches:
|
|
assert sub.startswith(expected_prefix), f"Subject '{sub}' does not start with expected prefix '{expected_prefix}'"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-20 — (G-R1) run_loop.sh exports MAM_ENV_FILE
|
|
# --------------------------------------------------------------------------
|
|
def test_d20_run_loop_exports_mam_env_file():
|
|
run_loop_path = os.path.join(REPO_ROOT, ".agents", "skills", "multi-agent-mux-loop", "scripts", "run_loop.sh")
|
|
with open(run_loop_path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
assert 'export MAM_ENV_FILE=' in content, "run_loop.sh must export MAM_ENV_FILE"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# D-21 — (G-R2) MQTT_KEEPALIVE documented and no un-commented retry vars
|
|
# --------------------------------------------------------------------------
|
|
def test_d21_env_template_mqtt_var_coverage():
|
|
template_path = os.path.join(REPO_ROOT, ".mam.env.example")
|
|
with open(template_path, "r", encoding="utf-8") as f:
|
|
template = f.read()
|
|
assert "MQTT_KEEPALIVE" in template, ".mam.env.example must document MQTT_KEEPALIVE"
|
|
for line in template.splitlines():
|
|
line = line.strip()
|
|
if not line.startswith("#"):
|
|
assert "MQTT_RETRY_INTERVAL" not in line
|
|
assert "MQTT_MAX_RETRIES" not in line
|
|
|
|
|