Files
magnus919_agent-skills/release-engineering/tests/test_release_plan_scaffold.py
T
usernameandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 6730015312 feat(release-engineering): add release engineering skill bundle
Dense release engineering methodology bundle: 15 references (role/career, process models,
CD pipelines, progressive delivery, change governance/compliance, readiness gates, rollback
by system type, versioning/artifacts, feature flags, monorepo/polyrepo, toolchain,
supply-chain security, DORA metrics, release operations), 6 templates, 4 assets, 5 stdlib-only
Python CLIs (version_bump, semver_check, changelog_check, dora_metrics, release_plan_scaffold)
with colocated tests, schema-v1 evals (8 cases), README, and catalog regeneration.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-08-01 22:54:13 -04:00

237 lines
9.2 KiB
Python

"""Tests for release_plan_scaffold.py.
Covers: rendered document structure, --output file writing, --json output
with parsed fields, --git-range scope source, determinism, and exit codes.
Discoverable by both pytest and unittest (unittest.TestCase classes).
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
SCRIPTS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "scripts")
PLAN_SCAFFOLD = os.path.join(SCRIPTS_DIR, "release_plan_scaffold.py")
BASE_ARGS = [
"--version", "1.2.3",
"--name", "Acme 1.2.3",
"--date", "2026-08-15",
"--owner", "Jane Doe",
"--milestones", "Branch cut;Release candidate;GA",
"--scope", "feat: new API;fix: retry bug",
"--risks", "DB migration late;API contract change",
]
def run_plan(args, cwd=None):
"""Run release_plan_scaffold.py with given args, return (rc, stdout, stderr)."""
cmd = [sys.executable, PLAN_SCAFFOLD] + args
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30, cwd=cwd)
return proc.returncode, proc.stdout, proc.stderr
class TestReleasePlanScaffoldRender(unittest.TestCase):
"""Rendered document structure."""
def test_document_contains_fields(self):
"""The rendered markdown contains the provided fields."""
rc, out, _ = run_plan(BASE_ARGS)
self.assertEqual(rc, 0)
self.assertIn("# Release Plan: Acme 1.2.3", out)
self.assertIn("| Version | 1.2.3 |", out)
self.assertIn("| Target date (GA) | 2026-08-15 |", out)
self.assertIn("| Release manager (DRI) | Jane Doe |", out)
def test_document_contains_lists(self):
"""Milestones, scope, and risks are rendered as tables."""
rc, out, _ = run_plan(BASE_ARGS)
self.assertEqual(rc, 0)
self.assertIn("| Branch cut | TBD | TBD | TBD |", out)
self.assertIn("| S-1 | feat: new API | feat | — |", out)
self.assertIn("| R-1 | DB migration late | TBD | TBD | TBD | TBD |", out)
def test_standard_sections_present(self):
"""The release plan template structure is complete."""
rc, out, _ = run_plan(BASE_ARGS)
self.assertEqual(rc, 0)
for section in (
"## Metadata",
"## 1. Overview and Scope",
"## 2. Versioning and Artifacts",
"## 3. Timeline and Milestones",
"## 4. Owners and RACI",
"## 5. Risks and Mitigations",
"## 6. Rollout Plan",
"## 7. Rollback Contingency",
"## 8. Communication Plan",
"## 9. Post-Release Monitoring",
"## 10. Sign-offs",
):
self.assertIn(section, out)
def test_default_name_and_placeholders(self):
"""Unspecified fields fall back to deterministic TBD placeholders."""
rc, out, _ = run_plan(["--version", "1.2.3"])
self.assertEqual(rc, 0)
self.assertIn("# Release Plan: Release 1.2.3", out)
self.assertIn("| Target date (GA) | TBD |", out)
self.assertIn("| Release manager (DRI) | TBD |", out)
def test_empty_lists_render_tbd(self):
"""No scope/milestones/risks render TBD placeholder rows."""
rc, out, _ = run_plan(["--version", "1.2.3"])
self.assertEqual(rc, 0)
self.assertIn("_TBD_", out)
def test_deterministic_output(self):
"""Two runs on identical input produce identical output."""
rc1, out1, _ = run_plan(BASE_ARGS)
rc2, out2, _ = run_plan(BASE_ARGS)
self.assertEqual(rc1, 0)
self.assertEqual(rc2, 0)
self.assertEqual(out1, out2)
class TestReleasePlanScaffoldOutput(unittest.TestCase):
"""--output and --json."""
def test_output_writes_file(self):
"""--output writes the rendered markdown to the file."""
with tempfile.TemporaryDirectory() as tmp:
target = os.path.join(tmp, "release-plan.md")
rc, out, _ = run_plan(BASE_ARGS + ["--output", target])
self.assertEqual(rc, 0)
self.assertEqual(out, "")
with open(target, "r") as fh:
content = fh.read()
self.assertIn("# Release Plan: Acme 1.2.3", content)
def test_json_parseable(self):
"""--json emits parsed fields plus the rendered document."""
rc, out, _ = run_plan(BASE_ARGS + ["--json"])
self.assertEqual(rc, 0)
data = json.loads(out)
self.assertEqual(data["version"], "1.2.3")
self.assertEqual(data["name"], "Acme 1.2.3")
self.assertEqual(data["date"], "2026-08-15")
self.assertEqual(data["owner"], "Jane Doe")
self.assertEqual(data["milestones"], ["Branch cut", "Release candidate", "GA"])
self.assertEqual(data["scope"], ["feat: new API", "fix: retry bug"])
self.assertEqual(data["risks"], ["DB migration late", "API contract change"])
self.assertEqual(data["scope_source"], "flags")
self.assertIn("# Release Plan: Acme 1.2.3", data["document"])
def test_json_with_output_writes_both(self):
"""--json plus --output writes the file and prints JSON."""
with tempfile.TemporaryDirectory() as tmp:
target = os.path.join(tmp, "plan.md")
rc, out, _ = run_plan(BASE_ARGS + ["--json", "--output", target])
self.assertEqual(rc, 0)
data = json.loads(out)
self.assertEqual(data["version"], "1.2.3")
with open(target, "r") as fh:
self.assertIn("# Release Plan: Acme 1.2.3", fh.read())
def test_json_deterministic(self):
"""Two --json runs produce identical output."""
rc1, out1, _ = run_plan(BASE_ARGS + ["--json"])
rc2, out2, _ = run_plan(BASE_ARGS + ["--json"])
self.assertEqual(rc1, 0)
self.assertEqual(rc2, 0)
self.assertEqual(out1, out2)
class TestReleasePlanScaffoldGitRange(unittest.TestCase):
"""--git-range pulls scope from commit subjects."""
@unittest.skipUnless(shutil.which("git"), "git not available")
def test_git_range_scope(self):
"""Commit subjects become the scope items."""
with tempfile.TemporaryDirectory() as tmp:
subprocess.run(["git", "init", "-q"], cwd=tmp, check=True)
subprocess.run(
["git", "-c", "user.name=t", "-c", "user.email=t@t",
"commit", "--allow-empty", "-q", "-m", "chore: init"],
cwd=tmp, check=True,
)
first_sha = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=tmp, check=True,
capture_output=True, text=True,
).stdout.strip()
for message in ("feat: widget", "fix: retry"):
subprocess.run(
["git", "-c", "user.name=t", "-c", "user.email=t@t",
"commit", "--allow-empty", "-q", "-m", message],
cwd=tmp, check=True,
)
rc, out, _ = run_plan(
["--version", "1.2.3", "--git-range", "{}..HEAD".format(first_sha),
"--json"],
cwd=tmp,
)
self.assertEqual(rc, 0)
data = json.loads(out)
self.assertEqual(data["scope_source"], "git-range")
self.assertEqual(data["scope"], ["feat: widget", "fix: retry"])
# Short commit SHAs are surfaced for the in-scope table.
self.assertEqual(len(data["scope_sources"]), 2)
self.assertTrue(all(data["scope_sources"]))
self.assertIn("feat: widget", data["document"])
self.assertIn("| feat |", data["document"])
def test_git_range_failure_exit_1(self):
"""An invalid git range is an input error."""
rc, _, err = run_plan(
["--version", "1.2.3", "--git-range", "nope..HEAD"]
)
self.assertEqual(rc, 1)
self.assertIn("error", err.lower())
class TestReleasePlanScaffoldExitCodes(unittest.TestCase):
"""Exit codes and usage errors."""
def test_missing_version_exit_2(self):
"""Missing --version is a usage error (exit 2)."""
rc, _, err = run_plan(["--name", "X"])
self.assertEqual(rc, 2)
self.assertIn("usage", err.lower())
def test_scope_and_git_range_conflict_exit_2(self):
"""--scope and --git-range together is a usage error."""
rc, _, _ = run_plan(
["--version", "1.2.3", "--scope", "a;b", "--git-range", "main..HEAD"]
)
self.assertEqual(rc, 2)
def test_unwritable_output_exit_1(self):
"""An unwritable output path is an input error."""
rc, _, err = run_plan(BASE_ARGS + ["--output", "/nonexistent/dir/plan.md"])
self.assertEqual(rc, 1)
self.assertIn("error", err.lower())
def test_no_traceback_on_error(self):
"""Errors never produce a traceback."""
rc, out, err = run_plan(
["--version", "1.2.3", "--git-range", "nope..HEAD"]
)
self.assertNotEqual(rc, 0)
self.assertNotIn("Traceback", err)
self.assertNotIn("Traceback", out)
def test_help_exits_0(self):
"""--help exits 0 and describes usage."""
rc, out, _ = run_plan(["--help"])
self.assertEqual(rc, 0)
self.assertIn("usage", out.lower())
self.assertIn("release_plan_scaffold", out.lower())
if __name__ == "__main__":
unittest.main()