diff mbox series

[wic,2/2] tests/unit/test_ksparser_parse: cover the .wks KickStart parser

Message ID 20260717183507.3539287-3-twoerner@gmail.com
State New
Headers show
Series tests/unit/test_ksparser_parse: parse the .wks file, fix a diskid crash | expand

Commit Message

Trevor Woerner July 17, 2026, 6:35 p.m. UTC
Add end-to-end coverage for KickStart(): happy-path part/bootloader
lines, degenerate files (empty, comment-only, CRLF, unknown directive),
mutually-exclusive and invalid option combinations, multiple-bootloader
rejection, diskid parsing for both ptables, and include handling. The
bitbake variable lookup is neutralised so the parser runs with no
BitBake and no host tools.

The msdos --diskid cases assert that a non-integer value raises a
KickStartError naming the offending value.

AI-Generated: codex/claude-opus 4.8 (xhigh)
Signed-off-by: Trevor Woerner <twoerner@gmail.com>
---
 tests/unit/test_ksparser_parse.py | 217 ++++++++++++++++++++++++++++++
 1 file changed, 217 insertions(+)
 create mode 100644 tests/unit/test_ksparser_parse.py
diff mbox series

Patch

diff --git a/tests/unit/test_ksparser_parse.py b/tests/unit/test_ksparser_parse.py
new file mode 100644
index 000000000000..d5edc1250efc
--- /dev/null
+++ b/tests/unit/test_ksparser_parse.py
@@ -0,0 +1,217 @@ 
+r"""
+End-to-end tests for the KickStart .wks parser (wic/ksparser.py).
+
+KickStart(confpath) reads a .wks file line by line, runs each line
+through an argparse-based grammar, validates filesystem/option
+combinations, and builds Partition objects plus a bootloader config.
+It is pure logic apart from get_bitbake_var() (used for ${VAR}
+expansion and the bootloader APPEND), which these tests neutralise so
+no bitbake or host tools are needed.
+
+Each test writes a small .wks file and asserts on the parsed result or
+on the specific error raised.
+"""
+
+import sys
+from pathlib import Path
+
+import pytest
+
+_SRC = Path(__file__).resolve().parent.parent.parent / "src"
+if str(_SRC) not in sys.path:
+    sys.path.insert(0, str(_SRC))
+
+import wic.misc as misc
+import wic.ksparser as ksparser
+from wic.ksparser import KickStart, KickStartError
+
+
+@pytest.fixture(autouse=True)
+def no_bitbake(monkeypatch):
+    """Neutralise bitbake variable lookups in both modules.
+
+    get_bitbake_var is imported into ksparser's namespace and also used
+    via misc; patch both so ${VAR} expansion and APPEND resolve to None
+    instead of trying to run `bitbake -e`.
+    """
+    monkeypatch.setattr(misc, "get_bitbake_var", lambda *a, **k: None)
+    monkeypatch.setattr(ksparser, "get_bitbake_var", lambda *a, **k: None)
+
+
+def _wks(tmp_path, text):
+    path = tmp_path / "test.wks"
+    path.write_text(text)
+    return str(path)
+
+
+def _parse(tmp_path, text):
+    return KickStart(_wks(tmp_path, text))
+
+
+# ---------------------------------------------------------------------------
+# Happy path
+# ---------------------------------------------------------------------------
+
+class TestParseHappyPath:
+
+    def test_single_part(self, tmp_path):
+        ks = _parse(tmp_path, "part / --source rootfs --fstype ext4 --size 100M\n")
+        assert len(ks.partitions) == 1
+        p = ks.partitions[0]
+        assert p.fstype == "ext4"
+        assert p.size == 100 * 1024
+        assert p.mountpoint == "/"
+
+    def test_defaults_applied_without_fixed_size(self, tmp_path):
+        ks = _parse(tmp_path, "part / --source rootfs --fstype ext4 --size 1M\n")
+        p = ks.partitions[0]
+        assert p.overhead_factor == KickStart.DEFAULT_OVERHEAD_FACTOR
+        assert p.extra_filesystem_space == KickStart.DEFAULT_EXTRA_FILESYSTEM_SPACE
+
+    def test_partnum_increments(self, tmp_path):
+        ks = _parse(
+            tmp_path,
+            "part /boot --source bootimg --fstype vfat --size 50M\n"
+            "part / --source rootfs --fstype ext4 --size 100M\n",
+        )
+        assert [p.lineno for p in ks.partitions] == [1, 2]
+        assert len(ks.partitions) == 2
+
+    def test_bootloader_parsed(self, tmp_path):
+        ks = _parse(tmp_path, "bootloader --ptable gpt\n")
+        assert ks.bootloader.ptable == "gpt"
+
+    def test_comment_and_blank_lines_ignored(self, tmp_path):
+        ks = _parse(
+            tmp_path,
+            "# a comment\n"
+            "\n"
+            "   \n"
+            "part / --source rootfs --fstype ext4 --size 1M\n",
+        )
+        assert len(ks.partitions) == 1
+
+
+# ---------------------------------------------------------------------------
+# Degenerate files
+# ---------------------------------------------------------------------------
+
+class TestParseDegenerateFiles:
+
+    def test_empty_file(self, tmp_path):
+        ks = _parse(tmp_path, "")
+        assert ks.partitions == []
+        assert ks.bootloader is not None  # defaults filled in
+
+    def test_comment_only_file(self, tmp_path):
+        ks = _parse(tmp_path, "# only comments\n# nothing else\n")
+        assert ks.partitions == []
+
+    def test_crlf_line_endings(self, tmp_path):
+        ks = _parse(tmp_path, "part / --source rootfs --fstype ext4 --size 1M\r\n")
+        assert len(ks.partitions) == 1
+
+    def test_unknown_directive_rejected(self, tmp_path):
+        with pytest.raises(KickStartError):
+            _parse(tmp_path, "frobnicate everything\n")
+
+
+# ---------------------------------------------------------------------------
+# Mutually-exclusive and invalid option combinations
+# ---------------------------------------------------------------------------
+
+class TestParseInvalidCombinations:
+
+    def test_size_and_fixed_size_conflict(self, tmp_path):
+        with pytest.raises(KickStartError):
+            _parse(tmp_path, "part / --source rootfs --fstype ext4 "
+                             "--size 100M --fixed-size 200M\n")
+
+    def test_fixed_size_with_overhead_factor_rejected(self, tmp_path):
+        with pytest.raises(KickStartError):
+            _parse(tmp_path, "part / --source rootfs --fstype ext4 "
+                             "--fixed-size 100M --overhead-factor 1.5\n")
+
+    def test_squashfs_with_label_rejected(self, tmp_path):
+        with pytest.raises(KickStartError):
+            _parse(tmp_path, "part / --source rootfs --fstype squashfs --label boot\n")
+
+    def test_squashfs_with_fsuuid_rejected(self, tmp_path):
+        with pytest.raises(KickStartError):
+            _parse(tmp_path, "part / --source rootfs --fstype squashfs --fsuuid 0x1234\n")
+
+    def test_erofs_with_label_rejected(self, tmp_path):
+        with pytest.raises(KickStartError):
+            _parse(tmp_path, "part / --source rootfs --fstype erofs --label x --size 1M\n")
+
+    def test_use_label_without_label_rejected(self, tmp_path):
+        with pytest.raises(KickStartError):
+            _parse(tmp_path, "part / --source rootfs --fstype ext4 --size 1M --use-label\n")
+
+    def test_msdos_fsuuid_too_long_rejected(self, tmp_path):
+        with pytest.raises(KickStartError):
+            _parse(tmp_path, "part / --source rootfs --fstype msdos "
+                             "--size 1M --fsuuid 0xDEADBEEF12\n")
+
+
+# ---------------------------------------------------------------------------
+# Multiple bootloaders
+# ---------------------------------------------------------------------------
+
+class TestParseBootloader:
+
+    def test_two_bootloaders_rejected(self, tmp_path):
+        with pytest.raises(KickStartError):
+            _parse(tmp_path, "bootloader --ptable gpt\nbootloader --ptable msdos\n")
+
+    def test_msdos_diskid_integer(self, tmp_path):
+        ks = _parse(tmp_path, "bootloader --ptable msdos --diskid 0x12345678\n")
+        assert ks.bootloader.diskid == 0x12345678
+
+    def test_gpt_bad_diskid_raises_kickstart_error(self, tmp_path):
+        with pytest.raises(KickStartError):
+            _parse(tmp_path, "bootloader --ptable gpt --diskid not-a-uuid\n")
+
+    def test_msdos_bad_diskid_raises_kickstart_error(self, tmp_path):
+        # A non-integer --diskid on an msdos ptable must raise a described
+        # KickStartError, not an opaque AttributeError from the error path
+        # interpolating a missing attribute.
+        with pytest.raises(KickStartError) as excinfo:
+            _parse(tmp_path, "bootloader --ptable msdos --diskid not-an-int\n")
+        # The message names the offending value so the user can see what
+        # was rejected.
+        assert "not-an-int" in str(excinfo.value)
+
+    def test_msdos_bad_diskid_hex_string_rejected(self, tmp_path):
+        # int(x, 0) parses "0x..." but a stray non-hex digit still fails;
+        # the same described KickStartError path handles it.
+        with pytest.raises(KickStartError) as excinfo:
+            _parse(tmp_path, "bootloader --ptable msdos --diskid 0xZZ\n")
+        assert "0xZZ" in str(excinfo.value)
+
+    def test_gpt_diskid_uuid_parsed(self, tmp_path):
+        ks = _parse(
+            tmp_path,
+            "bootloader --ptable gpt "
+            "--diskid 12345678-1234-5678-1234-567812345678\n",
+        )
+        assert str(ks.bootloader.diskid) == \
+            "12345678-1234-5678-1234-567812345678"
+
+
+# ---------------------------------------------------------------------------
+# Include handling
+# ---------------------------------------------------------------------------
+
+class TestParseInclude:
+
+    def test_include_missing_file_rejected(self, tmp_path):
+        with pytest.raises(KickStartError):
+            _parse(tmp_path, "include does-not-exist.wks\n")
+
+    def test_include_pulls_in_partitions(self, tmp_path):
+        inc = tmp_path / "inc.wks"
+        inc.write_text("part / --source rootfs --fstype ext4 --size 5M\n")
+        ks = _parse(tmp_path, "include inc.wks\n")
+        assert len(ks.partitions) == 1
+        assert ks.partitions[0].size == 5 * 1024