Files
kaptan/scripts/test-make-argv.py
T
Onur Özkan 7dc3ccb619
Host SITL smoke / Deterministic Gazebo command path (push) Has been cancelled
build: add directory-oriented make workflows
2026-08-30 07:05:01 +03:00

313 lines
10 KiB
Python

#!/usr/bin/env python3
"""Tests for exact argument forwarding through GNU Make."""
import importlib.util
import io
import json
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
SPEC = importlib.util.spec_from_file_location(
"make_argv", Path(__file__).with_name("make-argv.py")
)
assert SPEC is not None and SPEC.loader is not None
MAKE_ARGV = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MAKE_ARGV)
class ArgumentEncodingTests(unittest.TestCase):
def test_round_trip_preserves_exact_arguments(self):
arguments = [
"--key=one",
"two words",
"",
"--key=one",
"$HOME",
"--",
"--key=two",
]
self.assertEqual(
MAKE_ARGV.decode_arguments(MAKE_ARGV.encode_arguments(arguments)),
arguments,
)
def test_rejects_invalid_payload(self):
with self.assertRaisesRegex(
MAKE_ARGV.ArgumentRecoveryError, "invalid application argument payload"
):
MAKE_ARGV.decode_arguments("not-base64!")
@mock.patch.object(
MAKE_ARGV, "execute", side_effect=FileNotFoundError(2, "missing", "tool")
)
def test_missing_application_has_a_concise_error(self, _execute):
errors = io.StringIO()
with mock.patch("sys.stderr", errors):
status = MAKE_ARGV.main(["exec", "run", "tool"])
self.assertEqual(status, 127)
self.assertEqual(errors.getvalue(), "make-argv: cannot execute tool: missing\n")
class RouteSelectionTests(unittest.TestCase):
def test_delimiter_makes_test_an_application_argument(self):
target, arguments = MAKE_ARGV.select_route(
["make", "firmware", "--", "test"],
"firmware",
"run",
{"test", "clippy"},
)
self.assertEqual(target, "run")
self.assertEqual(arguments, ["test"])
def test_no_delimiter_selects_a_known_local_target(self):
target, arguments = MAKE_ARGV.select_route(
["make", "firmware", "test"],
"firmware",
"run",
{"test", "clippy"},
)
self.assertEqual(target, "test")
self.assertEqual(arguments, [])
def test_configurable_no_argument_default(self):
target, arguments = MAKE_ARGV.select_route(
["make", "docs"], "docs", "check", {"help"}
)
self.assertEqual(target, "check")
self.assertEqual(arguments, [])
def test_explicit_docs_arguments_select_run_not_no_argument_default(self):
target, arguments = MAKE_ARGV.select_route(
["make", "docs", "--", "--version"], "docs", "check", {"help"}
)
self.assertEqual(target, "run")
self.assertEqual(arguments, ["--version"])
def test_unknown_words_without_delimiter_remain_application_arguments(self):
target, arguments = MAKE_ARGV.select_route(
["make", "firmware/tools/sitl", "move", "5", "two words"],
"firmware/tools/sitl",
"run",
{"help"},
)
self.assertEqual(target, "run")
self.assertEqual(arguments, ["move", "5", "two words"])
def test_local_flash_keeps_every_argument_after_delimiter(self):
target, arguments = MAKE_ARGV.select_route(
[
"make",
"firmware/boards/example",
"flash",
"--",
"--device=0483:df11",
"",
"--",
"$HOME",
],
"firmware/boards/example",
"run",
{"flash", "help"},
)
self.assertEqual(target, "flash")
self.assertEqual(arguments, ["--device=0483:df11", "", "--", "$HOME"])
class EnvironmentTests(unittest.TestCase):
def test_scrubs_make_flags_and_command_line_assignments(self):
environment = {
"PATH": "/bin",
"MAKEFLAGS": "-- CARGO=wrong",
"MAKEOVERRIDES": "CARGO=wrong",
"MFLAGS": "--",
"CARGO": "wrong",
"OTHER": "keep",
}
clean = MAKE_ARGV.scrub_make_environment(
environment,
["make", "firmware", "--", "CARGO=wrong", "--key=value"],
)
self.assertEqual(clean, {"PATH": "/bin", "OTHER": "keep"})
def test_preserves_make_execution_flags_without_overrides(self):
clean = MAKE_ARGV.scrub_make_environment(
{
"MAKEFLAGS": "n --no-print-directory -- --key=value",
"MFLAGS": "-n -- --key=value",
"MAKEOVERRIDES": "--key=value",
},
["make", "firmware", "--", "--key=value"],
)
self.assertEqual(
clean,
{"MAKEFLAGS": "n --no-print-directory", "MFLAGS": "-n"},
)
@mock.patch.object(MAKE_ARGV.os, "execvpe")
@mock.patch.object(MAKE_ARGV, "find_make_arguments")
def test_route_execs_recursive_make_with_encoded_payload(
self, find_make_arguments, execvpe
):
payload = ["--key=one", "two words", "", "--", "--key=one"]
find_make_arguments.return_value = [
"make",
"firmware/tools/sitl",
"--",
*payload,
]
with mock.patch.dict(
MAKE_ARGV.os.environ,
{"PATH": "/bin", "MAKEFLAGS": "n -- --key=one"},
clear=True,
):
MAKE_ARGV.route("firmware/tools/sitl", "make", "run", {"help"})
program, arguments, environment = execvpe.call_args.args
self.assertEqual(program, "make")
self.assertEqual(
arguments,
[
"make",
"--no-print-directory",
"-C",
"firmware/tools/sitl",
"run",
],
)
self.assertEqual(
MAKE_ARGV.decode_arguments(environment[MAKE_ARGV.PAYLOAD_ENV]), payload
)
self.assertEqual(environment[MAKE_ARGV.ARG_COUNT_ENV], str(len(payload)))
self.assertEqual(environment["MAKEFLAGS"], "n")
@mock.patch.object(MAKE_ARGV.os, "execvpe")
def test_exec_uses_routed_payload_without_shell_joining(self, execvpe):
payload = ["--key=one", "two words", "", "$HOME", "--", "--key=one"]
environment = {
MAKE_ARGV.PAYLOAD_ENV: MAKE_ARGV.encode_arguments(payload),
MAKE_ARGV.ARG_COUNT_ENV: str(len(payload)),
"PATH": "/bin",
"MAKEFLAGS": "-- --key=one",
}
with mock.patch.dict(MAKE_ARGV.os.environ, environment, clear=True):
MAKE_ARGV.execute("run", ["example-app", "fixed argument"])
program, arguments, child_environment = execvpe.call_args.args
self.assertEqual(program, "example-app")
self.assertEqual(arguments, ["example-app", "fixed argument", *payload])
self.assertEqual(child_environment, {"PATH": "/bin"})
@mock.patch.object(MAKE_ARGV.os, "execvpe")
@mock.patch.object(MAKE_ARGV, "find_make_arguments")
def test_direct_local_make_exec_recovers_arguments(
self, find_make_arguments, execvpe
):
payload = ["--key=value", "two words", "", "--", "$HOME"]
find_make_arguments.return_value = ["make", "run", "--", *payload]
with mock.patch.dict(MAKE_ARGV.os.environ, {"PATH": "/bin"}, clear=True):
MAKE_ARGV.execute("run", ["example-app"])
self.assertEqual(execvpe.call_args.args[1], ["example-app", *payload])
class ProcfsTests(unittest.TestCase):
def test_procfs_preserves_a_final_empty_argument(self):
with tempfile.TemporaryDirectory() as directory:
process_directory = Path(directory, "123")
process_directory.mkdir()
Path(process_directory, "cmdline").write_bytes(b"make\0run\0\0")
self.assertEqual(
MAKE_ARGV.read_process_arguments(123, Path(directory)),
["make", "run", ""],
)
def test_procfs_failure_is_actionable(self):
with tempfile.TemporaryDirectory() as directory:
with self.assertRaisesRegex(
MAKE_ARGV.ArgumentRecoveryError, "requires Linux /proc"
):
MAKE_ARGV.read_process_arguments(123, Path(directory))
@unittest.skipUnless(Path("/proc/self/cmdline").is_file(), "requires Linux procfs")
class MakeIntegrationTests(unittest.TestCase):
def test_recursive_make_preserves_exact_application_argv(self):
make = shutil.which("make")
if make is None:
self.skipTest("GNU Make is unavailable")
helper = Path(__file__).with_name("make-argv.py").resolve()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
component = root / "component"
component.mkdir()
output = root / "arguments.json"
application = root / "record-arguments.py"
application.write_text(
"import json, pathlib, sys\n"
"pathlib.Path(sys.argv[1]).write_text(json.dumps(sys.argv[2:]))\n",
encoding="utf-8",
)
(root / "Makefile").write_text(
".PHONY: component\n"
"component:\n"
f"\t+@exec {sys.executable} {helper} route component make run help\n"
".DEFAULT:\n"
"\t@:\n",
encoding="utf-8",
)
(component / "Makefile").write_text(
".PHONY: run\n"
"run:\n"
f"\t@exec {sys.executable} {helper} exec run "
f"{sys.executable} {application} {output}\n"
".DEFAULT:\n"
"\t@:\n",
encoding="utf-8",
)
payload = [
"--key=one",
"two words",
"",
"--key=one",
"$HOME",
"--",
"--key=two",
]
subprocess.run(
[make, "component", "--", *payload],
cwd=root,
check=True,
capture_output=True,
text=True,
)
self.assertEqual(json.loads(output.read_text(encoding="utf-8")), payload)
if __name__ == "__main__":
unittest.main()