"""Tests for esphome.build_helpers.ninja.""" from __future__ import annotations import os from pathlib import Path import subprocess import sys from unittest.mock import MagicMock, patch import pytest from esphome.build_helpers import ninja as ninja_helper from esphome.core import EsphomeError def test_find_ninja_prefers_path(tmp_path: Path) -> None: with ( patch("shutil.which", return_value=str(tmp_path / "ninja")), patch.object(ninja_helper, "_ninja_runs", return_value=True), ): assert ninja_helper.find_ninja() == tmp_path / "ninja" def test_find_ninja_falls_back_to_wheel(tmp_path: Path) -> None: """Without a PATH entry, the ninja PyPI wheel's binary is used.""" binary_name = "ninja.exe" if os.name == "nt" else "ninja" (tmp_path / binary_name).touch() wheel = MagicMock(BIN_DIR=str(tmp_path)) with ( patch("shutil.which", return_value=None), patch.dict(sys.modules, {"ninja": wheel}), ): assert ninja_helper.find_ninja() == tmp_path / binary_name def test_find_ninja_package_not_installed() -> None: """A missing ninja package raises the actionable message, not ImportError.""" with ( patch("shutil.which", return_value=None), patch.dict(sys.modules, {"ninja": None}), pytest.raises(EsphomeError, match="ninja not found"), ): ninja_helper.find_ninja() def test_find_ninja_missing_everywhere(tmp_path: Path) -> None: wheel = MagicMock(BIN_DIR=str(tmp_path)) with ( patch("shutil.which", return_value=None), patch.dict(sys.modules, {"ninja": wheel}), pytest.raises(EsphomeError, match="ninja not found"), ): ninja_helper.find_ninja() def test_escape_ninja_specials() -> None: assert ninja_helper.escape("a b:c$d") == "a$ b$:c$$d" def _q(tok: str) -> str: """The platform's shell_token quote wrapper (argv rule on Windows).""" return f'"{tok}"' if os.name == "nt" else f"'{tok}'" def test_quote_arg_windows_argv_rule() -> None: # Backslash runs double only before a quote (subprocess.list2cmdline rule) assert ninja_helper.quote_arg('-DX=a\\"b c') == '"-DX=a\\\\\\"b c"' assert ninja_helper.quote_arg("a b\\") == '"a b\\\\"' def test_shell_token_quotes_only_when_needed() -> None: assert ninja_helper.shell_token("-Os") == "-Os" assert ninja_helper.shell_token("-DP=C:\\x y") == _q("-DP=C:\\x y") assert ninja_helper.shell_token("plain", force=True) == _q("plain") def test_shell_token_quotes_shell_metacharacters() -> None: """Tokens like -DMASK=(1<<3) must not reach /bin/sh -c bare.""" assert ninja_helper.shell_token("-DMASK=(1<<3)") == _q("-DMASK=(1<<3)") assert ninja_helper.shell_token("-DX=a;b") == _q("-DX=a;b") assert ninja_helper.shell_token("-DX=$HOME") == _q("-DX=$$HOME") def test_shell_token_posix_roundtrips_through_sh() -> None: """Backslash runs, $, backticks, and quotes must reach the compiler exactly as lexed once ninja un-doubles $$ and /bin/sh strips quotes.""" if sys.platform == "win32": pytest.skip("POSIX sh quoting") for tok in ("-DP=a\\\\b", "-DX=$VAR", "-DY=`date`", "-DZ=it's", '-DC="q"'): quoted = ninja_helper.shell_token(tok).replace("$$", "$") out = subprocess.run( ["/bin/sh", "-c", f'printf "%s" {quoted}'], capture_output=True, text=True, check=True, ) assert out.stdout == tok def test_quote_path_force_quotes() -> None: assert ninja_helper.quote_path(Path("a b")) == _q("a b") assert ninja_helper.quote_path("simple") == _q("simple") def test_shell_token_empty_token_is_quoted() -> None: """An empty argv element must survive as an explicit pair of quotes.""" assert ninja_helper.shell_token("") == _q("") def test_find_ninja_probes_path_hit(tmp_path: Path) -> None: """A broken PATH shim falls back to the wheel instead of failing every build later.""" binary_name = "ninja.exe" if os.name == "nt" else "ninja" (tmp_path / binary_name).touch() wheel = MagicMock(BIN_DIR=str(tmp_path)) with ( patch("shutil.which", return_value="/broken/ninja"), patch.object(ninja_helper, "_ninja_runs", return_value=False), patch.dict(sys.modules, {"ninja": wheel}), ): assert ninja_helper.find_ninja() == tmp_path / binary_name def test_ninja_probe_failure_warns(caplog: pytest.LogCaptureFixture) -> None: with patch("esphome.framework_helpers.subprocess.run", side_effect=OSError("boom")): assert ninja_helper._ninja_runs("/broken/ninja") is False assert "failed to run" in caplog.text def test_ninja_probe_success() -> None: with patch("esphome.framework_helpers.subprocess.run") as mock_run: assert ninja_helper._ninja_runs("/usr/bin/ninja") is True assert mock_run.call_args.kwargs["close_fds"] is False def test_shell_token_windows_branch_uses_argv_rule() -> None: """The nt branch quotes with the CreateProcess argv rule (the ubuntu coverage run never takes it naturally).""" with patch.object(os, "name", "nt"): assert ninja_helper.shell_token("a b") == '"a b"' assert ninja_helper.shell_token("", force=True) == '""' @pytest.mark.parametrize("char", ["|", "\n", "\r"]) def test_escape_rejects_what_ninja_cannot_express(char: str) -> None: with pytest.raises(EsphomeError, match="a ninja build file cannot express"): ninja_helper.escape(f"src/a{char}b.cpp") def test_escape_keeps_a_hash() -> None: """A # is only a comment at the start of a ninja line.""" assert ninja_helper.escape("src/a#b c.cpp") == "src/a#b$ c.cpp" def test_absolute_tool_anchors_a_relative_path( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Anchor the path: ninja runs from the build directory.""" monkeypatch.chdir(tmp_path) assert Path(ninja_helper.absolute_tool("bin/ninja")) == tmp_path / "bin" / "ninja" def test_find_ninja_returns_an_absolute_path( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) with ( patch("shutil.which", return_value="bin/ninja"), patch.object(ninja_helper, "_ninja_runs", return_value=True), ): assert ninja_helper.find_ninja() == tmp_path / "bin" / "ninja" @pytest.fixture def compdb_dir(tmp_path: Path) -> Path: build_dir = tmp_path / "build" build_dir.mkdir() (build_dir / "build.ninja").write_text("rule x\n") return build_dir def test_refresh_compile_commands_regenerates_when_stale(compdb_dir: Path) -> None: ninja = Path("ninja") compdb = compdb_dir / "compile_commands.json" stamp = compdb_dir / ".compile_commands.stamp" with patch.object(ninja_helper, "write_compile_commands") as write: # A rewritten manifest always regenerates ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, True) assert write.call_count == 1 assert stamp.is_file() # No compile DB yet ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False) assert write.call_count == 2 compdb.write_text("[]") # Fresh stamp: nothing to do ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False) assert write.call_count == 2 # A manifest newer than the stamp (interrupted previous run) os.utime(stamp, (1, 1)) ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False) assert write.call_count == 3 # A missing stamp regenerates too stamp.unlink() ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False) assert write.call_count == 4 def _completed(rc: int = 0, stdout: str = "", stderr: str = "") -> MagicMock: return MagicMock(returncode=rc, stdout=stdout, stderr=stderr) def test_write_compile_commands_success(compdb_dir: Path) -> None: entries = '[{"file": "a.cpp", "command": "g++ -c a.cpp"}]' with patch("subprocess.run", return_value=_completed(stdout=entries)) as run: ninja_helper.write_compile_commands(Path("ninja"), compdb_dir, {"A": "1"}) assert (compdb_dir / "compile_commands.json").read_text() == entries # Every compile rule, so preprocessed assembly reaches the database too assert run.call_args.args[0] == [ "ninja", "-C", str(compdb_dir), "-t", "compdb", "c", "cxx", "aspp", "asm", ] assert run.call_args.kwargs["env"] == {"A": "1"} @pytest.mark.parametrize( ("result", "message"), [ (_completed(rc=1, stderr="boom"), "Could not generate compile_commands.json"), # A parse failure names its cause, not the rule-name story (_completed(stdout="not json"), "unparsable compile database.*not json"), (_completed(stdout="[]"), "empty compile database"), ], ) def test_write_compile_commands_failures_drop_stale_db( compdb_dir: Path, result: MagicMock, message: str ) -> None: compdb = compdb_dir / "compile_commands.json" compdb.write_text("[stale]") with ( patch("subprocess.run", return_value=result), pytest.raises(EsphomeError, match=message), ): ninja_helper.write_compile_commands(Path("ninja"), compdb_dir, {}) assert not compdb.exists() def test_write_compile_commands_keeps_the_mtime_of_an_unchanged_db( compdb_dir: Path, ) -> None: """The idedata cache is keyed on the DB's mtime.""" entries = '[{"file": "a.cpp", "command": "g++ -c a.cpp"}]' compdb = compdb_dir / "compile_commands.json" compdb.write_text(entries) os.utime(compdb, (1000, 1000)) with patch("subprocess.run", return_value=_completed(stdout=entries)): ninja_helper.write_compile_commands(Path("ninja"), compdb_dir, {}) assert compdb.stat().st_mtime == 1000