build: make toolchain inputs reproducible
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
GENERATED_PATHS = (
|
||||
"lib/i18n/strings.g.dart",
|
||||
"lib/models/model.freezed.dart",
|
||||
"lib/models/model.g.dart",
|
||||
"lib/watch_together/services/relay_protocol.g.dart",
|
||||
"server/relay_protocol_gen.go",
|
||||
)
|
||||
|
||||
|
||||
def executable(path: Path, contents: str) -> None:
|
||||
path.write_text(contents, encoding="utf-8")
|
||||
path.chmod(path.stat().st_mode | stat.S_IXUSR)
|
||||
|
||||
|
||||
class CodegenCheckTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
self.codegen_temp = Path(tempfile.mkdtemp(prefix="plezy-codegen-test-temp-"))
|
||||
subprocess.run(["git", "init", "-q", str(self.root)], check=True)
|
||||
subprocess.run(["git", "config", "user.email", "fixture@example.invalid"], cwd=self.root, check=True)
|
||||
subprocess.run(["git", "config", "user.name", "Fixture"], cwd=self.root, check=True)
|
||||
|
||||
(self.root / "scripts").mkdir()
|
||||
shutil.copy2(SCRIPT_DIR / "codegen.sh", self.root / "scripts" / "codegen.sh")
|
||||
shutil.copy2(SCRIPT_DIR / "check_codegen.py", self.root / "scripts" / "check_codegen.py")
|
||||
(self.root / "scripts" / "generate_relay_protocol.py").write_text("fixture\n", encoding="utf-8")
|
||||
(self.root / "source.txt").write_text("version one\n", encoding="utf-8")
|
||||
for relative in GENERATED_PATHS:
|
||||
path = self.root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("version one\n", encoding="utf-8")
|
||||
|
||||
self.bin = self.root / "fake-bin"
|
||||
self.bin.mkdir()
|
||||
executable(
|
||||
self.bin / "python3",
|
||||
"""#!/usr/bin/env bash
|
||||
if [[ "$1" == *check_codegen.py ]]; then exec "$REAL_PYTHON" "$@"; fi
|
||||
mkdir -p lib/watch_together/services server
|
||||
cp source.txt lib/watch_together/services/relay_protocol.g.dart
|
||||
cp source.txt server/relay_protocol_gen.go
|
||||
""",
|
||||
)
|
||||
executable(
|
||||
self.bin / "dart",
|
||||
"""#!/usr/bin/env bash
|
||||
if [ "${FAIL_DART:-0}" -ne 0 ]; then exit "$FAIL_DART"; fi
|
||||
case "$*" in
|
||||
"run slang")
|
||||
mkdir -p lib/i18n
|
||||
cp source.txt lib/i18n/strings.g.dart
|
||||
;;
|
||||
*"build_runner"*)
|
||||
mkdir -p lib/models
|
||||
cp source.txt lib/models/model.g.dart
|
||||
cp source.txt lib/models/model.freezed.dart
|
||||
printf '%s\n' "$*" > build-runner-args.txt
|
||||
;;
|
||||
esac
|
||||
""",
|
||||
)
|
||||
subprocess.run(["git", "add", "."], cwd=self.root, check=True)
|
||||
subprocess.run(["git", "commit", "-qm", "fixture"], cwd=self.root, check=True)
|
||||
self.env = os.environ | {
|
||||
"PATH": f"{self.bin}:{os.environ['PATH']}",
|
||||
"REAL_PYTHON": sys.executable,
|
||||
"TMPDIR": str(self.codegen_temp),
|
||||
}
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temp.cleanup()
|
||||
shutil.rmtree(self.codegen_temp, ignore_errors=True)
|
||||
|
||||
def generated_state(self) -> dict[str, str]:
|
||||
state = {}
|
||||
for relative in GENERATED_PATHS:
|
||||
path = self.root / relative
|
||||
if path.is_file():
|
||||
state[relative] = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
for path in (self.root / "lib").rglob("*.dart"):
|
||||
relative = path.relative_to(self.root).as_posix()
|
||||
if relative.endswith(".g.dart") or relative.endswith(".freezed.dart"):
|
||||
state.setdefault(relative, hashlib.sha256(path.read_bytes()).hexdigest())
|
||||
return state
|
||||
|
||||
def git_status(self) -> bytes:
|
||||
return subprocess.run(
|
||||
["git", "status", "--porcelain=v1", "-z"],
|
||||
cwd=self.root,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
).stdout
|
||||
|
||||
def run_codegen(self, *arguments: str, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["bash", "scripts/codegen.sh", *arguments],
|
||||
cwd=self.root,
|
||||
env=self.env if env is None else env,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
def assert_isolation_cleaned_up(self) -> None:
|
||||
worktrees = subprocess.run(
|
||||
["git", "worktree", "list", "--porcelain"],
|
||||
cwd=self.root,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout
|
||||
self.assertEqual(worktrees.count("worktree "), 1)
|
||||
self.assertEqual(list(self.codegen_temp.glob("plezy-codegen-check-*")), [])
|
||||
|
||||
def test_stale_check_reports_sorted_paths_without_changing_caller(self) -> None:
|
||||
(self.root / "source.txt").write_text("version two\n", encoding="utf-8")
|
||||
before = self.generated_state()
|
||||
status_before = self.git_status()
|
||||
|
||||
result = self.run_codegen("--check")
|
||||
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertEqual(
|
||||
result.stderr.splitlines(),
|
||||
[
|
||||
"Generated files are out of date:",
|
||||
*(f" {relative}" for relative in GENERATED_PATHS),
|
||||
"Run 'scripts/codegen.sh' and commit the result.",
|
||||
],
|
||||
)
|
||||
self.assertEqual(self.generated_state(), before)
|
||||
self.assertEqual(self.git_status(), status_before)
|
||||
self.assert_isolation_cleaned_up()
|
||||
|
||||
def test_generator_failure_propagates_without_partial_writes(self) -> None:
|
||||
(self.root / "source.txt").write_text("version two\n", encoding="utf-8")
|
||||
before = self.generated_state()
|
||||
status_before = self.git_status()
|
||||
|
||||
result = self.run_codegen("--check", env=self.env | {"FAIL_DART": "7"})
|
||||
|
||||
self.assertEqual(result.returncode, 7)
|
||||
self.assertEqual(self.generated_state(), before)
|
||||
self.assertEqual(self.git_status(), status_before)
|
||||
self.assert_isolation_cleaned_up()
|
||||
|
||||
def test_index_boundary_requires_matching_staged_outputs(self) -> None:
|
||||
(self.root / "source.txt").write_text("version two\n", encoding="utf-8")
|
||||
self.assertEqual(self.run_codegen().returncode, 0)
|
||||
unstaged_state = self.generated_state()
|
||||
self.assertEqual(self.run_codegen("--check").returncode, 1)
|
||||
self.assertEqual(self.generated_state(), unstaged_state)
|
||||
|
||||
subprocess.run(["git", "add", "source.txt", "lib", "server/relay_protocol_gen.go"], cwd=self.root, check=True)
|
||||
self.assertEqual(self.run_codegen("--check").returncode, 0)
|
||||
|
||||
incorrect = self.root / "lib" / "models" / "model.g.dart"
|
||||
incorrect.write_text("incorrect staged output\n", encoding="utf-8")
|
||||
subprocess.run(["git", "add", str(incorrect.relative_to(self.root))], cwd=self.root, check=True)
|
||||
status_before = self.git_status()
|
||||
|
||||
result = self.run_codegen("--check")
|
||||
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("lib/models/model.g.dart", result.stderr)
|
||||
self.assertEqual(incorrect.read_text(encoding="utf-8"), "incorrect staged output\n")
|
||||
self.assertEqual(self.git_status(), status_before)
|
||||
|
||||
def test_deleted_and_untracked_outputs_are_reported_without_repair(self) -> None:
|
||||
deleted = self.root / "lib" / "models" / "model.g.dart"
|
||||
deleted.unlink()
|
||||
untracked = self.root / "lib" / "models" / "extra.g.dart"
|
||||
untracked.write_text("caller sentinel\n", encoding="utf-8")
|
||||
status_before = self.git_status()
|
||||
|
||||
result = self.run_codegen("--check")
|
||||
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("lib/models/extra.g.dart", result.stderr)
|
||||
self.assertIn("lib/models/model.g.dart", result.stderr)
|
||||
self.assertFalse(deleted.exists())
|
||||
self.assertEqual(untracked.read_text(encoding="utf-8"), "caller sentinel\n")
|
||||
self.assertEqual(self.git_status(), status_before)
|
||||
|
||||
def test_write_mode_updates_outputs_and_forwards_build_runner_arguments(self) -> None:
|
||||
(self.root / "source.txt").write_text("version two\n", encoding="utf-8")
|
||||
|
||||
result = self.run_codegen("--build-filter=lib/models/**")
|
||||
|
||||
self.assertEqual(result.returncode, 0)
|
||||
for relative in GENERATED_PATHS:
|
||||
self.assertEqual((self.root / relative).read_text(encoding="utf-8"), "version two\n")
|
||||
self.assertEqual(
|
||||
(self.root / "build-runner-args.txt").read_text(encoding="utf-8"),
|
||||
"run build_runner build --delete-conflicting-outputs --build-filter=lib/models/**\n",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user