1
0
Fork 0
mirror of https://github.com/nix-community/home-manager.git synced 2025-11-08 11:36:05 +01:00
home-manager/tests/tests.py
Austin Horstman 6ee8473173 tests: improve debugging for failed test runs
Signed-off-by: Austin Horstman <khaneliman12@gmail.com>
2025-09-28 16:28:05 -05:00

243 lines
9.4 KiB
Python
Executable file
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
import argparse
import subprocess
import sys
from collections.abc import Sequence
from pathlib import Path
from textwrap import dedent
SUCCESS_EMOJI = ""
FAILURE_EMOJI = ""
INFO_EMOJI = ""
class TestRunnerError(Exception):
"""Custom exception for TestRunner errors."""
pass
def _run_command(
cmd: Sequence[str],
*,
cwd: Path | None = None,
text_input: str | None = None,
check: bool = True,
) -> subprocess.CompletedProcess:
"""A wrapper for subprocess.run with consistent error handling."""
try:
return subprocess.run(
cmd,
capture_output=True,
text=True,
input=text_input,
check=check,
cwd=cwd,
)
except FileNotFoundError as e:
print(f"{FAILURE_EMOJI} Error: Command '{e.filename}' not found. Is it in your PATH?", file=sys.stderr)
raise TestRunnerError(f"Command not found: {e.filename}") from e
except subprocess.CalledProcessError as e:
print(f"{FAILURE_EMOJI} Error executing command: {' '.join(cmd)}", file=sys.stderr)
if e.stderr:
print(f"Nix Error Output:\n{e.stderr.strip()}", file=sys.stderr)
raise TestRunnerError("Subprocess command failed.") from e
class TestRunner:
"""Manages the discovery and execution of Nix-based tests."""
def __init__(self, repo_root: Path | None = None):
self.repo_root = repo_root or Path.cwd()
def get_current_system(self) -> str:
"""Get the current system architecture using Nix."""
cmd = ["nix", "eval", "--raw", "--impure", "--expr", "builtins.currentSystem"]
result = _run_command(cmd)
return result.stdout.strip()
def discover_tests(self, integration: bool = False) -> list[str]:
"""Discover available tests using 'nix eval'."""
system = self.get_current_system()
test_prefix = "integration-test-" if integration else "test-"
nix_apply_expr = (
'pkgs: builtins.concatStringsSep "\\n" '
f'(builtins.filter (name: builtins.match "{test_prefix}.*" name != null) '
'(builtins.attrNames pkgs))'
)
cmd = [
"nix", "eval", "--raw", "--reference-lock-file", "flake.lock",
f"./tests#packages.{system}", "--apply", nix_apply_expr
]
result = _run_command(cmd, cwd=self.repo_root)
return result.stdout.splitlines()
def filter_tests(self, tests: list[str], filters: list[str]) -> list[str]:
"""Filter tests based on a list of substrings."""
if not filters:
return tests
return [test for test in tests if any(f in test for f in filters)]
def interactive_select(self, tests: list[str]) -> list[str]:
"""Allow interactive test selection using fzf."""
if not tests:
return []
fzf_input = "\n".join(tests)
cmd = ["fzf", "--multi", "--header=Select tests (TAB to select, ENTER to confirm)"]
try:
result = _run_command(cmd, text_input=fzf_input)
return result.stdout.splitlines()
except TestRunnerError:
# Can happen if fzf is not found or the user cancels (non-zero exit)
return []
def run_tests(self, tests_to_run: list[str], nix_args: list[str]) -> bool:
"""Run the selected tests and report the outcome."""
if not tests_to_run:
print(f"{INFO_EMOJI} No tests selected to run.", file=sys.stderr)
return True
count = len(tests_to_run)
print(f"{INFO_EMOJI} Running {count} test(s)...")
failed_tests = []
for i, test in enumerate(tests_to_run, 1):
print(f"\n--- Running test {i}/{count}: {test} ---")
cmd = [
"nix", "build", "-L", "--keep-failed", "--reference-lock-file", "flake.lock",
f"./tests#{test}", *nix_args
]
try:
# For this command, we want output to go directly to the terminal
result = subprocess.run(cmd, check=True, cwd=self.repo_root, capture_output=True, text=True)
print(f"{SUCCESS_EMOJI} Test passed: {test}")
except subprocess.CalledProcessError as e:
failed_tests.append(test)
print(f"{FAILURE_EMOJI} Test failed: {test}", file=sys.stderr)
if e.stderr:
print(e.stderr, file=sys.stderr)
import re
if e.stderr:
build_dir_match = re.search(r"keeping build directory '([^']+)'", e.stderr)
if build_dir_match:
build_dir = build_dir_match.group(1)
try:
import glob
attr_files = glob.glob(f"{build_dir}/.attr-*")
for attr_file in attr_files:
with open(attr_file, 'r') as f:
content = f.read()
tested_match = re.search(r'TESTED="([^"]+)"', content)
if tested_match:
tested_path = tested_match.group(1)
print(f"{INFO_EMOJI} Generated test directory at: {tested_path}/", file=sys.stderr)
break
except Exception:
print(f"{INFO_EMOJI} Build directory available at: {build_dir}", file=sys.stderr)
try:
store_cmd = [
"nix", "build", "--no-link", "--json", "--reference-lock-file", "flake.lock",
f"./tests#{test}", *nix_args
]
result = _run_command(store_cmd, cwd=self.repo_root, check=False)
if result.returncode == 0:
import json
build_info = json.loads(result.stdout)
if build_info:
store_path = build_info[0]["outputs"]["out"]
print(f"{INFO_EMOJI} Test directory available at: {store_path}/tested/", file=sys.stderr)
except Exception:
pass
print("\n--- Summary ---")
if not failed_tests:
print(f"{SUCCESS_EMOJI} All {count} tests passed!")
return True
else:
print(f"{FAILURE_EMOJI} {len(failed_tests)} of {count} test(s) failed:")
for test in failed_tests:
print(f" - {test}")
return False
def main() -> None:
"""Main entry point for the test runner script."""
parser = argparse.ArgumentParser(
description="A modern test runner for Home Manager.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=dedent("""\
Examples:
%(prog)s
Run tests interactively.
%(prog)s -l
List all available tests.
%(prog)s -l alacritty
List tests matching 'alacritty'.
%(prog)s alacritty
Run all tests matching 'alacritty'.
%(prog)s -i firefox git
Interactively select from tests matching 'firefox' or 'git'.
%(prog)s -t
Run integration tests interactively.
%(prog)s -- --show-trace
Pass '--show-trace' to all 'nix build' commands.
""")
)
parser.add_argument(
'-l', '--list', action='store_true', help='List available tests instead of running them.'
)
parser.add_argument(
'-i', '--interactive', action='store_true', help='Force interactive test selection using fzf.'
)
parser.add_argument(
'-t', '--integration', action='store_true', help='Discover and run integration tests.'
)
parser.add_argument(
'filters', nargs='*', help='Filter tests by name (partial matches work).'
)
parser.add_argument(
'nix_args', nargs=argparse.REMAINDER,
help="Arguments to pass to 'nix build', must be after '--'."
)
args = parser.parse_args()
# Strip the '--' if it exists
nix_args = [arg for arg in args.nix_args if arg != '--']
runner = TestRunner()
try:
print(f"{INFO_EMOJI} Discovering tests...", file=sys.stderr)
all_tests = runner.discover_tests(integration=args.integration)
if not all_tests:
print("No tests found for the current configuration.", file=sys.stderr)
sys.exit(1)
tests_to_consider = runner.filter_tests(all_tests, args.filters)
if not tests_to_consider:
print("No tests match the provided filters.", file=sys.stderr)
sys.exit(1)
if args.list:
print("\n".join(tests_to_consider))
print(f"\n{INFO_EMOJI} Found {len(tests_to_consider)} matching tests.", file=sys.stderr)
return
# Determine which tests to run
should_be_interactive = args.interactive or not args.filters
if should_be_interactive:
tests_to_run = runner.interactive_select(tests_to_consider)
else:
tests_to_run = tests_to_consider
if not runner.run_tests(tests_to_run, nix_args):
sys.exit(1)
except TestRunnerError:
# Error messages are printed by the functions that raise the exception
sys.exit(1)
if __name__ == "__main__":
main()