diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/gallery-probes/probe.mjs | 2 | ||||
| -rw-r--r-- | tests/gallery-probes/test_dupre_merge.py | 53 | ||||
| -rw-r--r-- | tests/gallery-tokens/test_gen_tokens.py | 22 | ||||
| -rw-r--r-- | tests/gallery-widgets/test-gallery-widget.el | 20 | ||||
| -rw-r--r-- | tests/installer-steps/test_grub_cmdline.py | 42 | ||||
| -rw-r--r-- | tests/installer-steps/test_hyprlock_pam.py | 85 | ||||
| -rw-r--r-- | tests/installer-steps/test_orchestrators.py | 2 | ||||
| -rw-r--r-- | tests/maint-scenarios/test_scenario_plan.py | 61 | ||||
| -rw-r--r-- | tests/vm-framework/test_run_common.py | 97 |
9 files changed, 383 insertions, 1 deletions
diff --git a/tests/gallery-probes/probe.mjs b/tests/gallery-probes/probe.mjs index 5f56f46..9d230c0 100644 --- a/tests/gallery-probes/probe.mjs +++ b/tests/gallery-probes/probe.mjs @@ -97,7 +97,7 @@ try { // 1. card count (the instruments/row default is checked in 1h) const cards = await evl(`document.querySelectorAll('.card').length`); - ok('111 cards', cards === 111, `got ${cards}`); + ok('112 cards', cards === 112, `got ${cards}`); // 1b. Every card carries a spec sheet. R58 shipped without one and nothing // noticed — the sheet is the card's actual specification, so a card without diff --git a/tests/gallery-probes/test_dupre_merge.py b/tests/gallery-probes/test_dupre_merge.py new file mode 100644 index 0000000..891b789 --- /dev/null +++ b/tests/gallery-probes/test_dupre_merge.py @@ -0,0 +1,53 @@ +"""The settings-casting widget candidates live in the canonical Dupre kit.""" + +import os +import unittest + + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + + +def source(name): + with open(os.path.join(ROOT, "docs", "prototypes", name)) as handle: + return handle.read() + + +class DupreMergeTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.widgets = source("widgets.js") + cls.additions = source("dupre-kit-additions.js") + cls.gallery = source("panel-widget-gallery.html") + + def test_three_candidates_are_owned_only_by_the_canonical_kit(self): + for name in ("detentFader", "drumRoller", "guardedToggle"): + declaration = f"DUPRE.{name} = function" + self.assertEqual(self.widgets.count(declaration), 1, name) + self.assertNotIn(declaration, self.additions, name) + + def test_detent_fader_brings_its_styles_and_policy(self): + self.assertIn(".dupre-dfader{", self.widgets) + self.assertIn("DUPRE.detentFader.POLICY", self.widgets) + + def test_drum_supports_any_channel_count_and_configurable_range(self): + for fragment in ( + "opts.min !== undefined", + "opts.max !== undefined", + "opts.height !== undefined", + "chans.forEach((c, i) => set(i, c.v))", + ): + self.assertIn(fragment, self.widgets) + + def test_guarded_toggle_throws_through_the_viewer_plane(self): + self.assertIn("lever.style.transformBox = 'view-box'", self.widgets) + self.assertIn("'rotateX(180deg)'", self.widgets) + + def test_gallery_specifies_detent_and_multichannel_drum(self): + self.assertIn("card(C,'A1','Detent fader'", self.gallery) + self.assertIn("channels:[{name:'HIGH'", self.gallery) + self.assertIn("{name:'MID'", self.gallery) + self.assertIn("{name:'LOW'", self.gallery) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/gallery-tokens/test_gen_tokens.py b/tests/gallery-tokens/test_gen_tokens.py index 2968119..afd9db1 100644 --- a/tests/gallery-tokens/test_gen_tokens.py +++ b/tests/gallery-tokens/test_gen_tokens.py @@ -95,6 +95,28 @@ class ResolveColor(unittest.TestCase): gt.resolve_color(FIXTURE, "nonexistent") +class IterSectionItems(unittest.TestCase): + def test_preserves_section_and_item_order(self): + self.assertEqual( + list(gt.iter_section_items(FIXTURE, ("amber", "palette"))), + [ + ("gold", "#e2a038"), + ("gold-hi", "#ffbe54"), + ("amber-grad-top", "#f2c76a"), + ("ground", "#151311"), + ("wash", "#2c2f32"), + ("slate-hi", "#54677d"), + ], + ) + + def test_missing_and_empty_sections_contribute_no_items(self): + tokens = {"palette": {}, "amber": {"gold": "#e2a038"}} + self.assertEqual( + list(gt.iter_section_items(tokens, ("missing", "palette", "amber"))), + [("gold", "#e2a038")], + ) + + class EmitWebCss(unittest.TestCase): def setUp(self): self.css = gt.emit_web_css(FIXTURE) diff --git a/tests/gallery-widgets/test-gallery-widget.el b/tests/gallery-widgets/test-gallery-widget.el index c0d38b4..9a3c29f 100644 --- a/tests/gallery-widgets/test-gallery-widget.el +++ b/tests/gallery-widgets/test-gallery-widget.el @@ -54,6 +54,26 @@ (should-error (gallery-widget--needle-angle "fifty")) (should-error (gallery-widget--needle-angle nil))) +;;; --- shared SVG geometry --- + +(ert-deftest gallery-widget-semicircle-path-normal () + "A semicircle path is derived from center and radius, not copied literals." + (should (equal (gallery-widget--semicircle-path 48 48 47) + "M 1 48 A 47 47 0 0 1 95 48"))) + +(ert-deftest gallery-widget-semicircle-path-can-close-a-hub () + "The same geometry helper closes the smaller half-dome hub." + (should (equal (gallery-widget--semicircle-path 48 48 4 t) + "M 44 48 A 4 4 0 0 1 52 48 Z"))) + +(ert-deftest gallery-widget-source-uses-only-public-dom-append () + "The renderer must not depend on svg.el's private `svg--append' API." + (with-temp-buffer + (insert-file-contents + (expand-file-name "docs/prototypes/gallery-widget.el" + test-gallery-widget--root)) + (should-not (search-forward "svg--append" nil t)))) + ;;; --- rendered SVG structure --- (defun test-gallery-widget--svg-string (value) diff --git a/tests/installer-steps/test_grub_cmdline.py b/tests/installer-steps/test_grub_cmdline.py index 8c0b5b0..4c2d202 100644 --- a/tests/installer-steps/test_grub_cmdline.py +++ b/tests/installer-steps/test_grub_cmdline.py @@ -67,6 +67,48 @@ class MergeGrubCmdline(unittest.TestCase): self.assertIn("zfs=zroot/ROOT/default", out) +def run_with_gpu(body, gpu): + # detect_gpu_vendors is stubbed to a chosen vendor list so the AMD-only + # amdgpu.runpm=0 addition can be exercised without real /sys reads. + stub = 'detect_gpu_vendors() { printf "%s\\n" %s; }\n' % ("%s", "'" + gpu + "'") + stub = 'detect_gpu_vendors() { printf "%s" "$GPU"; [ -n "$GPU" ] && echo; }\n' + script = ('logfile=/dev/null\nerror_warn() { echo "WARN: $1"; return 1; }\n' + + stub + EXTRACT + "\n" + body + "\n") + return subprocess.run(["bash", "-c", script], capture_output=True, text=True, + timeout=10, env={**os.environ, "GPU": gpu}) + + +class AmdRunpmCmdline(unittest.TestCase): + """amdgpu.runpm=0 is added on AMD machines only: the AMD iGPU invalidates + hyprlock's GPU resources on a display power cycle, wedging the lock + (hyprlock#953). Disabling GPU runtime PM keeps them valid. A no-op on Intel + or NVIDIA.""" + + def _file(self, gpu): + import tempfile + fd, path = tempfile.mkstemp(prefix="grub-runpm-") + os.close(fd) + with open(path, "w") as f: + f.write('GRUB_CMDLINE_LINUX_DEFAULT="rw quiet"\n') + self.addCleanup(os.remove, path) + run_with_gpu(f'update_grub_cmdline "{path}"', gpu) + with open(path) as f: + return f.read() + + def test_amd_gets_runpm_disabled(self): + self.assertIn("amdgpu.runpm=0", self._file("amd")) + + def test_intel_does_not(self): + self.assertNotIn("amdgpu.runpm", self._file("intel")) + + def test_no_gpu_detected_does_not(self): + self.assertNotIn("amdgpu.runpm", self._file("")) + + def test_amd_with_nvidia_still_gets_it(self): + # A hybrid box with an AMD part present still wants the fix. + self.assertIn("amdgpu.runpm=0", self._file("amd\nnvidia")) + + class UpdateGrubCmdline(unittest.TestCase): def run_update(self, grub_body): with tempfile.TemporaryDirectory() as d: diff --git a/tests/installer-steps/test_hyprlock_pam.py b/tests/installer-steps/test_hyprlock_pam.py new file mode 100644 index 0000000..8d7295f --- /dev/null +++ b/tests/installer-steps/test_hyprlock_pam.py @@ -0,0 +1,85 @@ +"""Tests for configure_hyprlock_pam in the archsetup installer. + +hyprlock's packaged /etc/pam.d/hyprlock ships only `auth include login`, with no +account or session sections. pam_end() then crashes cleaning up handles that +were never initialised -- one of the documented causes of the hyprlock lockout +where the session wedges with no prompt (hyprlock#953). configure_hyprlock_pam +writes a complete stack so the account and session phases run. + +These tests exercise the REAL function body, extracted from the `archsetup` +script at run time, against a fixture PAM path. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_hyprlock_pam +""" + +import os +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") + + +def run(path): + script = ( + 'logfile=/dev/null\n' + 'display() { :; }\n' + 'backup_system_file() { :; }\n' + 'error_warn() { echo "WARN:$1"; return 1; }\n' + "source <(sed -n '/^configure_hyprlock_pam() {/,/^}/p' \"%s\")\n" + 'configure_hyprlock_pam "%s"\n' + ) % (ARCHSETUP, path) + return subprocess.run(["bash", "-c", script], capture_output=True, + text=True, timeout=10) + + +class ConfigureHyprlockPam(unittest.TestCase): + def _write(self, initial="auth include login\n"): + fd, path = tempfile.mkstemp(prefix="pam-hyprlock-") + os.close(fd) + with open(path, "w") as f: + f.write(initial) + self.addCleanup(os.remove, path) + return path + + def _stack(self, path): + with open(path) as f: + return [l.split()[0] for l in f if l.strip() + and not l.lstrip().startswith("#")] + + # ---------------------------------------------------------- normal ---- + def test_writes_all_three_phases(self): + path = self._write() + run(path) + phases = set(self._stack(path)) + self.assertEqual(phases, {"auth", "account", "session"}, + "the complete stack must carry all three PAM phases") + + def test_account_and_session_are_the_added_ones(self): + path = self._write() + run(path) + stack = self._stack(path) + self.assertIn("account", stack) + self.assertIn("session", stack) + + # -------------------------------------------------------- boundary ---- + def test_idempotent_on_rerun(self): + path = self._write() + run(path) + first = open(path).read() + run(path) + self.assertEqual(open(path).read(), first, + "a second run must not stack duplicate lines") + + def test_overwrites_the_incomplete_package_default(self): + # The package default is exactly `auth include login`; a re-run replaces + # it wholesale rather than appending to it. + path = self._write("auth include login\n") + run(path) + self.assertEqual(self._stack(path).count("auth"), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_orchestrators.py b/tests/installer-steps/test_orchestrators.py index 8de75cc..a2b235b 100644 --- a/tests/installer-steps/test_orchestrators.py +++ b/tests/installer-steps/test_orchestrators.py @@ -169,6 +169,8 @@ CALL_SITES = { "configure_snapshots": ["configure_zfs_snapshots", "configure_btrfs_snapshots"], "configure_zfs_snapshots": ["zfs_scrub_timer_units"], "install_maintenance_config": ["install_maintenance_thresholds"], + "hyprland": ["configure_hyprlock_pam"], + "update_grub_cmdline": ["detect_gpu_vendors"], } diff --git a/tests/maint-scenarios/test_scenario_plan.py b/tests/maint-scenarios/test_scenario_plan.py index 9a72db2..d2dc8ea 100644 --- a/tests/maint-scenarios/test_scenario_plan.py +++ b/tests/maint-scenarios/test_scenario_plan.py @@ -213,6 +213,67 @@ class UsageTests(unittest.TestCase): NSPAWN_RUNNER = os.path.join(REPO_ROOT, "scripts", "testing", "run-maint-nspawn.sh") +SCENARIO_LIB = os.path.join(REPO_ROOT, "scripts", "testing", "lib", + "maint-scenario.sh") + + +class SharedScenarioLibraryTests(unittest.TestCase): + """Both transports share scenario parsing and execution semantics.""" + + def test_both_runners_source_the_shared_library(self): + for runner in (RUNNER, NSPAWN_RUNNER): + with open(runner) as f: + source = f.read() + self.assertIn('source "$SCRIPT_DIR/lib/maint-scenario.sh"', source) + self.assertNotRegex(source, r"(?m)^_scenario_var\(\)") + self.assertNotRegex(source, r"(?m)^_validate_scenario\(\)") + self.assertNotRegex(source, r"(?m)^run_scenario\(\)") + + def test_shared_runner_records_pass_and_cleans_contract_symbols(self): + with tempfile.TemporaryDirectory() as d: + scenario = write_scenario(d, "10-pass.sh") + script = f""" +source "{SCENARIO_LIB}" +section() {{ :; }}; step() {{ :; }}; success() {{ :; }}; error() {{ :; }} +mexec() {{ :; }}; mfix() {{ :; }} +S_FILES=("{scenario}"); S_NAMES=("pass"); S_DESCS=("works") +PASS=(); FAIL=() +run_scenario 0 +printf 'pass=%s fail=%s desc=%s fn=%s\\n' \ + "${{#PASS[@]}}" "${{#FAIL[@]}}" "${{SCENARIO_DESC-unset}}" \ + "$(declare -F scenario_break || echo unset)" +""" + proc = subprocess.run(["bash", "-c", script], capture_output=True, + text=True, cwd=REPO_ROOT) + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertIn("pass=1 fail=0 desc=unset fn=unset", proc.stdout) + + def test_shared_runner_stops_after_failed_fix(self): + with tempfile.TemporaryDirectory() as d: + scenario = write_scenario( + d, "10-fail.sh", + body=GOOD_SCENARIO.format(desc="fails", group="g", + profiles="any").replace( + "scenario_fix() { mfix some_remedy; }", + "scenario_fix() { return 1; }", + ).replace( + 'scenario_assert() { mexec "true"; }', + 'scenario_assert() { echo ASSERT_RAN; }', + ), + ) + script = f""" +source "{SCENARIO_LIB}" +section() {{ :; }}; step() {{ :; }}; success() {{ :; }}; error() {{ :; }} +S_FILES=("{scenario}"); S_NAMES=("fail"); S_DESCS=("fails") +PASS=(); FAIL=() +run_scenario 0 +printf 'pass=%s fail=%s\\n' "${{#PASS[@]}}" "${{#FAIL[@]}}" +""" + proc = subprocess.run(["bash", "-c", script], capture_output=True, + text=True, cwd=REPO_ROOT) + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertNotIn("ASSERT_RAN", proc.stdout) + self.assertIn("pass=0 fail=1", proc.stdout) class NspawnPlanTests(unittest.TestCase): diff --git a/tests/vm-framework/test_run_common.py b/tests/vm-framework/test_run_common.py new file mode 100644 index 0000000..c5f22e5 --- /dev/null +++ b/tests/vm-framework/test_run_common.py @@ -0,0 +1,97 @@ +"""Tests for the run-test transport-independent polling/report core.""" + +import os +import subprocess +import tempfile +import textwrap +import unittest + + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +RUN_COMMON = os.path.join( + REPO_ROOT, "scripts", "testing", "lib", "run-common.sh") +VM_RUNNER = os.path.join(REPO_ROOT, "scripts", "testing", "run-test.sh") +BARE_RUNNER = os.path.join( + REPO_ROOT, "scripts", "testing", "run-test-baremetal.sh") + + +def run_bash(body): + return subprocess.run( + ["bash", "-c", f'source "{RUN_COMMON}"\n{body}'], + capture_output=True, text=True, cwd=REPO_ROOT, timeout=10, + ) + + +class SharedCoreWiring(unittest.TestCase): + def test_both_runners_source_shared_core_and_avoid_ps_grep(self): + for runner in (VM_RUNNER, BARE_RUNNER): + with open(runner) as f: + source = f.read() + self.assertIn('source "$SCRIPT_DIR/lib/run-common.sh"', source) + self.assertNotIn("ps aux | grep '[b]ash archsetup'", source) + + def test_vm_runner_adapts_the_password_taking_transport(self): + with open(VM_RUNNER) as f: + source = f.read() + self.assertIn('vm_exec "$ROOT_PASSWORD" "$@"', source) + self.assertIn("archsetup_process_running vm_transport", source) + self.assertIn("wait_for_archsetup vm_transport", source) + + +class PollArchsetup(unittest.TestCase): + def test_uses_one_pgrep_liveness_probe_until_process_stops(self): + proc = run_bash(textwrap.dedent("""\ + checks=0 + transport() { + [ "$1" = "pgrep -f '[b]ash archsetup' > /dev/null" ] || + { echo "bad-command:$1"; return 2; } + checks=$((checks + 1)) + [ "$checks" -lt 3 ] + } + progress() { echo "progress:$1"; } + wait_for_archsetup transport 5 0 1 progress + echo "rc=$? checks=$checks polls=$ARCHSETUP_POLL_COUNT" + """)) + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertNotIn("bad-command", proc.stdout) + self.assertIn("progress:1", proc.stdout) + self.assertIn("progress:2", proc.stdout) + self.assertIn("rc=0 checks=3 polls=2", proc.stdout) + + def test_returns_124_at_poll_limit(self): + proc = run_bash(textwrap.dedent("""\ + transport() { return 0; } + set +e + wait_for_archsetup transport 2 0 10 + rc=$? + set -e + echo "rc=$rc polls=$ARCHSETUP_POLL_COUNT" + """)) + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertIn("rc=124 polls=2", proc.stdout) + + +class Report(unittest.TestCase): + def test_writes_common_report_with_transport_specific_details(self): + with tempfile.TemporaryDirectory() as d: + report = os.path.join(d, "report.txt") + proc = run_bash(textwrap.dedent(f"""\ + write_archsetup_test_report "{report}" \ + "Bare Metal ArchSetup Test Report" "stamp" \ + "Bare Metal ZFS" "Target: ratio.local" \ + yes true 12 1 2 "Results: {d}/" + """)) + self.assertEqual(proc.returncode, 0, proc.stderr) + with open(report) as f: + text = f.read() + self.assertIn("Bare Metal ArchSetup Test Report", text) + self.assertIn("Test Method: Bare Metal ZFS", text) + self.assertIn("Target: ratio.local", text) + self.assertIn("ArchSetup Completed: yes", text) + self.assertIn("Validation: PASSED", text) + self.assertIn("Passed: 12", text) + self.assertIn(f"Results: {d}/", text) + + +if __name__ == "__main__": + unittest.main() |
