Compare commits

...

3 Commits

Author SHA1 Message Date
Kevin Veen-Birkenbach
7f06447bbd feat(cli): add --system-update flag to update command
Some checks failed
CI / test-unit (push) Has been cancelled
CI / test-integration (push) Has been cancelled
CI / test-env-virtual (push) Has been cancelled
CI / test-env-nix (push) Has been cancelled
CI / test-e2e (push) Has been cancelled
CI / test-virgin-user (push) Has been cancelled
CI / test-virgin-root (push) Has been cancelled
CI / codesniffer-shellcheck (push) Has been cancelled
CI / codesniffer-ruff (push) Has been cancelled
- Register --system-update for `pkgmgr update`
- Expose args.system_update for update workflow
- Align CLI with update_repos and E2E tests

https://chatgpt.com/share/693db645-c420-800f-b921-9d5c0356d0ac
2025-12-13 20:02:48 +01:00
Kevin Veen-Birkenbach
1e5d6d3eee test(unit): update NixFlakeInstaller tests for new run_command-based logic
- Adapt DummyCtx to include quiet and force_update flags
- Replace os.system mocking with run_command/subprocess mocks
- Align assertions with new Nix install/upgrade output
- Keep coverage for mandatory vs optional output handling

https://chatgpt.com/share/693db645-c420-800f-b921-9d5c0356d0ac
2025-12-13 19:53:34 +01:00
Kevin Veen-Birkenbach
f2970adbb2 test(e2e): enforce --system-update and isolate update-all integration tests
- Require --system-update for update-all integration tests
- Run tests with isolated HOME and temporary gitconfig
- Allow /src as git safe.directory for nix run
- Capture and print combined stdout/stderr on failure
- Ensure consistent environment for pkgmgr and nix-run executions
2025-12-13 19:49:40 +01:00
3 changed files with 187 additions and 141 deletions

View File

