"""Configuration validation: valid examples load, anything unknown or unimplemented is rejected clearly.""" import tempfile import unittest from collections.abc import Callable from pathlib import Path from typing import cast from platform_ci.configuration import ConfigurationError, Report, load_project_configuration, parse_project_configuration from tests.support import EXAMPLES_DIRECTORY, example_configuration, example_mapping Mapping = dict[str, object] def _library(configuration: Mapping) -> Mapping: """Return the Maven example's component mapping for in-place mutation.""" return cast("Mapping", cast("Mapping", configuration["components"])["library"]) def _snapshot_channel(configuration: Mapping) -> Mapping: """Return the Maven example's release channel mapping for in-place mutation.""" return cast("Mapping", cast("Mapping", cast("Mapping", configuration["release"])["channels"])["snapshot"]) def _rename_component(configuration: Mapping) -> None: """Rename the component to an invalid identifier.""" components = cast("Mapping", configuration["components"]) components["Library"] = components.pop("library") def _use_commands(configuration: Mapping, commands: object) -> None: """Replace the legacy build arguments with a `commands` value.""" build = cast("Mapping", _library(configuration)["build"]) del build["arguments"] build["commands"] = commands def _set_reports(configuration: Mapping, reports: Mapping) -> None: """Replace the component's reports.""" _library(configuration)["reports"] = reports REJECTED_CONFIGURATIONS: dict[str, tuple[Callable[[Mapping], object], str]] = { "unknown root field": (lambda c: c.update(unexpected=1), "configuration.unexpected: unsupported field"), "other schema version": (lambda c: c.update(schema=2), "only schema 1 is supported"), "artifact source": (lambda c: cast("Mapping", cast("Mapping", _library(c)["artifacts"])["packages"]).update(source="reactor-outputs"), "source: described by the approved design but not implemented"), "unknown release versioning rule": (lambda c: _snapshot_channel(c).update(versioning="whatever"), "unsupported rule 'whatever'"), "unsupported artifact format": (lambda c: cast("Mapping", cast("Mapping", _library(c)["artifacts"])["packages"]).update(format="rpm"), "unsupported artifact format 'rpm'"), "skip tests setting": (lambda c: _library(c).update(skip_tests=True), "components.library.skip_tests: unsupported field"), "unsupported build tool": (lambda c: cast("Mapping", _library(c)["build"]).update(tool="bazel"), "unsupported build tool 'bazel'"), "unquoted toolchain version": (lambda c: cast("Mapping", _library(c)["toolchains"]).update(java=21), "expected a non-empty quoted string"), "toolchain of another tool": (lambda c: _library(c).update(toolchains={"node": "22"}), "components.library.toolchains.node: unsupported field"), "directory escaping the repository": (lambda c: _library(c).update(directory="../elsewhere"), "expected a relative path inside the repository"), "unsupported platform": (lambda c: _library(c).update(platforms=["windows-amd64"]), "unsupported platform 'windows-amd64'"), "empty build arguments": (lambda c: cast("Mapping", _library(c)["build"]).update(arguments=[]), "expected a non-empty list"), "arguments and commands together": (lambda c: cast("Mapping", _library(c)["build"]).update(commands=[["verify"]]), "mutually exclusive"), "neither arguments nor commands": (lambda c: cast("Mapping", _library(c)["build"]).pop("arguments"), "one of arguments or commands is required"), "empty commands": (lambda c: _use_commands(c, []), "commands: expected a non-empty list"), "empty command": (lambda c: _use_commands(c, cast("list[list[str]]", [[]])), "commands[0]: expected a non-empty list"), "flat commands list": (lambda c: _use_commands(c, ["verify"]), "commands[0]: expected a non-empty list"), "non-text command argument": (lambda c: _use_commands(c, [["verify", 1]]), "commands[0][1]: expected a non-empty quoted string"), "executable field": (lambda c: cast("Mapping", _library(c)["build"]).update(executable="sh"), "build.executable: unsupported field"), "report of unknown format": (lambda c: _set_reports(c, {"junit": {"format": "xunit", "paths": ["a.xml"]}}), "unsupported report format 'xunit'"), "report without format": (lambda c: _set_reports(c, {"junit": {"paths": ["a.xml"]}}), "reports.junit.format: required field is missing"), "report without paths": (lambda c: _set_reports(c, {"junit": {"format": "junit"}}), "reports.junit.paths: required field is missing"), "report with empty paths": (lambda c: _set_reports(c, {"junit": {"format": "junit", "paths": []}}), "paths: expected a non-empty list"), "report with escaping path": (lambda c: _set_reports(c, {"junit": {"format": "junit", "paths": ["../a.xml"]}}), "expected a relative path inside the repository"), "report required not boolean": (lambda c: _set_reports(c, {"junit": {"format": "junit", "paths": ["a.xml"], "required": "no"}}), "required: expected true or false"), "report unknown field": (lambda c: _set_reports(c, {"junit": {"format": "junit", "paths": ["a.xml"], "optional": True}}), "reports.junit.optional: unsupported field"), "report neither string nor mapping": (lambda c: _set_reports(c, {"junit": 5}), "reports.junit: expected a mapping"), "missing build": (lambda c: _library(c).pop("build"), "components.library.build: required field is missing"), "invalid component name": (_rename_component, "names are lowercase letters"), "release branch glob with a character class": (lambda c: cast("Mapping", _snapshot_channel(c)["trigger"]).update(push_branches=["release/[ab]"]), "expected a branch name or a glob using only * and **"), "verification branch glob with a character class": (lambda c: c.update(verification={"push": {"branches": ["release/[ab]"]}}), "verification.push.branches: expected a branch name or a glob using only * and **"), "verification branch glob with a run of stars": (lambda c: c.update(verification={"push": {"branches": ["release/***"]}}), "verification.push.branches: expected a branch name or a glob using only * and **"), "verification tag character class": (lambda c: c.update(verification={"push": {"tags": ["v[0-9]"]}}), "verification.push.tags: expected a tag name"), "verification unknown field": (lambda c: c.update(verification={"push": {"skip": True}}), "verification.push.skip: unsupported field"), "verification pull request switch": (lambda c: c.update(verification={"pull_request": False}), "verification.pull_request: unsupported field"), "verification branches not a list": (lambda c: c.update(verification={"push": {"branches": "main"}}), "expected a list"), } class ConfigurationValidationTest(unittest.TestCase): """Strict validation of `.ci.yaml`.""" def test_examples_are_valid(self) -> None: """Both shipped examples validate and describe what they claim.""" self.assertEqual(example_configuration("maven.ci.yaml").components[0].build_tool, "maven") self.assertEqual(example_configuration("node.ci.yaml").components[0].build_tool, "node") def test_legacy_arguments_normalize_to_one_command(self) -> None: """`build.arguments` becomes a single canonical command; `build.commands` keeps its order.""" self.assertEqual(example_configuration("maven.ci.yaml").components[0].build_commands, (("--batch-mode", "verify"),)) self.assertEqual(example_configuration("node-commands.ci.yaml").components[0].build_commands, (("run", "lint"), ("test",), ("run", "build"))) def test_legacy_reports_normalize_to_typed_reports(self) -> None: """A `junit` string is a required junit report; any other name is a required file report; objects keep their fields.""" legacy = example_configuration("maven.ci.yaml").components[0].reports self.assertEqual(legacy, {"junit": Report("junit", ("**/target/surefire-reports/*.xml",), required=True)}) mapping = example_mapping("maven.ci.yaml") _set_reports(mapping, {"coverage": "target/jacoco.xml"}) self.assertEqual(parse_project_configuration(mapping).components[0].reports["coverage"], Report("file", ("target/jacoco.xml",), required=True)) explicit = example_configuration("maven-reports.ci.yaml").components[0].reports self.assertEqual(explicit["junit"], Report("junit", ("**/target/surefire-reports/*.xml", "**/target/failsafe-reports/*.xml"), required=False)) self.assertTrue(explicit["coverage"].required) def test_push_selection_defaults_and_examples(self) -> None: """Omitting `verification` selects no extra branches and every tag; the examples list their selections explicitly.""" mapping = example_mapping("maven.ci.yaml") del mapping["verification"] defaults = parse_project_configuration(mapping).push self.assertEqual((defaults.branches, defaults.tags), ((), ("*",))) self.assertEqual(example_configuration("maven.ci.yaml").push.branches, ("staging",)) def test_unknown_or_unimplemented_configuration_is_rejected(self) -> None: """Each invalid or unimplemented request fails with a message naming the field.""" for description, (mutate, expected_message) in REJECTED_CONFIGURATIONS.items(): with self.subTest(description): configuration = example_mapping("maven.ci.yaml") mutate(configuration) with self.assertRaises(ConfigurationError) as raised: parse_project_configuration(configuration) self.assertIn(expected_message, str(raised.exception)) def test_duplicate_keys_are_rejected_instead_of_keeping_the_last_value(self) -> None: """A repeated key, at the root or nested, fails naming the key; the earlier value is never silently dropped.""" valid_text = (EXAMPLES_DIRECTORY / "maven.ci.yaml").read_text(encoding="utf-8") duplicated = { "root key": valid_text + "schema: 1\n", "nested key": valid_text.replace(' java: "21"\n', ' java: "17"\n java: "21"\n'), } for description, content in duplicated.items(): with self.subTest(description), tempfile.TemporaryDirectory() as directory: Path(directory, ".ci.yaml").write_text(content, encoding="utf-8") with self.assertRaisesRegex(ConfigurationError, "duplicate key"): load_project_configuration(Path(directory)) def test_missing_empty_and_malformed_files_are_errors(self) -> None: """A repository without usable configuration never passes as an empty build.""" for description, content in {"missing": None, "empty": "", "malformed": "components: [unclosed"}.items(): with self.subTest(description), tempfile.TemporaryDirectory() as directory: if content is not None: Path(directory, ".ci.yaml").write_text(content, encoding="utf-8") with self.assertRaises(ConfigurationError): load_project_configuration(Path(directory))