diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index f6c0df8eb..ec883df67 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -297,6 +297,7 @@ jobs: python3 -B test/test_full_logging_compile.py python3 -B test/test_full_logging_resume.py python3 -B test/test_firmware_capabilities.py + MESHCORE_TEST_REAL_PIO_PATHS=1 python3 -B test/test_esp32_partition_migration_build_dir.py -v bash test/test_build_profiles.sh bash test/test_full_infrastructure_selection.sh bash test/test_nrf52_ota_profiles.sh diff --git a/scripts/build_esp32_partition_migration.py b/scripts/build_esp32_partition_migration.py index ce4f85719..d719b25c5 100644 --- a/scripts/build_esp32_partition_migration.py +++ b/scripts/build_esp32_partition_migration.py @@ -20,7 +20,7 @@ import sys import tempfile import zipfile -from package_esp32_partition_migration import BOARDS +from package_esp32_partition_migration import BOARDS, resolve_pio_build_dir ROOT = Path(__file__).resolve().parents[1] @@ -184,12 +184,18 @@ def main() -> None: if not shutil.which(executable): parser.error(f"{executable} is unavailable in this environment") + environment = os.environ.copy() + try: + utility_build_dir = resolve_pio_build_dir(environment=environment, project_dir=ROOT) + except ValueError as error: + parser.error(str(error)) + environment["PLATFORMIO_BUILD_DIR"] = str(utility_build_dir) output_root.mkdir(parents=True, exist_ok=True) for command, full in steps: - environment = os.environ.copy() + build_environment = environment.copy() if full: - environment["OUTPUT_DIR"] = str(build_dir) - subprocess.run(command, cwd=ROOT, env=environment, check=True) + build_environment["OUTPUT_DIR"] = str(build_dir) + subprocess.run(command, cwd=ROOT, env=build_environment, check=True) # Package in a fresh directory so an interruption never leaves a partial # release ZIP at the final path. Existing identical ZIPs are reusable. @@ -198,11 +204,12 @@ def main() -> None: command = [ sys.executable, "-B", "scripts/package_esp32_partition_migration.py", "--build-dir", str(build_dir), "--output-dir", str(staging), + "--pio-build-dir", str(utility_build_dir), "--version", args.version, "--source", source, ] for name in boards: command.extend(("--board", name)) - subprocess.run(command, cwd=ROOT, check=True) + subprocess.run(command, cwd=ROOT, env=environment, check=True) archives = sorted(staging.glob("*.zip")) if len(archives) != len(boards): raise ValueError("packager did not produce one ZIP per selected board") diff --git a/scripts/build_local_release.py b/scripts/build_local_release.py index c2f4fb8f4..a738d5cf4 100644 --- a/scripts/build_local_release.py +++ b/scripts/build_local_release.py @@ -16,7 +16,7 @@ import sys import tempfile from build_esp32_partition_migration import bundle_release, verify_archive -from package_esp32_partition_migration import BOARDS +from package_esp32_partition_migration import BOARDS, resolve_pio_build_dir from package_cascade_release import category, collect_artifacts, nrf52_sensor_profile @@ -66,7 +66,12 @@ def migration_bridges() -> list[str]: def build_migration_artifacts(work: Path, version: str, short_source: str, - jobs: int, environment: dict[str, str]) -> None: + jobs: int, environment: dict[str, str], + pio_build_dir: Path | None = None) -> None: + utility_build_dir = resolve_pio_build_dir( + pio_build_dir, environment=environment, project_dir=ROOT) + environment = environment.copy() + environment["PLATFORMIO_BUILD_DIR"] = str(utility_build_dir) for target in migration_targets(): matches = list(work.glob(full_image_pattern(target, version, short_source))) if len(matches) == 1: @@ -141,9 +146,12 @@ def select_ordinary_full_records(records: list[dict]) -> list[dict]: def stage_release(work: Path, migration_work: Path, destination: Path, version: str, - source: str) -> None: + source: str, pio_build_dir: Path | None = None) -> None: if destination.exists(): raise FileExistsError(f"release already exists: {destination}") + utility_build_dir = resolve_pio_build_dir(pio_build_dir, project_dir=ROOT) + package_environment = os.environ.copy() + package_environment["PLATFORMIO_BUILD_DIR"] = str(utility_build_dir) destination.parent.mkdir(parents=True, exist_ok=True) short_source = source[:8] tooling_source = git("rev-parse", "HEAD") @@ -180,11 +188,12 @@ def stage_release(work: Path, migration_work: Path, destination: Path, version: with tempfile.TemporaryDirectory(prefix="migration-package-", dir=staging) as temp_packages: package_command = [sys.executable, "-B", "scripts/package_esp32_partition_migration.py", "--build-dir", str(migration_work), "--output-dir", temp_packages, + "--pio-build-dir", str(utility_build_dir), "--version", version, "--source", short_source] package_log = work / "migration-packaging.log" with package_log.open("w", encoding="utf-8") as output: result = subprocess.run(package_command, cwd=ROOT, stdout=output, - stderr=subprocess.STDOUT) + stderr=subprocess.STDOUT, env=package_environment) if result.returncode: raise RuntimeError(f"migration packaging failed; see {package_log}") for board in BOARDS: @@ -258,8 +267,13 @@ def main() -> None: print(f"Output: {destination}", flush=True) if args.dry_run: return - work.mkdir(parents=True, exist_ok=True) environment = os.environ.copy() + try: + utility_build_dir = resolve_pio_build_dir(environment=environment, project_dir=ROOT) + except ValueError as error: + parser.error(str(error)) + environment["PLATFORMIO_BUILD_DIR"] = str(utility_build_dir) + work.mkdir(parents=True, exist_ok=True) environment["OUTPUT_DIR"] = str(work) run_logged([ "bash", "build_legacy.sh", "build-firmwares-logging-matrix", @@ -274,8 +288,9 @@ def main() -> None: migration_environment = environment.copy() migration_environment["OUTPUT_DIR"] = str(migration_work) build_migration_artifacts(migration_work, args.firmware_version, short_source, - args.pio_jobs, migration_environment) - stage_release(work, migration_work, destination, args.firmware_version, source) + args.pio_jobs, migration_environment, utility_build_dir) + stage_release(work, migration_work, destination, args.firmware_version, source, + utility_build_dir) if __name__ == "__main__": diff --git a/scripts/package_esp32_partition_migration.py b/scripts/package_esp32_partition_migration.py index 269d1b8fa..444a20324 100644 --- a/scripts/package_esp32_partition_migration.py +++ b/scripts/package_esp32_partition_migration.py @@ -6,8 +6,10 @@ from __future__ import annotations import argparse import hashlib import json +import os from pathlib import Path import struct +import subprocess import sys import zipfile @@ -279,6 +281,56 @@ def sha256(data: bytes) -> str: return hashlib.sha256(data).hexdigest() +def resolve_pio_build_dir(pio_build_dir: Path | None = None, *, + environment: dict[str, str] | None = None, + project_dir: Path | None = None) -> Path: + """Use PlatformIO's resolved directory, never an unrelated default tree.""" + root = (ROOT if project_dir is None else project_dir).resolve() + try: + if pio_build_dir is not None: + selected = str(pio_build_dir) + else: + metadata_environment = (os.environ if environment is None else environment).copy() + # Expose this defaulted option in project-config JSON without + # overriding a configured workspace/build_dir. PIO ignores an + # empty environment override but enumerates its option name. + metadata_environment.setdefault("PLATFORMIO_BUILD_DIR", "") + result = subprocess.run( + ["pio", "project", "config", "--json-output", "--project-dir", str(root)], + cwd=root, env=metadata_environment, capture_output=True, text=True, + check=True, timeout=30, + ) + if result.returncode: + raise ValueError("metadata query failed") + options = json.loads(result.stdout) + if (not isinstance(options, list) + or any(not isinstance(entry, list) or len(entry) != 2 + or not isinstance(entry[0], str) + or not isinstance(entry[1], list) for entry in options)): + raise ValueError("invalid metadata") + sections = [entry for entry in options if entry[0] == "platformio"] + if len(sections) != 1: + raise ValueError("missing or duplicate platformio section") + if any(not isinstance(entry, list) or len(entry) != 2 + or not isinstance(entry[0], str) for entry in sections[0][1]): + raise ValueError("invalid platformio options") + directories = [entry[1] for entry in sections[0][1] + if entry[0] == "build_dir"] + if len(directories) != 1: + raise ValueError("missing or duplicate build directory") + selected = directories[0] + if (not isinstance(selected, str) or not selected.strip() + or "\0" in selected or "${" in selected): + raise ValueError("invalid build directory") + path = Path(selected).expanduser() + return (path if path.is_absolute() else root / path).resolve() + except (OSError, RuntimeError, ValueError, TypeError, + subprocess.SubprocessError): + # Config JSON and command diagnostics can contain WiFi credentials. + # Never include their contents in the public error. + raise ValueError("cannot resolve the PlatformIO build directory") from None + + def partition_entries(table: bytes) -> dict[str, tuple[int, int]]: entries = {} for offset in range(0, len(table) - 31, 32): @@ -483,7 +535,9 @@ example of the same board first. def package_board(name: str, spec: dict, build_dir: Path, output_dir: Path, - version: str, source: str) -> Path: + version: str, source: str, + pio_build_dir: Path | None = None) -> Path: + utility_build_dir = resolve_pio_build_dir(pio_build_dir) target = spec["target"] target_id = target_id_for_env(target) hw_id = hardware_id_for_env(target) @@ -520,7 +574,7 @@ def package_board(name: str, spec: dict, build_dir: Path, output_dir: Path, or full_ident.hw_id != hw_id or len(full_image) >= slot_bytes): raise ValueError(f"{name}: Full image identity or partition fit failed") - wifi_bridge = (ROOT / ".pio" / "build" / spec["wifi_bridge"] / "firmware.bin").read_bytes() + wifi_bridge = (utility_build_dir / spec["wifi_bridge"] / "firmware.bin").read_bytes() bridge_ident = FwIdent(pack_version(version.split("-", 1)[0]), target_id, hw_id) if len(wifi_bridge) > LEGACY_SLOT_BYTES: raise ValueError(f"{name}: bridge does not fit the 1.25 MiB legacy slot") @@ -534,7 +588,7 @@ def package_board(name: str, spec: dict, build_dir: Path, output_dir: Path, "capabilities.json": capability_path.read_bytes(), } if spec.get("expander_bridge"): - expander_body = (ROOT / ".pio" / "build" / spec["expander_bridge"] / + expander_body = (utility_build_dir / spec["expander_bridge"] / "firmware.bin").read_bytes() expander_image = bridge_with_successor_endf(expander_body, bridge_ident) if len(expander_image) > LEGACY_SLOT_BYTES: @@ -556,7 +610,7 @@ def package_board(name: str, spec: dict, build_dir: Path, output_dir: Path, elif spec["lora_bridge"]: raise ValueError(f"{name}: LoRa Full image exceeds the mOTA block limit") if spec["lora_bridge"]: - lora_bridge_body = (ROOT / ".pio" / "build" / spec["lora_bridge"] / "firmware.bin").read_bytes() + lora_bridge_body = (utility_build_dir / spec["lora_bridge"] / "firmware.bin").read_bytes() lora_bridge = bridge_with_successor_endf(lora_bridge_body, bridge_ident) if len(lora_bridge) > LEGACY_SLOT_BYTES: raise ValueError(f"{name}: LoRa bridge does not fit the legacy slot") @@ -603,18 +657,25 @@ def package_board(name: str, spec: dict, build_dir: Path, output_dir: Path, def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--build-dir", type=Path, required=True) + parser.add_argument("--pio-build-dir", type=Path, + help="explicit utility build tree (default: actual resolved PlatformIO " + "build directory; relative paths use the project root)") parser.add_argument("--output-dir", type=Path, required=True) parser.add_argument("--version", required=True) parser.add_argument("--source", required=True, help="eight-character source commit") parser.add_argument("--board", action="append", choices=tuple(BOARDS), help="board to package; repeat for multiple boards (default: all)") args = parser.parse_args() + try: + utility_build_dir = resolve_pio_build_dir(args.pio_build_dir) + except ValueError as error: + parser.error(str(error)) if args.output_dir.exists() and any(args.output_dir.iterdir()): parser.error("output directory must be empty") args.output_dir.mkdir(parents=True, exist_ok=True) selected = dict.fromkeys(args.board or BOARDS) archives = [package_board(name, BOARDS[name], args.build_dir, args.output_dir, - args.version, args.source) + args.version, args.source, utility_build_dir) for name in selected] for archive in archives: print(f"{archive}: {archive.stat().st_size} bytes") diff --git a/test/test_esp32_partition_migration_build_dir.py b/test/test_esp32_partition_migration_build_dir.py new file mode 100644 index 000000000..50af9946e --- /dev/null +++ b/test/test_esp32_partition_migration_build_dir.py @@ -0,0 +1,661 @@ +"""Package real qualified fixtures from the selected PlatformIO utility tree.""" + +from contextlib import contextmanager +from contextlib import redirect_stderr, redirect_stdout +import hashlib +import io +import json +import os +from pathlib import Path +import struct +import subprocess +import sys +import tempfile +import unittest +from unittest import mock +import zipfile + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +import package_esp32_partition_migration as package # noqa: E402 +from build_esp32_partition_migration import verify_archive # noqa: E402 +from motalib import ( # noqa: E402 + FwIdent, ensure_endf, hardware_id_for_env, pack_version, parse_container, + parse_endf, parse_endf_ident, target_id_for_env, verify, +) + + +VERSION = "v1.17.1.9-test" +SOURCE = "01234567" +UTILITY_KEYS = ("wifi_bridge", "expander_bridge", "lora_bridge") + + +@contextmanager +def platformio_build_dir(value): + previous = os.environ.get("PLATFORMIO_BUILD_DIR") + try: + if value is None: + os.environ.pop("PLATFORMIO_BUILD_DIR", None) + else: + os.environ["PLATFORMIO_BUILD_DIR"] = str(value) + yield + finally: + if previous is None: + os.environ.pop("PLATFORMIO_BUILD_DIR", None) + else: + os.environ["PLATFORMIO_BUILD_DIR"] = previous + + +class MigrationBuildDirTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="migration-build-tree-") + self.addCleanup(self.temporary.cleanup) + self.work = Path(self.temporary.name) + self.project = self.work / "project with spaces" + self.project.mkdir() + previous_root = package.ROOT + self.addCleanup(setattr, package, "ROOT", previous_root) + package.ROOT = self.project + self.artifacts = self.work / "qualified artifacts" + self.artifacts.mkdir() + self.output = self.work / "packages" + self.default = self.project / ".pio/build" + self.custom = self.work / "current utility builds" + # Exercise an actual metadata subprocess, never a PlatformIO process. + # This boundary accepts only the exact read-only query used by the + # resolver; firmware builds and all other PIO commands are forbidden. + metadata_bin = self.work / "metadata-bin"; metadata_bin.mkdir() + metadata_pio = metadata_bin / "pio" + metadata_pio.write_text(r'''#!/usr/bin/env python3 +import json +import os +from pathlib import Path +import sys +if len(sys.argv) != 6 or sys.argv[1:5] != ["project", "config", "--json-output", "--project-dir"]: + raise SystemExit("forbidden PlatformIO command") +root = Path(sys.argv[5]) +record = os.environ.get("MIGRATION_TEST_METADATA_LOG") +if record: + with open(record, "a", encoding="ascii") as output: + output.write(json.dumps({"args": sys.argv[1:], "cwd": os.getcwd(), + "build_dir": os.environ.get("PLATFORMIO_BUILD_DIR"), + "other": os.environ.get("MIGRATION_TEST_OTHER")}) + "\n") +if os.environ.get("MIGRATION_TEST_METADATA_EXIT"): + print("private-metadata-credential", file=sys.stderr) + raise SystemExit(int(os.environ["MIGRATION_TEST_METADATA_EXIT"])) +if "MIGRATION_TEST_METADATA_JSON" in os.environ: + print(os.environ["MIGRATION_TEST_METADATA_JSON"]) + raise SystemExit(0) +value = os.environ.get("PLATFORMIO_BUILD_DIR") or str(root / ".pio/build") +value = value.replace("${PROJECT_DIR}", str(root)) +directory = Path(value).expanduser() +if not directory.is_absolute(): + directory = root / directory +if os.environ.get("MIGRATION_TEST_CONFIGURED_DIR"): + directory = Path(os.environ["MIGRATION_TEST_CONFIGURED_DIR"]) +print(json.dumps([["platformio", [["build_dir", str(directory.resolve())]]]])) +''', encoding="ascii") + metadata_pio.chmod(0o755) + previous_path = os.environ["PATH"] + self.addCleanup(os.environ.__setitem__, "PATH", previous_path) + os.environ["PATH"] = str(metadata_bin) + os.pathsep + previous_path + + def qualified_full(self, board): + spec = package.BOARDS[board] + target = spec["target"] + ident = FwIdent(pack_version(VERSION.split("-", 1)[0]), + target_id_for_env(target), hardware_id_for_env(target)) + image, _ = ensure_endf(b"\xe9qualified Full application", ident) + table = bytearray(b"\xff" * 4096) + entries = (("nvs", 1, 2, 0x9000, 0x5000), + ("otadata", 1, 0, 0xE000, 0x2000), + ("app0", 0, 0x10, 0x10000, spec["slot_bytes"]), + ("app1", 0, 0x11, spec["slot1_address"], spec["slot_bytes"])) + for index, (label, kind, subtype, address, size) in enumerate(entries): + struct.pack_into(" 2 and command[2] == "scripts/package_esp32_partition_migration.py": + with mock.patch.object(sys, "argv", command[2:]): + package.main() # Actual hash-valid packaging and archive verification. + else: + self.fail("unexpected recipe process: " + repr(command)) + return subprocess.CompletedProcess(command, 0) + + def git(*arguments): + return "" if arguments[0] == "status" else SOURCE + + output_root = self.work / "recipe release" + with mock.patch.object(recipe, "ROOT", self.project), \ + mock.patch.object(recipe, "git_output", side_effect=git), \ + mock.patch.dict(os.environ, environment, clear=True), \ + mock.patch.object(subprocess, "run", side_effect=run), \ + mock.patch.object(sys, "argv", [ + "migration-recipe", "--board", "heltec-v4", "--version", VERSION, + "--radio-preset", "usa-cascadia", "--output-root", str(output_root)]), \ + redirect_stdout(io.StringIO()): + recipe.main() + self.assertEqual(len(self.queries()), 1) + self.assertEqual(len(calls), 5) # One Full, three utilities, one packager. + for command, child_environment in calls: + self.assertEqual(child_environment["PLATFORMIO_BUILD_DIR"], str(self.fixture.custom.resolve())) + self.assertEqual(child_environment["MIGRATION_TEST_OTHER"], "preserved-option") + if len(command) > 2 and command[2] == "scripts/package_esp32_partition_migration.py": + self.assertEqual(command[command.index("--pio-build-dir") + 1], + str(self.fixture.custom.resolve())) + archive, = (output_root / "packages").glob("*.zip") + self.fixture.check_archive(archive, "heltec-v4", expected, identity) + + def test_local_release_main_passes_one_canonical_directory_to_both_later_phases(self): + import build_local_release as recipe + environment = self.environment(MIGRATION_TEST_CONFIGURED_DIR=str(self.fixture.custom)) + captured = [] + source = SOURCE + "a" * 32 + with mock.patch.object(recipe, "ROOT", self.project), \ + mock.patch.object(recipe, "git", side_effect=lambda *args: "" if args[0] == "status" else source), \ + mock.patch.dict(os.environ, environment, clear=True), \ + mock.patch.object(recipe, "run_logged", side_effect=lambda command, log, env: + captured.append(("matrix", env.copy()))), \ + mock.patch.object(recipe, "build_migration_artifacts", side_effect=lambda *args: + captured.append(("migration", args[4].copy(), args[5]))), \ + mock.patch.object(recipe, "stage_release", side_effect=lambda *args: + captured.append(("package", args[5]))), \ + mock.patch.object(sys, "argv", ["local-release"]), \ + redirect_stdout(io.StringIO()): + recipe.main() + self.assertEqual(len(self.queries()), 1) + self.assertEqual([value[0] for value in captured], ["matrix", "migration", "package"]) + for item in captured[:2]: + self.assertEqual(item[1]["PLATFORMIO_BUILD_DIR"], str(self.fixture.custom.resolve())) + self.assertEqual(item[1]["MIGRATION_TEST_OTHER"], "preserved-option") + self.assertEqual(captured[1][2], self.fixture.custom.resolve()) + self.assertEqual(captured[2][1], self.fixture.custom.resolve()) + + def test_local_utility_builder_preserves_environment_and_never_requeries_pinned_directory(self): + import build_local_release as recipe + spec = package.BOARDS["heltec-v4"] + environment = self.environment(PLATFORMIO_BUILD_DIR="${PROJECT_DIR}/not-pinned") + previous = environment.copy() + work = self.work / "local migration builds" + (work / "build-logs").mkdir(parents=True) + captured = [] + + def run(command, **options): + self.assertEqual(command[:2], ["pio", "run"]) + captured.append(("utility", options["env"].copy())) + return subprocess.CompletedProcess(command, 0) + + with mock.patch.object(recipe, "ROOT", self.project), \ + mock.patch.object(recipe, "BOARDS", {"heltec-v4": spec}), \ + mock.patch.object(recipe, "run_logged", side_effect=lambda command, log, env: + captured.append(("full", env.copy()))), \ + mock.patch.object(subprocess, "run", side_effect=run), \ + redirect_stdout(io.StringIO()): + recipe.build_migration_artifacts(work, VERSION, SOURCE, 2, environment, + self.fixture.custom) + self.assertEqual(environment, previous) + self.assertFalse(self.record.exists()) + self.assertEqual([item[0] for item in captured], ["full", "utility", "utility", "utility"]) + for _, child_environment in captured: + self.assertEqual(child_environment["PLATFORMIO_BUILD_DIR"], str(self.fixture.custom.resolve())) + self.assertEqual(child_environment["MIGRATION_TEST_OTHER"], "preserved-option") + + def test_local_staging_passes_pinned_directory_and_environment_to_actual_packaging(self): + import build_local_release as recipe + spec, identity = self.fixture.qualified_full("heltec-v4") + expected = self.fixture.utility_tree(self.fixture.custom, spec, "LOCAL PINNED") + manifest = json.loads(next(self.fixture.artifacts.glob("*.capabilities.json")).read_text()) + record = {"manifest": manifest, "files": list(self.fixture.artifacts.iterdir())} + captured = [] + + def run(command, **options): + self.assertEqual(command[2], "scripts/package_esp32_partition_migration.py") + captured.append((command, options["env"].copy())) + with mock.patch.object(sys, "argv", command[2:]): + package.main() + return subprocess.CompletedProcess(command, 0) + + destination = self.work / "local staged release" + environment = self.environment(PLATFORMIO_BUILD_DIR="wrong parent directory") + with mock.patch.object(recipe, "ROOT", self.project), \ + mock.patch.object(recipe, "BOARDS", {"heltec-v4": spec}), \ + mock.patch.object(package, "BOARDS", {"heltec-v4": spec}), \ + mock.patch.object(recipe, "git", return_value=SOURCE + "a" * 32), \ + mock.patch.object(recipe, "collect_artifacts", return_value=[record]), \ + mock.patch.dict(os.environ, environment, clear=True), \ + mock.patch.object(subprocess, "run", side_effect=run), \ + redirect_stdout(io.StringIO()): + recipe.stage_release(self.fixture.artifacts, self.fixture.artifacts, destination, + VERSION, SOURCE + "a" * 32, self.fixture.custom) + self.assertFalse(self.record.exists()) # Explicit directory bypasses metadata. + command, child_environment = captured[0] + self.assertEqual(child_environment["PLATFORMIO_BUILD_DIR"], str(self.fixture.custom.resolve())) + self.assertEqual(child_environment["MIGRATION_TEST_OTHER"], "preserved-option") + self.assertEqual(command[command.index("--pio-build-dir") + 1], + str(self.fixture.custom.resolve())) + archive, = (destination / "esp32-partition-migration").glob("*.zip") + self.fixture.check_archive(archive, "heltec-v4", expected, identity) + + def test_recipe_metadata_failure_stops_before_output_creation_without_private_diagnostic(self): + import build_esp32_partition_migration as migration + import build_local_release as local + environment = self.environment(MIGRATION_TEST_METADATA_EXIT="9") + for recipe, argv in ( + (migration, ["migration", "--board", "heltec-v4", "--version", VERSION, + "--radio-preset", "usa-cascadia", + "--output-root", str(self.work / "failed-output")]), + (local, ["local-release"]), + ): + with self.subTest(recipe=recipe.__name__): + git_name = "git_output" if recipe is migration else "git" + errors = io.StringIO() + with mock.patch.object(recipe, "ROOT", self.project), \ + mock.patch.object(recipe, git_name, + side_effect=lambda *args: "" if args[0] == "status" else SOURCE), \ + mock.patch.dict(os.environ, environment, clear=True), \ + mock.patch.object(sys, "argv", argv), \ + redirect_stdout(io.StringIO()), redirect_stderr(errors): + with self.assertRaises(SystemExit) as stopped: + recipe.main() + self.assertEqual(stopped.exception.code, 2) + self.assertIn("cannot resolve the PlatformIO build directory", errors.getvalue()) + self.assertNotIn("private-metadata-credential", errors.getvalue()) + self.assertFalse((self.work / "failed-output").exists()) + self.assertFalse((self.project / ".releases").exists()) + + def test_dry_runs_and_dirty_source_do_not_query_or_create_outputs(self): + import build_esp32_partition_migration as migration + import build_local_release as local + for recipe, argv in ( + (migration, ["migration", "--board", "heltec-v4", "--version", VERSION, + "--radio-preset", "usa-cascadia"]), + (local, ["local-release"]), + ): + with self.subTest(recipe=recipe.__name__): + git_name = "git_output" if recipe is migration else "git" + with mock.patch.object(recipe, "ROOT", self.project), \ + mock.patch.object(recipe, git_name, + side_effect=lambda *args: "dirty" if args[0] == "status" else SOURCE), \ + mock.patch.object(sys, "argv", argv + ["--dry-run"]), \ + redirect_stdout(io.StringIO()): + recipe.main() + with mock.patch.object(recipe, "ROOT", self.project), \ + mock.patch.object(recipe, git_name, + side_effect=lambda *args: "dirty" if args[0] == "status" else SOURCE), \ + mock.patch.object(sys, "argv", argv), \ + redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as stopped: + recipe.main() + self.assertEqual(stopped.exception.code, 2) + self.assertFalse(self.record.exists()) + self.assertFalse((self.project / ".releases").exists()) + + +@unittest.skipUnless(os.environ.get("MESHCORE_TEST_REAL_PIO_PATHS") == "1", + "real PlatformIO metadata checks require explicit opt-in") +class RealPlatformIOPathTest(unittest.TestCase): + """Opt-in metadata integration only; never compile or create build output.""" + + def test_real_platformio_resolves_nine_directory_selections_without_building(self): + with tempfile.TemporaryDirectory(prefix="migration-real-pio-paths-") as temporary: + work = Path(temporary) + projects = { + "default": "[platformio]\n\n[env:probe]\n", + "config": ("[platformio]\n" + "build_dir = ${PROJECT_DIR}/configured build/with spaces\n" + "\n[env:probe]\n"), + "workspace": ("[platformio]\n" + "workspace_dir = ${sysenv.MESHCORE_TEST_PATH_WORKSPACE}\n" + "\n[env:probe]\n"), + } + for name, content in projects.items(): + project = work / ("project " + name) + project.mkdir() + (project / "platformio.ini").write_text(content, encoding="ascii") + project = work / "project default" + # A unique home-relative name exercises expanduser without writing + # into the home directory or colliding with another test's output. + tilde = "~/" + work.name + "-home-build" + base = os.environ.copy() + base.pop("PLATFORMIO_BUILD_DIR", None) + base.pop("PLATFORMIO_WORKSPACE_DIR", None) + original_environment = os.environ.copy() + cases = ( + ("default", project, {}, project / ".pio/build"), + ("relative", project, {"PLATFORMIO_BUILD_DIR": "relative build"}, + project / "relative build"), + ("absolute", project, {"PLATFORMIO_BUILD_DIR": str(work / "absolute build")}, + work / "absolute build"), + ("tilde", project, {"PLATFORMIO_BUILD_DIR": tilde}, Path(tilde).expanduser()), + ("project interpolation", project, + {"PLATFORMIO_BUILD_DIR": "${PROJECT_DIR}/project build"}, + project / "project build"), + ("environment interpolation", project, + {"PLATFORMIO_BUILD_DIR": "${sysenv.MESHCORE_TEST_PATH_BUILD_ROOT}/objects", + "MESHCORE_TEST_PATH_BUILD_ROOT": str(work / "env build")}, + work / "env build/objects"), + ("workspace environment", project, + {"PLATFORMIO_WORKSPACE_DIR": str(work / "workspace")}, + work / "workspace/build"), + ("configured build", work / "project config", {}, + work / "project config/configured build/with spaces"), + ("configured workspace", work / "project workspace", + {"MESHCORE_TEST_PATH_WORKSPACE": str(work / "configured workspace")}, + work / "configured workspace/build"), + ) + initial_files = {path.relative_to(work): path.read_bytes() + for path in work.rglob("*") if path.is_file()} + initial_directories = {path.relative_to(work) + for path in work.rglob("*") if path.is_dir()} + for name, root, changes, expected in cases: + with self.subTest(case=name): + environment = {**base, **changes} + before = environment.copy() + self.assertFalse(expected.exists()) + selected = package.resolve_pio_build_dir( + environment=environment, project_dir=root) + self.assertEqual(selected, expected.resolve()) + self.assertEqual(environment, before) + self.assertFalse(selected.exists(), "metadata query created build output") + self.assertFalse((root / ".pio").exists()) + self.assertEqual(os.environ, original_environment) + self.assertEqual(initial_files, {path.relative_to(work): path.read_bytes() + for path in work.rglob("*") if path.is_file()}) + self.assertEqual(initial_directories, {path.relative_to(work) + for path in work.rglob("*") if path.is_dir()}) + + +if __name__ == "__main__": + unittest.main()