MIL-010 task 6: reusable workflow code-metrics.yml and tools/code-metrics.sh
Task: MIL-010#6 Refs #160
This commit is contained in:
@@ -280,3 +280,10 @@ class TestMain:
|
||||
code = badges.main(["--metrics", str(bad), "--output-dir", str(tmp_path / "o")])
|
||||
assert code == 1
|
||||
assert "cannot be read" in capsys.readouterr().out
|
||||
|
||||
def test_no_code_files_is_a_notice_and_badges_that_show_zero(self, tmp_path, capsys):
|
||||
out = tmp_path / "out"
|
||||
metrics = metrics_file(tmp_path, files=0, lines=0)
|
||||
assert badges.main(["--metrics", str(metrics), "--output-dir", str(out)]) == 0
|
||||
assert "::notice::No code files were found" in capsys.readouterr().out
|
||||
assert 'aria-label="code files: 0"' in (out / "code-files.svg").read_text()
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Offline tests for the reusable code-metrics workflow and tools/code-metrics.sh (ADR-0010).
|
||||
|
||||
The script steps run in a throwaway git repository whose `origin` is a bare
|
||||
repository on disk, so no network and no instance is needed.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from workflow_source import REPO_ROOT
|
||||
|
||||
WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "code-metrics.yml"
|
||||
SCRIPT = REPO_ROOT / "tools" / "code-metrics.sh"
|
||||
BASH = shutil.which("bash")
|
||||
GIT = shutil.which("git")
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
BASH is None or GIT is None, reason="bash and git are needed to run the workflow steps"
|
||||
)
|
||||
|
||||
COVERAGE = '<?xml version="1.0" ?>\n<coverage lines-covered="87" lines-valid="100"><packages/></coverage>\n'
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def document() -> dict:
|
||||
return yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class TestDefinition:
|
||||
def test_is_a_reusable_workflow_with_the_contract_inputs(self, document):
|
||||
inputs = document[True]["workflow_call"]["inputs"] # PyYAML reads `on` as True
|
||||
assert {name: item["default"] for name, item in inputs.items()} == {
|
||||
"working-directory": ".",
|
||||
"exclude": "",
|
||||
"coverage-report": "",
|
||||
"coverage-warn": "60",
|
||||
"coverage-good": "80",
|
||||
"badges-branch": "badges",
|
||||
"tools-ref": "v1",
|
||||
}
|
||||
|
||||
def test_asks_for_contents_write_and_nothing_else_and_needs_no_secret(self, document):
|
||||
job = document["jobs"]["code-metrics"]
|
||||
assert job["permissions"] == {"contents": "write"}
|
||||
assert "secrets" not in WORKFLOW.read_text(encoding="utf-8").replace("needs no secret", "")
|
||||
|
||||
def test_inputs_reach_the_scripts_only_through_the_environment(self, document):
|
||||
job = document["jobs"]["code-metrics"]
|
||||
for step in job["steps"]:
|
||||
assert "${{" not in step.get("run", ""), f"{step.get('name')} interpolates an expression"
|
||||
assert set(job["env"]) == {
|
||||
"WORKING_DIRECTORY",
|
||||
"EXCLUDE",
|
||||
"COVERAGE_REPORT",
|
||||
"COVERAGE_WARN",
|
||||
"COVERAGE_GOOD",
|
||||
"BADGES_BRANCH",
|
||||
}
|
||||
|
||||
def test_the_workflow_embeds_no_script_and_runs_the_file(self, document):
|
||||
steps = document["jobs"]["code-metrics"]["steps"]
|
||||
assert [step["run"] for step in steps if "run" in step] == [
|
||||
"bash shared-scripts/tools/code-metrics.sh validate",
|
||||
"bash shared-scripts/tools/code-metrics.sh measure",
|
||||
"bash shared-scripts/tools/code-metrics.sh publish",
|
||||
]
|
||||
|
||||
def test_the_scripts_are_fetched_at_the_tools_ref_input(self, document):
|
||||
steps = document["jobs"]["code-metrics"]["steps"]
|
||||
fetch = next(step for step in steps if step.get("name") == "Fetch the shared scripts")
|
||||
assert fetch["with"] == {
|
||||
"repository": "TirSystem/github-action",
|
||||
"ref": "${{ inputs.tools-ref }}",
|
||||
"path": "shared-scripts",
|
||||
}
|
||||
|
||||
def test_the_script_parses(self):
|
||||
result = subprocess.run([BASH, "-n", str(SCRIPT)], text=True, capture_output=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
def test_an_unknown_command_is_refused(self):
|
||||
result = subprocess.run([BASH, str(SCRIPT), "bogus"], text=True, capture_output=True)
|
||||
assert result.returncode == 2
|
||||
|
||||
|
||||
def git(*args: str, cwd: Path) -> str:
|
||||
result = subprocess.run(
|
||||
[GIT, "-c", "user.name=t", "-c", "user.email=t@t", *args],
|
||||
cwd=cwd,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def python3_bin(tmp_path) -> Path:
|
||||
"""A directory with a `python3` that runs the interpreter of the tests."""
|
||||
directory = tmp_path / "pybin"
|
||||
directory.mkdir()
|
||||
shim = directory / "python3"
|
||||
shim.write_text(f'#!/bin/sh\nexec "{Path(sys.executable).as_posix()}" "$@"\n', newline="\n")
|
||||
shim.chmod(0o755)
|
||||
return directory
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def remote(tmp_path) -> Path:
|
||||
path = tmp_path / "remote.git"
|
||||
subprocess.run([GIT, "init", "-q", "--bare", str(path)], check=True)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def repo(tmp_path, remote) -> Path:
|
||||
root = tmp_path / "repo"
|
||||
root.mkdir()
|
||||
git("init", "-q", "-b", "main", cwd=root)
|
||||
git("remote", "add", "origin", str(remote), cwd=root)
|
||||
(root / "a.py").write_text("x = 1\n# c\n\n", newline="\n")
|
||||
(root / "run.sh").write_text("echo hi\n", newline="\n")
|
||||
git("add", "-A", cwd=root)
|
||||
git("commit", "-q", "-m", "init", cwd=root)
|
||||
return root
|
||||
|
||||
|
||||
def run(command: str, repo: Path, tmp_path: Path, python3_bin: Path, **env: str):
|
||||
environment = dict(os.environ)
|
||||
environment.update(
|
||||
{
|
||||
"WORKING_DIRECTORY": ".",
|
||||
"EXCLUDE": "",
|
||||
"COVERAGE_REPORT": "",
|
||||
"COVERAGE_WARN": "60",
|
||||
"COVERAGE_GOOD": "80",
|
||||
"BADGES_BRANCH": "badges",
|
||||
"BADGES_OUT": str(tmp_path / "out"),
|
||||
"PATH": f"{python3_bin.as_posix()}{os.pathsep}{environment['PATH']}",
|
||||
}
|
||||
)
|
||||
environment.update(env)
|
||||
return subprocess.run(
|
||||
[BASH, str(SCRIPT), command], cwd=repo, env=environment, text=True, capture_output=True
|
||||
)
|
||||
|
||||
|
||||
def branch_files(remote: Path, branch: str = "badges") -> list[str]:
|
||||
out = subprocess.run(
|
||||
[GIT, "--git-dir", str(remote), "ls-tree", "-r", "--name-only", branch],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return out.stdout.split()
|
||||
|
||||
|
||||
def commit_count(remote: Path, branch: str = "badges") -> int:
|
||||
out = subprocess.run(
|
||||
[GIT, "--git-dir", str(remote), "rev-list", "--count", branch], text=True, capture_output=True
|
||||
)
|
||||
return int(out.stdout.strip() or 0)
|
||||
|
||||
|
||||
def measure_and_publish(repo, tmp_path, python3_bin, **env):
|
||||
measured = run("measure", repo, tmp_path, python3_bin, **env)
|
||||
assert measured.returncode == 0, measured.stdout + measured.stderr
|
||||
return run("publish", repo, tmp_path, python3_bin, **env)
|
||||
|
||||
|
||||
class TestValidate:
|
||||
def test_accepts_the_defaults(self, repo, tmp_path, python3_bin):
|
||||
result = run("validate", repo, tmp_path, python3_bin)
|
||||
assert result.returncode == 0, result.stdout
|
||||
assert "badges go to the branch badges" in result.stdout
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "value", "message"),
|
||||
[
|
||||
("WORKING_DIRECTORY", "/etc", "working-directory must be a relative path"),
|
||||
("WORKING_DIRECTORY", "../x", "working-directory must be a relative path"),
|
||||
("WORKING_DIRECTORY", "nope", "working-directory 'nope' does not exist"),
|
||||
("COVERAGE_REPORT", "/tmp/c.xml", "coverage-report must be a relative path"),
|
||||
("BADGES_BRANCH", "main", "must not be a default branch"),
|
||||
("BADGES_BRANCH", "master", "must not be a default branch"),
|
||||
("BADGES_BRANCH", "", "must not be a default branch"),
|
||||
("BADGES_BRANCH", "a b", "is not a valid branch name"),
|
||||
("BADGES_BRANCH", "x..y", "is not a valid branch name"),
|
||||
],
|
||||
)
|
||||
def test_refuses_an_invalid_input_and_names_it(
|
||||
self, repo, tmp_path, python3_bin, name, value, message
|
||||
):
|
||||
result = run("validate", repo, tmp_path, python3_bin, **{name: value})
|
||||
assert result.returncode == 1
|
||||
assert f"::error::[configuration] " in result.stdout
|
||||
assert message in result.stdout
|
||||
|
||||
|
||||
class TestPublish:
|
||||
def test_creates_the_branch_with_the_badges_and_the_numbers(self, repo, remote, tmp_path, python3_bin):
|
||||
result = measure_and_publish(repo, tmp_path, python3_bin)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert sorted(branch_files(remote)) == ["code-files.svg", "code-lines.svg", "code-metrics.json"]
|
||||
shown = subprocess.run(
|
||||
[GIT, "--git-dir", str(remote), "show", "badges:code-lines.svg"], text=True, capture_output=True
|
||||
).stdout
|
||||
assert 'aria-label="lines of code: 2"' in shown # a.py has one code line, run.sh one
|
||||
message = subprocess.run(
|
||||
[GIT, "--git-dir", str(remote), "log", "-1", "--format=%s", "badges"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
).stdout.strip()
|
||||
assert message == "Update code metrics badges [skip ci]"
|
||||
|
||||
def test_the_branch_of_the_caller_is_not_changed(self, repo, remote, tmp_path, python3_bin):
|
||||
before = git("status", "--porcelain", cwd=repo)
|
||||
measure_and_publish(repo, tmp_path, python3_bin)
|
||||
assert git("status", "--porcelain", cwd=repo) == before
|
||||
assert git("branch", "--show-current", cwd=repo) == "main"
|
||||
|
||||
def test_an_unchanged_run_commits_nothing(self, repo, remote, tmp_path, python3_bin):
|
||||
measure_and_publish(repo, tmp_path, python3_bin)
|
||||
second = measure_and_publish(repo, tmp_path, python3_bin)
|
||||
assert second.returncode == 0
|
||||
assert "Badges are current; nothing to commit." in second.stdout
|
||||
assert commit_count(remote) == 1
|
||||
|
||||
def test_a_changed_number_is_one_commit_that_changes_only_badge_files(
|
||||
self, repo, remote, tmp_path, python3_bin
|
||||
):
|
||||
measure_and_publish(repo, tmp_path, python3_bin)
|
||||
(repo / "b.py").write_text("y = 2\nz = 3\n", newline="\n")
|
||||
git("add", "-A", cwd=repo)
|
||||
git("commit", "-q", "-m", "more", cwd=repo)
|
||||
result = measure_and_publish(repo, tmp_path, python3_bin)
|
||||
assert result.returncode == 0, result.stdout
|
||||
assert commit_count(remote) == 2
|
||||
changed = subprocess.run(
|
||||
[GIT, "--git-dir", str(remote), "show", "--name-only", "--format=", "badges"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
).stdout.split()
|
||||
assert sorted(changed) == ["code-files.svg", "code-lines.svg", "code-metrics.json"]
|
||||
|
||||
def test_a_coverage_report_adds_the_coverage_badge(self, repo, remote, tmp_path, python3_bin):
|
||||
(repo / "cov.xml").write_text(COVERAGE, newline="\n")
|
||||
result = measure_and_publish(repo, tmp_path, python3_bin, COVERAGE_REPORT="cov.xml")
|
||||
assert result.returncode == 0, result.stdout
|
||||
assert "coverage.svg" in branch_files(remote)
|
||||
|
||||
def test_a_missing_report_fails_names_the_file_and_changes_nothing(
|
||||
self, repo, remote, tmp_path, python3_bin
|
||||
):
|
||||
result = run("measure", repo, tmp_path, python3_bin, COVERAGE_REPORT="nope.xml")
|
||||
assert result.returncode == 1
|
||||
assert "::error::[configuration] coverage-report 'nope.xml' does not exist." in result.stdout
|
||||
assert branch_files(remote) == []
|
||||
|
||||
def test_the_shared_scripts_directory_is_not_counted(self, repo, remote, tmp_path, python3_bin):
|
||||
shared = repo / "shared-scripts" / "tools"
|
||||
shared.mkdir(parents=True)
|
||||
(shared / "big.py").write_text("a = 1\n" * 50, newline="\n")
|
||||
measure_and_publish(repo, tmp_path, python3_bin)
|
||||
shown = subprocess.run(
|
||||
[GIT, "--git-dir", str(remote), "show", "badges:code-files.svg"], text=True, capture_output=True
|
||||
).stdout
|
||||
assert 'aria-label="code files: 2"' in shown
|
||||
|
||||
def test_a_branch_that_holds_other_files_is_refused(self, repo, remote, tmp_path, python3_bin):
|
||||
git("push", "-q", "origin", "main:docs", cwd=repo)
|
||||
result = measure_and_publish(repo, tmp_path, python3_bin, BADGES_BRANCH="docs")
|
||||
assert result.returncode == 1
|
||||
assert "it is not a badges branch" in result.stdout
|
||||
|
||||
def test_publish_without_measure_is_refused(self, repo, tmp_path, python3_bin):
|
||||
result = run("publish", repo, tmp_path, python3_bin)
|
||||
assert result.returncode == 1
|
||||
assert "run measure first" in result.stdout
|
||||
|
||||
def test_a_rejected_push_is_retried_once_after_a_fetch(self, repo, remote, tmp_path, python3_bin):
|
||||
marker = tmp_path / "rejected-once"
|
||||
hook = remote / "hooks" / "pre-receive"
|
||||
hook.write_text(
|
||||
f'#!/bin/sh\nif [ ! -e "{marker.as_posix()}" ]; then touch "{marker.as_posix()}"; '
|
||||
"echo rejected once >&2; exit 1; fi\nexit 0\n",
|
||||
newline="\n",
|
||||
)
|
||||
hook.chmod(0o755)
|
||||
result = measure_and_publish(repo, tmp_path, python3_bin)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert marker.exists()
|
||||
|
||||
def test_two_rejected_pushes_fail(self, repo, remote, tmp_path, python3_bin):
|
||||
hook = remote / "hooks" / "pre-receive"
|
||||
hook.write_text("#!/bin/sh\necho rejected >&2\nexit 1\n", newline="\n")
|
||||
hook.chmod(0o755)
|
||||
result = measure_and_publish(repo, tmp_path, python3_bin)
|
||||
assert result.returncode == 1
|
||||
assert re.search(r"push to 'badges' failed twice", result.stdout)
|
||||
Reference in New Issue
Block a user