From 3becfac66dc569e71d0f6085fb56e92cfa6d626a Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Fri, 24 Jul 2026 12:19:04 -0500 Subject: fix(installer): eight fixes from an overnight bug-hunt and its review I squashed these because the per-bug reasoning lives in todo.org, which this commit carries. Two could cost a machine. configure_initramfs_hook swapped the udev hook for systemd on a LUKS root, leaving a standalone encrypt hook under an init that never runs it. The rebuild succeeds and the installer exits clean, then the root won't unlock at the next boot. trim_firmware ran pacman -Rdd against twelve firmware packages behind a DMI gate reading product_name, where "Framework" never appears. That left it dead on the hardware it targets, and dangerous to fix the obvious way: this machine is a Framework Desktop whose Ryzen iGPU needs the amdgpu firmware. It refuses on PCI modalias evidence now. wipedisk discarded before it checked. blkdiscard ran with -f, which disables the exclusive open, so picking the wrong disk destroyed a live filesystem and then reported that nothing had happened. Four more are smaller. The NVIDIA preflight aborted dwm and headless installs over a driver floor they never need. zfs-replicate exited 0 after every dataset failed. Unattended installs blocked on two prompts, and the first fix for that inherited a [Y/n] default into passwordless console login. A fresh install left the dotfiles repo permanently dirty. The review found a pattern worth more than any single fix. Helpers had thorough tests and none proved they were called. Deleting the call left five suites green, including the guard on that pacman -Rdd. CALL_SITES now pins nine caller/callee pairs. The suite runs 341 tests at exit 0, with no new shellcheck findings. I proved every guard by deleting it and watching the intended test go red. --- tests/installer-steps/test_select_locale.py | 160 ++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 tests/installer-steps/test_select_locale.py (limited to 'tests/installer-steps/test_select_locale.py') diff --git a/tests/installer-steps/test_select_locale.py b/tests/installer-steps/test_select_locale.py new file mode 100644 index 0000000..b9b9ce7 --- /dev/null +++ b/tests/installer-steps/test_select_locale.py @@ -0,0 +1,160 @@ +"""Tests for select_locale in the archsetup installer. + +select_locale resolves the system locale during pre-flight. Three inputs, in +precedence order: an existing LANG= in locale.conf wins outright, then LOCALE +from the config file, then a numbered menu. + +That menu is why these tests exist. It carries its own "[1]" default, LOCALE is +optional in archsetup.conf.example, and archsetup does not require an archangel +install -- so an unattended run (--config-file) can reach the menu's bare read +with nobody there to answer it. configure_build_environment's own "no LANG= in +locale.conf" branch shows archsetup expects that state to occur. Unattended +therefore takes the menu's default, the same ruling nvidia_preflight and +configure_autologin make for their prompts. + +These tests exercise the REAL function body, extracted from the `archsetup` +script at run time (not a copy), against a fixture locale.conf. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_select_locale +""" + +import os +import shutil +import subprocess +import tempfile +import unittest + + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + + +class SelectLocaleHarness(unittest.TestCase): + """Source select_locale out of the real archsetup script.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="select-locale-test-") + self.conf = os.path.join(self.tmp, "locale.conf") + self.wrapper = os.path.join(self.tmp, "run.sh") + with open(self.wrapper, "w") as f: + f.write( + "#!/bin/bash\n" + 'ARCHSETUP="$1"; shift\n' + "source <(sed -n " + "'/^select_locale() {/,/^}/p' \"$ARCHSETUP\")\n" + 'select_locale "$LOCALE_CONF"\n' + 'echo "RESOLVED=$locale"\n' + # Whatever the prompt did not consume is still on stdin. + 'if IFS= read -r leftover; then echo "LEFTOVER:$leftover"; ' + 'else echo "LEFTOVER:"; fi\n' + ) + os.chmod(self.wrapper, 0o755) + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def write_conf(self, body): + with open(self.conf, "w") as f: + f.write(body) + + def run_select(self, locale="", config_file="", stdin=""): + env = dict(os.environ) + env["locale"] = locale + env["config_file"] = config_file + env["LOCALE_CONF"] = self.conf + return subprocess.run( + ["bash", self.wrapper, ARCHSETUP], + input=stdin, capture_output=True, text=True, env=env, + ) + + def resolved(self, r): + for line in r.stdout.splitlines(): + if line.startswith("RESOLVED="): + return line.split("=", 1)[1] + self.fail("no RESOLVED line; stdout=%r stderr=%r" % (r.stdout, r.stderr)) + + # ---------------------------------------------------------- normal ---- + def test_existing_locale_conf_wins_and_does_not_prompt(self): + self.write_conf("LANG=de_DE.UTF-8\n") + r = self.run_select(stdin="sentinel\n") + self.assertIn("[OK] Locale: de_DE.UTF-8", r.stdout) + # locale.conf is authoritative, so the global is deliberately left + # unset -- configure_build_environment reads LANG out of the file. + self.assertEqual(self.resolved(r), "") + self.assertIn("LEFTOVER:sentinel", r.stdout) + + def test_config_file_locale_used_when_conf_has_no_lang(self): + self.write_conf("") + r = self.run_select(locale="fr_FR.UTF-8", stdin="sentinel\n") + self.assertIn("(from config)", r.stdout) + self.assertEqual(self.resolved(r), "fr_FR.UTF-8") + self.assertIn("LEFTOVER:sentinel", r.stdout) + + def test_menu_choice_selects_that_locale(self): + self.write_conf("") + r = self.run_select(stdin="3\nsentinel\n") + self.assertEqual(self.resolved(r), "de_DE.UTF-8") + self.assertIn("LEFTOVER:sentinel", r.stdout) + + # -------------------------------------------------------- boundary ---- + def test_missing_locale_conf_falls_through_to_the_menu(self): + # No file at all, not just no LANG= line. + r = self.run_select(stdin="2\nsentinel\n") + self.assertEqual(self.resolved(r), "en_GB.UTF-8") + self.assertIn("LEFTOVER:sentinel", r.stdout) + + def test_bare_enter_takes_choice_one(self): + self.write_conf("") + r = self.run_select(stdin="\nsentinel\n") + self.assertEqual(self.resolved(r), "en_US.UTF-8") + self.assertIn("LEFTOVER:sentinel", r.stdout) + + def test_unattended_config_file_takes_the_default_without_reading(self): + # THE BUG. --config-file is the documented unattended mode and LOCALE + # is optional, so this is an ordinary unattended install on a machine + # whose locale.conf carries no LANG=. + self.write_conf("") + r = self.run_select(config_file="/etc/archsetup.conf", + stdin="sentinel\n") + self.assertEqual(self.resolved(r), "en_US.UTF-8") + self.assertIn("LEFTOVER:sentinel", r.stdout) + + def test_unattended_still_prefers_an_existing_locale_conf(self): + self.write_conf("LANG=ja_JP.UTF-8\n") + r = self.run_select(config_file="/etc/archsetup.conf", + stdin="sentinel\n") + self.assertIn("[OK] Locale: ja_JP.UTF-8", r.stdout) + self.assertIn("LEFTOVER:sentinel", r.stdout) + + def test_unattended_still_prefers_the_configured_locale(self): + # Unattended does not mean "always en_US" -- LOCALE is an answer, and + # the menu default must not override it. + self.write_conf("") + r = self.run_select(locale="pt_BR.UTF-8", + config_file="/etc/archsetup.conf", stdin="sentinel\n") + self.assertEqual(self.resolved(r), "pt_BR.UTF-8") + self.assertIn("LEFTOVER:sentinel", r.stdout) + + # ----------------------------------------------------------- error ---- + def test_unrecognised_choice_falls_back_to_en_us(self): + self.write_conf("") + r = self.run_select(stdin="42\nsentinel\n") + self.assertEqual(self.resolved(r), "en_US.UTF-8") + self.assertIn("LEFTOVER:sentinel", r.stdout) + + def test_manual_entry_reads_a_second_line(self): + self.write_conf("") + r = self.run_select(stdin="9\nnl_NL.UTF-8\nsentinel\n") + self.assertEqual(self.resolved(r), "nl_NL.UTF-8") + self.assertIn("LEFTOVER:sentinel", r.stdout) + + def test_manual_entry_left_empty_falls_back_to_en_us(self): + self.write_conf("") + r = self.run_select(stdin="9\n\nsentinel\n") + self.assertEqual(self.resolved(r), "en_US.UTF-8") + self.assertIn("LEFTOVER:sentinel", r.stdout) + + +if __name__ == "__main__": + unittest.main() -- cgit v1.2.3