Files
nyaterm/scripts/tests/test_bump_version.py
Kang 44658ad751 fix(release): enhance whitespace handling in version bump script
Updated the version bump script to preserve CRLF line endings and respect configured whitespace checks during commits. Added tests to ensure proper handling of whitespace configurations and CRLF preservation, improving the robustness of the version bumping process.
2026-10-05 23:53:55 +08:00

245 lines
11 KiB
Python

from __future__ import annotations
import contextlib
import io
import sys
import tempfile
import tomllib
import unittest
from pathlib import Path
from unittest import mock
RELEASE_SCRIPTS = Path(__file__).resolve().parents[1] / "release"
sys.path.insert(0, str(RELEASE_SCRIPTS))
import bump_version # noqa: E402
OLD = "2.0.0-preview.4"
NEW = "2.0.0-preview.5"
class BumpVersionTests(unittest.TestCase):
def setUp(self) -> None:
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name).resolve()
manifest = (
'[workspace]\nmembers = ["crates/*"]\nexclude = ["crates/excluded"]\n\n'
f"[workspace.package] # release identity\nversion = '{OLD}' # keep comment\n"
'edition = "2024"\n\n[workspace.dependencies]\nexternal = "1.0.0"\n'
)
(self.root / "Cargo.toml").write_bytes(manifest.replace("\n", "\r\n").encode())
for name, version in (
("app", "version.workspace = true"),
("core", "version = { workspace = true }"),
("sdk", 'version = "1.0.0"'),
("excluded", "version.workspace = true"),
):
directory = self.root / "crates" / name
directory.mkdir(parents=True)
(directory / "Cargo.toml").write_text(
f'[package]\nname = "{name}"\n{version}\n', encoding="utf-8"
)
lock = (
'# generated lockfile\nversion = 4\n\n'
f'[[package]]\nname = "app"\nversion = "{OLD}"\n'
f'dependencies = ["core {OLD}", "core {OLD} (registry+https://example.org)", "sdk"]\n\n'
f'[[package]]\nname = "core"\nversion = "{OLD}"\n\n'
f'[[package]]\nname = "core"\nversion = "{OLD}"\n'
'source = "registry+https://example.org"\nchecksum = "keep"\n\n'
'[[package]]\nname = "sdk"\nversion = "1.0.0"\n'
)
(self.root / "Cargo.lock").write_bytes(lock.replace("\n", "\r\n").encode())
(self.root / "plugin.toml").write_text(f'host_version = ">={OLD}"\n', encoding="utf-8")
def run_cli(self, *args: str) -> int:
output = io.StringIO()
error = io.StringIO()
with (
mock.patch.object(bump_version, "ROOT_DIR", self.root),
contextlib.redirect_stdout(output),
contextlib.redirect_stderr(error),
):
result = bump_version.main(list(args))
self.cli_error = error.getvalue()
return result
def snapshot(self) -> dict[Path, bytes]:
return {path: path.read_bytes() for path in self.root.rglob("*") if path.is_file()}
def init_git(self) -> None:
bump_version.git(self.root, "init", "--initial-branch=main")
bump_version.git(self.root, "config", "user.name", "Version Script Test")
bump_version.git(self.root, "config", "user.email", "version-test@example.invalid")
bump_version.git(self.root, "config", "commit.gpgsign", "false")
# Exercise CRLF preservation without inheriting Windows autocrlf settings.
bump_version.git(self.root, "config", "core.autocrlf", "false")
hooks = self.root / ".git" / "empty-hooks"
hooks.mkdir()
bump_version.git(self.root, "config", "core.hooksPath", str(hooks))
bump_version.git(self.root, "add", "--", "Cargo.toml", "Cargo.lock", "crates", "plugin.toml")
bump_version.git(self.root, "commit", "-m", "test: create workspace fixture")
def test_updates_only_inherited_local_packages_and_dependency_references(self) -> None:
before = self.snapshot()
self.assertEqual(self.run_cli(f"v{NEW}"), 0)
manifest = (self.root / "Cargo.toml").read_bytes()
self.assertEqual(manifest, before[self.root / "Cargo.toml"].replace(OLD.encode(), NEW.encode()))
lock = tomllib.loads((self.root / "Cargo.lock").read_text(encoding="utf-8"))
app, core, external, sdk = lock["package"]
self.assertEqual((app["version"], core["version"]), (NEW, NEW))
self.assertEqual(app["dependencies"][0], f"core {NEW}")
self.assertEqual(app["dependencies"][1], f"core {OLD} (registry+https://example.org)")
self.assertEqual(external["version"], OLD)
self.assertEqual(external["checksum"], "keep")
self.assertEqual(sdk["version"], "1.0.0")
after = self.snapshot()
for path in before.keys() - {self.root / "Cargo.toml", self.root / "Cargo.lock"}:
self.assertEqual(before[path], after[path])
self.assertNotIn(b"\n", after[self.root / "Cargo.lock"].replace(b"\r\n", b""))
def test_dry_run_leaves_every_file_unchanged(self) -> None:
before = self.snapshot()
self.assertEqual(self.run_cli(NEW, "--dry-run"), 0)
self.assertEqual(self.snapshot(), before)
def test_repeated_bump_is_a_noop(self) -> None:
self.assertEqual(self.run_cli(NEW), 0)
before = self.snapshot()
self.assertEqual(self.run_cli(NEW), 0)
self.assertEqual(self.snapshot(), before)
def test_accepts_stable_prerelease_and_build_versions(self) -> None:
for version in ("2.0.0", "3.1.0-rc.1", "3.1.0+build.01"):
with self.subTest(version=version):
self.assertEqual(self.run_cli(version), 0)
manifest = tomllib.loads((self.root / "Cargo.toml").read_text(encoding="utf-8"))
self.assertEqual(manifest["workspace"]["package"]["version"], version)
def test_invalid_versions_do_not_write_files(self) -> None:
before = self.snapshot()
for version in ("2.0", "02.0.0", "2.0.0-preview.05", "2.0.0-", "2.0.0\nmalicious"):
with self.subTest(version=version):
self.assertEqual(self.run_cli(version), 1)
self.assertEqual(self.snapshot(), before)
def test_invalid_or_incomplete_lock_does_not_write_manifest(self) -> None:
lock_path = self.root / "Cargo.lock"
original = lock_path.read_bytes()
for broken in (
original.replace(b'name = "app"', b'name = "missing"'),
original.replace(OLD.encode(), b"0.0.1"),
b"invalid TOML!",
):
with self.subTest(lock=broken):
lock_path.write_bytes(broken)
before = self.snapshot()
self.assertEqual(self.run_cli(NEW), 1)
self.assertEqual(self.snapshot(), before)
def test_commit_contains_only_version_files_and_preserves_unrelated_staging(self) -> None:
self.init_git()
plugin = self.root / "plugin.toml"
plugin.write_text('host_version = ">=2.0.0"\n', encoding="utf-8")
bump_version.git(self.root, "add", "--", "plugin.toml")
staged = bump_version.git(self.root, "diff", "--cached")
(self.root / "notes.txt").write_text("unfinished work\n", encoding="utf-8")
self.assertEqual(self.run_cli(NEW, "--commit"), 0, self.cli_error)
self.assertEqual(
bump_version.git(self.root, "log", "-1", "--format=%s"),
f"chore(release): bump version to {NEW}",
)
self.assertEqual(
set(bump_version.git(self.root, "diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD").splitlines()),
{"Cargo.toml", "Cargo.lock"},
)
self.assertEqual(bump_version.git(self.root, "diff", "--cached"), staged)
self.assertEqual((self.root / "notes.txt").read_text(), "unfinished work\n")
head = bump_version.git(self.root, "rev-parse", "HEAD")
self.assertEqual(self.run_cli(NEW, "--commit"), 0)
self.assertEqual(bump_version.git(self.root, "rev-parse", "HEAD"), head)
def test_commit_accepts_crlf_and_preserves_configured_whitespace_checks(self) -> None:
self.init_git()
bump_version.git(self.root, "config", "core.whitespace", "trailing-space,tab-in-indent")
self.assertEqual(self.run_cli(NEW, "--commit"), 0, self.cli_error)
self.assertEqual(
bump_version.git(self.root, "config", "--get", "core.whitespace"),
"trailing-space,tab-in-indent",
)
for filename in bump_version.VERSION_FILES:
data = (self.root / filename).read_bytes()
self.assertIn(b"\r\n", data)
self.assertNotIn(b"\n", data.replace(b"\r\n", b""))
# A changed tab-indented version must still fail the configured check.
manifest = self.root / "Cargo.toml"
manifest.write_bytes(manifest.read_bytes().replace(b"version = '", b"\tversion = '"))
bump_version.git(self.root, "add", "--", "Cargo.toml")
bump_version.git(self.root, "commit", "-m", "test: add configured whitespace fixture")
head = bump_version.git(self.root, "rev-parse", "HEAD")
self.assertEqual(self.run_cli("2.0.0-preview.6", "--commit"), 1)
self.assertIn("tab in indent", self.cli_error)
self.assertEqual(bump_version.git(self.root, "rev-parse", "HEAD"), head)
def test_commit_dry_run_preserves_files_head_and_index(self) -> None:
self.init_git()
before = self.snapshot()
self.assertEqual(self.run_cli(NEW, "--commit", "--dry-run"), 0)
self.assertEqual(self.snapshot(), before)
def test_commit_rejects_existing_staged_and_unstaged_version_edits(self) -> None:
self.init_git()
manifest = self.root / "Cargo.toml"
manifest.write_bytes(manifest.read_bytes() + b"\r\n# unrelated edit\r\n")
for staged in (False, True):
with self.subTest(staged=staged):
if staged:
bump_version.git(self.root, "add", "--", "Cargo.toml")
before = self.snapshot()
self.assertEqual(self.run_cli(NEW, "--commit"), 1)
self.assertEqual(self.snapshot(), before)
def test_commit_rejects_detached_head_before_writing(self) -> None:
self.init_git()
bump_version.git(self.root, "checkout", "--detach")
before = self.snapshot()
self.assertEqual(self.run_cli(NEW, "--commit"), 1)
self.assertEqual(self.snapshot(), before)
def test_commit_rejects_active_merge_before_writing(self) -> None:
self.init_git()
(self.root / ".git" / "MERGE_HEAD").write_text(
bump_version.git(self.root, "rev-parse", "HEAD") + "\n", encoding="utf-8"
)
before = self.snapshot()
self.assertEqual(self.run_cli(NEW, "--commit"), 1)
self.assertEqual(self.snapshot(), before)
def test_failed_commit_leaves_version_edits_and_staging_for_review(self) -> None:
self.init_git()
head = bump_version.git(self.root, "rev-parse", "HEAD")
real_git = bump_version.git
def fail_commit(root: Path, *args: str) -> str:
if args[0] == "commit":
raise ValueError("commit hook rejected the change")
return real_git(root, *args)
with mock.patch.object(bump_version, "git", side_effect=fail_commit):
self.assertEqual(self.run_cli(NEW, "--commit"), 1)
self.assertEqual(bump_version.git(self.root, "rev-parse", "HEAD"), head)
self.assertEqual(bump_version.git(self.root, "diff", "--cached"), "")
manifest = tomllib.loads((self.root / "Cargo.toml").read_text(encoding="utf-8"))
self.assertEqual(manifest["workspace"]["package"]["version"], NEW)
def test_commit_requires_a_git_repository_without_writing(self) -> None:
before = self.snapshot()
self.assertEqual(self.run_cli(NEW, "--commit"), 1)
self.assertEqual(self.snapshot(), before)
if __name__ == "__main__":
unittest.main()