aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/gallery-probes/probe.mjs2
-rw-r--r--tests/gallery-probes/test_dupre_merge.py53
-rw-r--r--tests/gallery-tokens/test_gen_tokens.py22
-rw-r--r--tests/gallery-widgets/test-gallery-widget.el20
-rw-r--r--tests/installer-steps/test_grub_cmdline.py42
-rw-r--r--tests/installer-steps/test_hyprlock_pam.py85
-rw-r--r--tests/installer-steps/test_orchestrators.py2
-rw-r--r--tests/maint-scenarios/test_scenario_plan.py61
-rw-r--r--tests/vm-framework/test_run_common.py97
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()