@@ -33,11 +33,11 @@ def add_install_update_subparsers(
) )
add_install_update_arguments(update_parser) add_install_update_arguments(update_parser)
update_parser.add_argument( update_parser.add_argument(
"--system", "--system-update",
dest="system_update",
action="store_true", action="store_true",
help="Include system update commands", help="Include system update commands",
) )
# KEIN --update hier nötig → update impliziert force_update=True
deinstall_parser = subparsers.add_parser( deinstall_parser = subparsers.add_parser(
"deinstall", "deinstall",

View File

@@ -8,13 +8,17 @@ This test is intended to be run inside the Docker container where:
- and it is safe to perform real git operations. - and it is safe to perform real git operations.
It passes if BOTH commands complete successfully (in separate tests): It passes if BOTH commands complete successfully (in separate tests):
1) pkgmgr update --all --clone-mode https --no-verification 1) pkgmgr update --all --clone-mode https --no-verification --system-update
2) nix run .#pkgmgr -- update --all --clone-mode https --no-verification 2) nix run .#pkgmgr -- update --all --clone-mode https --no-verification --system-update
""" """
from __future__ import annotations
import os import os
import subprocess import subprocess
import tempfile
import unittest import unittest
from pathlib import Path
from test_install_pkgmgr_shallow import ( from test_install_pkgmgr_shallow import (
nix_profile_list_debug, nix_profile_list_debug,
@@ -23,69 +27,98 @@ from test_install_pkgmgr_shallow import (
) )
class TestIntegrationUpdateAllHttps(unittest.TestCase): def _make_temp_gitconfig_with_safe_dirs(home: Path) -> Path:
def _run_cmd(self, cmd: list[str], label: str) -> None: gitconfig = home / ".gitconfig"
""" gitconfig.write_text(
Run a real CLI command and raise a helpful assertion on failure. "[safe]\n"
""" "\tdirectory = /src\n"
cmd_repr = " ".join(cmd) "\tdirectory = /src/.git\n"
env = os.environ.copy() "\tdirectory = *\n"
)
return gitconfig
try:
print(f"\n[TEST] Running ({label}): {cmd_repr}") class TestIntegrationUpdateAllHttps(unittest.TestCase):
subprocess.run( def _common_env(self, home_dir: str) -> dict[str, str]:
cmd, env = os.environ.copy()
check=True, env["HOME"] = home_dir
cwd=os.getcwd(),
env=env, home = Path(home_dir)
text=True, home.mkdir(parents=True, exist_ok=True)
)
except subprocess.CalledProcessError as exc: env["GIT_CONFIG_GLOBAL"] = str(_make_temp_gitconfig_with_safe_dirs(home))
# Ensure nix is discoverable if the container has it
env["PATH"] = "/nix/var/nix/profiles/default/bin:" + env.get("PATH", "")
return env
def _run_cmd(self, cmd: list[str], label: str, env: dict[str, str]) -> None:
cmd_repr = " ".join(cmd)
print(f"\n[TEST] Running ({label}): {cmd_repr}")
proc = subprocess.run(
cmd,
check=False,
cwd=os.getcwd(),
env=env,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
print(proc.stdout.rstrip())
if proc.returncode != 0:
print(f"\n[TEST] Command failed ({label})") print(f"\n[TEST] Command failed ({label})")
print(f"[TEST] Command : {cmd_repr}") print(f"[TEST] Command : {cmd_repr}")
print(f"[TEST] Exit code: {exc.returncode}") print(f"[TEST] Exit code: {proc.returncode}")
nix_profile_list_debug(f"ON FAILURE ({label})") nix_profile_list_debug(f"ON FAILURE ({label})")
raise AssertionError( raise AssertionError(
f"({label}) {cmd_repr!r} failed with exit code {exc.returncode}. " f"({label}) {cmd_repr!r} failed with exit code {proc.returncode}.\n\n"
"Scroll up to see the full pkgmgr/nix output inside the container." f"--- output ---\n{proc.stdout}\n"
) from exc )
def _common_setup(self) -> None: def _common_setup(self) -> None:
# Debug before cleanup
nix_profile_list_debug("BEFORE CLEANUP") nix_profile_list_debug("BEFORE CLEANUP")
# Cleanup: aggressively try to drop any pkgmgr/profile entries
# (keeps the environment comparable to other integration tests).
remove_pkgmgr_from_nix_profile() remove_pkgmgr_from_nix_profile()
# Debug after cleanup
nix_profile_list_debug("AFTER CLEANUP") nix_profile_list_debug("AFTER CLEANUP")
def test_update_all_repositories_https_pkgmgr(self) -> None: def test_update_all_repositories_https_pkgmgr(self) -> None:
"""
Run: pkgmgr update --all --clone-mode https --no-verification
"""
self._common_setup() self._common_setup()
with tempfile.TemporaryDirectory(prefix="pkgmgr-updateall-") as tmp:
args = ["update", "--all", "--clone-mode", "https", "--no-verification"] env = self._common_env(tmp)
self._run_cmd(["pkgmgr", *args], label="pkgmgr") args = [
"update",
# After successful update: show `pkgmgr --help` via interactive bash "--all",
pkgmgr_help_debug() "--clone-mode",
"https",
"--no-verification",
"--system-update",
]
self._run_cmd(["pkgmgr", *args], label="pkgmgr", env=env)
pkgmgr_help_debug()
def test_update_all_repositories_https_nix_pkgmgr(self) -> None: def test_update_all_repositories_https_nix_pkgmgr(self) -> None:
"""
Run: nix run .#pkgmgr -- update --all --clone-mode https --no-verification
"""
self._common_setup() self._common_setup()
with tempfile.TemporaryDirectory(prefix="pkgmgr-updateall-nix-") as tmp:
args = ["update", "--all", "--clone-mode", "https", "--no-verification"] env = self._common_env(tmp)
self._run_cmd(["nix", "run", ".#pkgmgr", "--", *args], label="nix run .#pkgmgr") args = [
"update",
# After successful update: show `pkgmgr --help` via interactive bash "--all",
pkgmgr_help_debug() "--clone-mode",
"https",
"--no-verification",
"--system-update",
]
self._run_cmd(
["nix", "run", ".#pkgmgr", "--", *args],
label="nix run .#pkgmgr",
env=env,
)
pkgmgr_help_debug()
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -5,15 +5,18 @@
Unit tests for NixFlakeInstaller using unittest (no pytest). Unit tests for NixFlakeInstaller using unittest (no pytest).
Covers: Covers:
- Successful installation (exit_code == 0) - Successful installation (returncode == 0)
- Mandatory failure → SystemExit with correct code - Mandatory failure → SystemExit with correct code
- Optional failure (pkgmgr default) → no raise, but warning - Optional failure (pkgmgr default) → no raise, but warning
- supports() behavior incl. PKGMGR_DISABLE_NIX_FLAKE_INSTALLER - supports() behavior incl. PKGMGR_DISABLE_NIX_FLAKE_INSTALLER
""" """
from __future__ import annotations
import io import io
import os import os
import shutil import shutil
import subprocess
import tempfile import tempfile
import unittest import unittest
from contextlib import redirect_stdout from contextlib import redirect_stdout
@@ -25,10 +28,19 @@ from pkgmgr.actions.install.installers.nix_flake import NixFlakeInstaller
class DummyCtx: class DummyCtx:
"""Minimal context object to satisfy NixFlakeInstaller.run() / supports().""" """Minimal context object to satisfy NixFlakeInstaller.run() / supports()."""
def __init__(self, identifier: str, repo_dir: str, preview: bool = False): def __init__(
self,
identifier: str,
repo_dir: str,
preview: bool = False,
quiet: bool = False,
force_update: bool = False,
):
self.identifier = identifier self.identifier = identifier
self.repo_dir = repo_dir self.repo_dir = repo_dir
self.preview = preview self.preview = preview
self.quiet = quiet
self.force_update = force_update
class TestNixFlakeInstaller(unittest.TestCase): class TestNixFlakeInstaller(unittest.TestCase):
@@ -44,161 +56,162 @@ class TestNixFlakeInstaller(unittest.TestCase):
os.environ.pop("PKGMGR_DISABLE_NIX_FLAKE_INSTALLER", None) os.environ.pop("PKGMGR_DISABLE_NIX_FLAKE_INSTALLER", None)
def tearDown(self) -> None: def tearDown(self) -> None:
# Cleanup temporary directory
if os.path.isdir(self._tmpdir): if os.path.isdir(self._tmpdir):
shutil.rmtree(self._tmpdir, ignore_errors=True) shutil.rmtree(self._tmpdir, ignore_errors=True)
def _enable_nix_in_module(self, which_patch): @staticmethod
def _cp(code: int) -> subprocess.CompletedProcess:
# stdout/stderr are irrelevant here, but keep shape realistic
return subprocess.CompletedProcess(args=["nix"], returncode=code, stdout="", stderr="")
@staticmethod
def _enable_nix_in_module(which_patch) -> None:
"""Ensure shutil.which('nix') in nix_flake module returns a path.""" """Ensure shutil.which('nix') in nix_flake module returns a path."""
which_patch.return_value = "/usr/bin/nix" which_patch.return_value = "/usr/bin/nix"
def test_nix_flake_run_success(self): def test_nix_flake_run_success(self) -> None:
""" """
When os.system returns a successful exit code, the installer When run_command returns success (returncode 0), installer
should report success and not raise. should report success and not raise.
""" """
ctx = DummyCtx(identifier="some-lib", repo_dir=self.repo_dir) ctx = DummyCtx(identifier="some-lib", repo_dir=self.repo_dir)
installer = NixFlakeInstaller() installer = NixFlakeInstaller()
buf = io.StringIO() buf = io.StringIO()
with patch( with patch("pkgmgr.actions.install.installers.nix_flake.shutil.which") as which_mock, patch(
"pkgmgr.actions.install.installers.nix_flake.shutil.which" "pkgmgr.actions.install.installers.nix_flake.subprocess.run"
) as which_mock, patch( ) as subproc_mock, patch(
"pkgmgr.actions.install.installers.nix_flake.os.system" "pkgmgr.actions.install.installers.nix_flake.run_command"
) as system_mock, redirect_stdout(buf): ) as run_cmd_mock, redirect_stdout(buf):
self._enable_nix_in_module(which_mock) self._enable_nix_in_module(which_mock)
# Simulate os.system returning success (exit code 0) # For profile list JSON (used only on failure paths, but keep deterministic)
system_mock.return_value = 0 subproc_mock.return_value = subprocess.CompletedProcess(
args=["nix", "profile", "list", "--json"],
returncode=0,
stdout='{"elements": []}',
stderr="",
)
# Install succeeds
run_cmd_mock.return_value = self._cp(0)
# Sanity: supports() must be True
self.assertTrue(installer.supports(ctx)) self.assertTrue(installer.supports(ctx))
installer.run(ctx) installer.run(ctx)
out = buf.getvalue() out = buf.getvalue()
self.assertIn("[INFO] Running: nix profile install", out) self.assertIn("[nix] install: nix profile install", out)
self.assertIn("Nix flake output 'default' successfully installed.", out) self.assertIn("[nix] output 'default' successfully installed.", out)
# Ensure the nix command was actually invoked run_cmd_mock.assert_called_with(
system_mock.assert_called_with( f"nix profile install {self.repo_dir}#default",
f"nix profile install {self.repo_dir}#default" cwd=self.repo_dir,
preview=False,
allow_failure=True,
) )
def test_nix_flake_run_mandatory_failure_raises(self): def test_nix_flake_run_mandatory_failure_raises(self) -> None:
""" """
For a generic repository (identifier not pkgmgr/package-manager), For a generic repository, 'default' is mandatory.
`default` is mandatory and a non-zero exit code should raise SystemExit A non-zero return code must raise SystemExit with that code.
with the real exit code (e.g. 1, not 256).
""" """
ctx = DummyCtx(identifier="some-lib", repo_dir=self.repo_dir) ctx = DummyCtx(identifier="some-lib", repo_dir=self.repo_dir)
installer = NixFlakeInstaller() installer = NixFlakeInstaller()
buf = io.StringIO() buf = io.StringIO()
with patch( with patch("pkgmgr.actions.install.installers.nix_flake.shutil.which") as which_mock, patch(
"pkgmgr.actions.install.installers.nix_flake.shutil.which" "pkgmgr.actions.install.installers.nix_flake.subprocess.run"
) as which_mock, patch( ) as subproc_mock, patch(
"pkgmgr.actions.install.installers.nix_flake.os.system" "pkgmgr.actions.install.installers.nix_flake.run_command"
) as system_mock, redirect_stdout(buf): ) as run_cmd_mock, redirect_stdout(buf):
self._enable_nix_in_module(which_mock) self._enable_nix_in_module(which_mock)
# Simulate os.system returning encoded status for exit code 1 # No indices available (empty list)
# os.system encodes exit code as (exit_code << 8) subproc_mock.return_value = subprocess.CompletedProcess(
system_mock.return_value = 1 << 8 args=["nix", "profile", "list", "--json"],
returncode=0,
stdout='{"elements": []}',
stderr="",
)
# First install fails, retry fails -> should raise SystemExit(1)
run_cmd_mock.side_effect = [self._cp(1), self._cp(1)]
self.assertTrue(installer.supports(ctx)) self.assertTrue(installer.supports(ctx))
with self.assertRaises(SystemExit) as cm: with self.assertRaises(SystemExit) as cm:
installer.run(ctx) installer.run(ctx)
# The real exit code should be 1 (not 256)
self.assertEqual(cm.exception.code, 1) self.assertEqual(cm.exception.code, 1)
out = buf.getvalue() out = buf.getvalue()
self.assertIn("[INFO] Running: nix profile install", out) self.assertIn("[nix] install: nix profile install", out)
self.assertIn("[Error] Failed to install Nix flake output 'default'", out) self.assertIn("[ERROR] Failed to install Nix flake output 'default' (exit 1)", out)
self.assertIn("[Error] Command exited with code 1", out)
def test_nix_flake_run_optional_failure_does_not_raise(self): def test_nix_flake_run_optional_failure_does_not_raise(self) -> None:
""" """
For the package-manager repository, the 'default' output is optional. For pkgmgr/package-manager repositories:
Failure to install it must not raise, but should log a warning instead. - 'pkgmgr' output is mandatory
- 'default' output is optional
Failure of optional output must not raise.
""" """
ctx = DummyCtx(identifier="pkgmgr", repo_dir=self.repo_dir) ctx = DummyCtx(identifier="pkgmgr", repo_dir=self.repo_dir)
installer = NixFlakeInstaller() installer = NixFlakeInstaller()
calls = []
def fake_system(cmd: str) -> int:
calls.append(cmd)
# First call (pkgmgr) → success
if len(calls) == 1:
return 0
# Second call (default) → failure (exit code 1 encoded)
return 1 << 8
buf = io.StringIO() buf = io.StringIO()
with patch( with patch("pkgmgr.actions.install.installers.nix_flake.shutil.which") as which_mock, patch(
"pkgmgr.actions.install.installers.nix_flake.shutil.which" "pkgmgr.actions.install.installers.nix_flake.subprocess.run"
) as which_mock, patch( ) as subproc_mock, patch(
"pkgmgr.actions.install.installers.nix_flake.os.system", "pkgmgr.actions.install.installers.nix_flake.run_command"
side_effect=fake_system, ) as run_cmd_mock, redirect_stdout(buf):
), redirect_stdout(buf):
self._enable_nix_in_module(which_mock) self._enable_nix_in_module(which_mock)
# No indices available (empty list)
subproc_mock.return_value = subprocess.CompletedProcess(
args=["nix", "profile", "list", "--json"],
returncode=0,
stdout='{"elements": []}',
stderr="",
)
# pkgmgr install ok; default fails twice (initial + retry)
run_cmd_mock.side_effect = [self._cp(0), self._cp(1), self._cp(1)]
self.assertTrue(installer.supports(ctx)) self.assertTrue(installer.supports(ctx))
# Optional failure must NOT raise # Must NOT raise despite optional failure
installer.run(ctx) installer.run(ctx)
out = buf.getvalue() out = buf.getvalue()
# Both outputs should have been mentioned # Should announce both outputs
self.assertIn( self.assertIn("ensuring outputs: pkgmgr, default", out)
"attempting to install profile outputs: pkgmgr, default", out
)
# First output ("pkgmgr") succeeded # First output ok
self.assertIn( self.assertIn("[nix] output 'pkgmgr' successfully installed.", out)
"Nix flake output 'pkgmgr' successfully installed.", out
)
# Second output ("default") failed but did not raise # Second output failed but no raise
self.assertIn( self.assertIn("[ERROR] Failed to install Nix flake output 'default' (exit 1)", out)
"[Error] Failed to install Nix flake output 'default'", out self.assertIn("[WARNING] Continuing despite failure of optional output 'default'.", out)
)
self.assertIn("[Error] Command exited with code 1", out)
self.assertIn(
"Continuing despite failure to install optional output 'default'.",
out,
)
# Ensure we actually called os.system twice (pkgmgr and default) # Verify run_command was called for both outputs (default twice due to retry)
self.assertEqual(len(calls), 2) expected_calls = [
self.assertIn( (f"nix profile install {self.repo_dir}#pkgmgr",),
f"nix profile install {self.repo_dir}#pkgmgr", (f"nix profile install {self.repo_dir}#default",),
calls[0], (f"nix profile install {self.repo_dir}#default",),
) ]
self.assertIn( actual_cmds = [c.args[0] for c in run_cmd_mock.call_args_list]
f"nix profile install {self.repo_dir}#default", self.assertEqual(actual_cmds, [e[0] for e in expected_calls])
calls[1],
)
def test_nix_flake_supports_respects_disable_env(self): def test_nix_flake_supports_respects_disable_env(self) -> None:
""" """
PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 must disable the installer, PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 must disable the installer,
even if flake.nix exists and nix is available. even if flake.nix exists and nix is available.
""" """
ctx = DummyCtx(identifier="pkgmgr", repo_dir=self.repo_dir) ctx = DummyCtx(identifier="pkgmgr", repo_dir=self.repo_dir, quiet=False)
installer = NixFlakeInstaller() installer = NixFlakeInstaller()
with patch( with patch("pkgmgr.actions.install.installers.nix_flake.shutil.which") as which_mock:
"pkgmgr.actions.install.installers.nix_flake.shutil.which"
) as which_mock:
self._enable_nix_in_module(which_mock) self._enable_nix_in_module(which_mock)
os.environ["PKGMGR_DISABLE_NIX_FLAKE_INSTALLER"] = "1" os.environ["PKGMGR_DISABLE_NIX_FLAKE_INSTALLER"] = "1"
self.assertFalse(installer.supports(ctx)) self.assertFalse(installer.supports(ctx))