diff --git a/.gitignore b/.gitignore index 64115a9d..9ad102a4 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,6 @@ Desktop.ini build-*/ node_modules/ target/ + +# ebuild build output (the default --build-dir) +_build/ diff --git a/ebuild/build/dispatch.py b/ebuild/build/dispatch.py index 0d708dfa..3cf77d5b 100644 --- a/ebuild/build/dispatch.py +++ b/ebuild/build/dispatch.py @@ -26,6 +26,23 @@ ALL_BACKENDS = {"cmake", "make", "meson", "cargo", "kbuild", "ninja"} +def ninja_command(): + """Return the argv prefix that runs ninja on this machine. + + Prefer a `ninja` executable on PATH -- that is what a developer who + followed any ordinary install guide has, and what CMake and Meson already + use. Fall back to the `ninja` PyPI wheel only when no binary is present. + + ebuild used to invoke `sys.executable -m ninja` unconditionally, so a + machine with ninja correctly installed still failed with "No module named + ninja" on the first build of a new project. + """ + import shutil + + exe = shutil.which("ninja") + return [exe] if exe else [sys.executable, "-m", "ninja"] + + def detect_backend(source_dir: Path) -> str: """Auto-detect the build system from project files. @@ -121,22 +138,15 @@ def configure( elif backend == "cargo": pass # Cargo does not have a separate configure step - elif backend in ("make", "kbuild", "ninja"): + elif backend in ("make", "kbuild"): pass # No separate configure step - else: - raise ValueError( - f"Unknown build backend '{backend}'. " - f"Supported backends: {', '.join(sorted(ALL_BACKENDS))}" - ) - else: raise RuntimeError( f"BackendDispatcher cannot configure backend '{backend}'. " "This dispatcher only handles cmake, meson, and cargo " "(make/kbuild need no configure step). ebuild's own ninja " - "backend is invoked directly and requires 'targets' in " - "build.yaml -- add targets or choose another backend." + "backend is generated and invoked by the CLI, not here." ) def build( @@ -185,9 +195,11 @@ def build( _run_or_log(cmd, dry_run) else: - raise ValueError( - f"Unknown build backend '{backend}'. " - f"Supported backends: {', '.join(sorted(ALL_BACKENDS))}" + raise RuntimeError( + f"BackendDispatcher cannot build backend '{backend}'. " + "Supported here: cargo, cmake, kbuild, make, meson. ebuild's own " + "ninja backend is generated and invoked by the CLI, not through " + "this dispatcher." ) def clean( @@ -203,7 +215,9 @@ def clean( dry_run: If True, log commands instead of executing them. Raises: - ValueError: If the backend is not recognized. + RuntimeError: If the backend is not recognized. configure() and + build() raise the same type for the same condition, so a + caller can guard all three with one ``except``. """ if backend == "cmake": _run_or_log( @@ -232,12 +246,12 @@ def clean( ) elif backend == "ninja": _run_or_log( - [sys.executable, "-m", "ninja", "-C", str(self.build_dir), "-t", "clean"], + ninja_command() + ["-C", str(self.build_dir), "-t", "clean"], dry_run, check=False, ) else: - raise ValueError( + raise RuntimeError( f"Unknown build backend '{backend}'. " f"Supported backends: {', '.join(sorted(ALL_BACKENDS))}" ) diff --git a/ebuild/build/ninja_backend.py b/ebuild/build/ninja_backend.py index 5aa2f374..1282545e 100644 --- a/ebuild/build/ninja_backend.py +++ b/ebuild/build/ninja_backend.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +import re import sys from dataclasses import dataclass, field from pathlib import Path @@ -30,6 +31,14 @@ class PackagePaths: "-fno-pie", "-fno-PIE"} +def _shared_flag() -> str: + """The compiler flag that produces a shared object on this platform. + + macOS links dynamic libraries with -dynamiclib; ELF platforms use -shared. + """ + return "-dynamiclib" if sys.platform == "darwin" else "-shared" + + class NinjaBackend: """Generate build.ninja from a ProjectConfig and resolved toolchain. @@ -104,6 +113,21 @@ def _resolve_target_cflags(self, target) -> List[str]: return cflags + def _object_path(self, target, src: str) -> Path: + """Object file for one source within one target. + + Namespaced by target name: a source shared by two targets must produce + two distinct objects. Ninja rejects two edges writing the same output, + and the targets may compile it with different cflags. + + The source's directory structure is flattened into the filename rather + than mirrored beneath the build directory. Mirroring lets a source from + outside the project -- ``../shared/util.c`` -- place its object outside + the build directory too, where ``clean`` will not find it. + """ + flat = re.sub(r"[^A-Za-z0-9_.-]", "_", str(src).replace("\\", "/")) + return self.build_dir / "obj" / target.name / f"{flat}.o" + def _write_ninja(self) -> None: """Write the build.ninja file.""" ninja_path = self.build_dir / "build.ninja" @@ -124,7 +148,7 @@ def _write_ninja(self) -> None: " description = LINK $out", "", "rule link_shared", - " command = $cc -shared $ldflags $in -o $out $libs", + f" command = $cc {_shared_flag()} $ldflags $in -o $out $libs", " description = LINK_SHARED $out", "", "rule ar_rule", @@ -149,7 +173,7 @@ def _write_ninja(self) -> None: lines.append(f" cflags = {' '.join(cflags)}") lines.append("") - if target.target_type == "executable": + if target.target_type in ("executable", "test"): ldflags = toolchain_ldflags + list(target.ldflags) libs = [] dep_archives = [] @@ -190,11 +214,11 @@ def _write_ninja(self) -> None: if target.target_type == "static_library": lines.append(f"build {out}: ar_rule {' '.join(obj_files)}") else: - # Shared libraries need the platform's "build a shared - # object" flag and the same -L/-l wiring executables get, - # neither of which the generic `link` rule provides. + # Shared libraries need the same -L/-l wiring executables + # get, which the rule preamble alone does not supply. The + # "build a shared object" flag itself lives in the + # link_shared rule, so it must not be repeated here. ldflags = list(target.ldflags) - ldflags.insert(0, "-dynamiclib" if sys.platform == "darwin" else "-shared") libs = [] for pkg_name in target.uses: pkg = self.package_paths.get(pkg_name) @@ -204,7 +228,7 @@ def _write_ninja(self) -> None: for lib in pkg.libraries: libs.append(f"-l{lib}") - lines.append(f"build {out}: link {' '.join(obj_files)}") + lines.append(f"build {out}: link_shared {' '.join(obj_files)}") if ldflags: lines.append(f" ldflags = {' '.join(ldflags)}") if libs: diff --git a/ebuild/cli/commands.py b/ebuild/cli/commands.py index b7b95228..3c4fed9e 100644 --- a/ebuild/cli/commands.py +++ b/ebuild/cli/commands.py @@ -9,7 +9,9 @@ from __future__ import annotations +import glob import os +import re import shutil import subprocess import threading @@ -187,6 +189,95 @@ def note_skipped(name: str, _cause: BaseException) -> None: return package_paths +def _workspace_repo_paths() -> Dict[str, PackagePaths]: + """Include paths for the eos and eboot repos that `ebuild setup` cloned. + + A scaffolded project includes , but the generated build.yaml + carried no path to the headers, so every template failed with + "fatal error: eos/hal.h: No such file or directory" on the first build. + + These are resolved at build time from the cache rather than written into + build.yaml as absolute paths: the path is a fact about this machine, and + build.yaml is a file the developer commits. + + Returns an empty mapping when the cache is absent, so the error a developer + sees stays the missing header rather than a stack trace, and `ebuild setup` + remains the fix. + """ + from ebuild.deps import EBUILD_REPOS_DIR + + paths: Dict[str, PackagePaths] = {} + for name in ("eos", "eboot"): + root = Path(EBUILD_REPOS_DIR) / name + if not root.is_dir(): + continue + # Headers sit at two depths: kernel/include, hal/include ... and + # services/crypto/include, services/ota/include. Both are needed -- + # and live only in the deeper set. + include_dirs = sorted( + {p for pattern in ("include", "*/include", "*/*/include") + for p in root.glob(pattern) if p.is_dir()} + ) + if include_dirs: + lib_dirs, libraries = _cached_repo_libraries(root) + paths[name] = PackagePaths( + include_dirs=include_dirs, + lib_dirs=lib_dirs, + libraries=libraries, + ) + return paths + + +# Where `ebuild` puts the CMake build tree for a cached repo. Kept inside the +# clone so `ebuild setup` remains the only thing that owns ~/.ebuild/repos. +_REPO_BUILD_DIRNAME = "_ebuild" + + +def _cached_repo_libraries(root: Path) -> Tuple[List[Path], List[str]]: + """Static libraries a cached repo offers to projects that `use` it. + + Headers alone are not enough: a scaffolded project compiles against + and then fails at the link step with undefined references. + The repo is a CMake project with no install() rules, so there is nothing + to point a -L at until it has been built once. Build it on demand and + cache the result; subsequent builds reuse the tree. + + Returns ([], []) when the repo cannot be built here — a missing cmake, a + repo that is not a CMake project — so the developer still gets a link + error naming the symbol rather than a stack trace from ebuild. + """ + if not (root / "CMakeLists.txt").is_file(): + return [], [] + + build_dir = root / _REPO_BUILD_DIRNAME + archives = sorted(build_dir.rglob("*.a")) if build_dir.is_dir() else [] + + if not archives: + if shutil.which("cmake") is None: + return [], [] + try: + subprocess.run( + ["cmake", "-S", str(root), "-B", str(build_dir)], + check=True, capture_output=True, timeout=600, + ) + subprocess.run( + ["cmake", "--build", str(build_dir), "-j", str(os.cpu_count() or 1)], + check=True, capture_output=True, timeout=1800, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError): + return [], [] + archives = sorted(build_dir.rglob("*.a")) + + if not archives: + return [], [] + + # -L one directory per archive location; -l the archive basenames with + # the lib prefix and .a suffix stripped, which is what the linker wants. + lib_dirs = sorted({a.parent for a in archives}) + libraries = [a.stem[3:] for a in archives if a.stem.startswith("lib")] + return lib_dirs, libraries + + def _detect_libraries(lib_dir: Path, pkg_name: str) -> List[str]: """Detect installed library names from a lib/ directory.""" if not lib_dir.exists(): @@ -230,12 +321,22 @@ def _configure_ninja_backend( cfg: ProjectConfig, build_path: Path, log: Logger, + *, + suggest_build: bool = True, ) -> None: - """Generate native ebuild Ninja files for configure-only workflows.""" + """Generate native ebuild Ninja files for configure-only workflows. + + The package paths are merged exactly as `ebuild build` merges them. If + they were not, `configure` and `build` would each write a different + build.ninja to the same path, and a developer who ran `configure` and then + invoked ninja directly would build without the cached-repo include and + library paths. + """ log.step("Resolving toolchain...") compiler = resolve_toolchain(cfg.toolchain) - package_paths = _install_packages(cfg, build_path, log, verbose=log.verbose) + package_paths = {**_workspace_repo_paths(), + **_install_packages(cfg, build_path, log, verbose=log.verbose)} log.step(f"Generating build.ninja in {build_path}/...") ninja_backend = NinjaBackend(cfg, build_path, compiler, package_paths=package_paths) @@ -243,7 +344,8 @@ def _configure_ninja_backend( log.success(f"Generated {build_path / 'build.ninja'}") log.success(f"Generated {build_path / 'compile_commands.json'}") - log.info("Run 'ebuild build' to compile.") + if suggest_build: + log.info("Run 'ebuild build' to compile.") def _configure_external_backend( @@ -291,6 +393,63 @@ def _format_missing_tool(exc: FileNotFoundError) -> str: # ═══════════════════════════════════════════════════════════════ +# Board selection — shared by `configure --board` and `monitor` +# --------------------------------------------------------------------------- + +# The project-local file that records which board this checkout targets. +_EOS_PROJECT_CONFIG = "eos.yaml" + + +def _record_board_selection(board: str, log: Logger) -> None: + """Persist ``--board`` into eos.yaml under ``system.board``. + + The golden path is `configure --board` then a bare `build`, so the choice + has to outlive the configure process. It is written to eos.yaml rather + than build.yaml because the board is a property of the system being + targeted, which is what eos.yaml already describes. + """ + path = Path(_EOS_PROJECT_CONFIG) + if not path.is_file(): + log.error( + f"No {_EOS_PROJECT_CONFIG} here, so there is nothing to record the " + f"board against. Run this from a project directory created by " + f"'ebuild new'." + ) + raise SystemExit(1) + + import yaml + + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except yaml.YAMLError as e: + log.error(f"{_EOS_PROJECT_CONFIG} is not valid YAML: {e}") + raise SystemExit(1) + + system = data.setdefault("system", {}) + previous = system.get("board") + system["board"] = board + path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + + if previous and previous != board: + log.info(f"Board: {previous} -> {board}") + else: + log.info(f"Board: {board}") + + +def _selected_board(default: str = "generic") -> str: + """The board recorded by ``configure --board``, or ``default``.""" + path = Path(_EOS_PROJECT_CONFIG) + if not path.is_file(): + return default + try: + import yaml + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except Exception: + return default + return (data.get("system") or {}).get("board") or default + + +# --------------------------------------------------------------------------- # Pipeline helper — shared by `pipeline` and `build --board` # ═══════════════════════════════════════════════════════════════ @@ -678,7 +837,8 @@ def build(log: Logger, config_path: str, build_dir: str, backend: Optional[str], log.debug(f"Compiler: {compiler.cc}") # Install packages if any are declared - package_paths = _install_packages(cfg, build_path, log, verbose=log.verbose, jobs=jobs) + package_paths = {**_workspace_repo_paths(), + **_install_packages(cfg, build_path, log, verbose=log.verbose, jobs=jobs)} log.step(f"Generating build.ninja in {build_path}/...") ninja_backend = NinjaBackend(cfg, build_path, compiler, package_paths=package_paths) @@ -687,7 +847,8 @@ def build(log: Logger, config_path: str, build_dir: str, backend: Optional[str], log.success(f"Generated {build_path / 'compile_commands.json'}") log.step("Invoking ninja...") - ninja_cmd = [sys.executable, "-m", "ninja", "-f", str(build_path / "build.ninja")] + from ebuild.build.dispatch import ninja_command + ninja_cmd = ninja_command() + ["-f", str(build_path / "build.ninja")] if log.verbose: ninja_cmd.append("-v") @@ -865,12 +1026,22 @@ def clean(log: Logger, build_dir: str) -> None: type=click.Choice(["auto", "cmake", "make", "meson", "cargo", "ninja", "kbuild"]), help="Force a specific build backend.", ) +@click.option( + "--board", + default=None, + help="Target board name (e.g., stm32f4, nrf52). Recorded in the project " + "config so later `ebuild build` / `flash` / `monitor` use it.", +) @click.pass_obj -def configure(log: Logger, config_path: str, build_dir: str, backend: Optional[str]) -> None: +def configure(log: Logger, config_path: str, build_dir: str, backend: Optional[str], + board: Optional[str]) -> None: """Generate build files without building.""" log.header("ebuild — Configure") try: + if board: + _record_board_selection(board, log) + log.step("Loading configuration...") cfg = load_config(config_path) log.info(f"Project: {cfg.name} v{cfg.version}") @@ -1630,6 +1801,11 @@ def new(log: Logger, project_name: str, template_name: str, board_name: str, # Create project directory structure src_dir = project_dir / "src" src_dir.mkdir(parents=True) + # A scaffolded project ships a runnable test so that `ebuild test`, step + # six of the golden path, passes on a fresh checkout instead of reporting + # that the project declares no tests. + tests_dir = project_dir / "tests" + tests_dir.mkdir(parents=True, exist_ok=True) # Template variable substitution replacements = { @@ -1645,6 +1821,7 @@ def new(log: Logger, project_name: str, template_name: str, board_name: str, # Copy and process template files file_mapping = { "main.c.template": src_dir / "main.c", + "test_main.c.template": tests_dir / "test_main.c", "build.yaml.template": project_dir / "build.yaml", "eos.yaml.template": project_dir / "eos.yaml", "README.md.template": project_dir / "README.md", @@ -1977,3 +2154,257 @@ def generate_board( except Exception as e: log.error(f"Board generation failed: {e}") raise SystemExit(1) + + +@cli.command() +@click.option( + "--config", + "config_path", + default="build.yaml", + type=click.Path(), + help="Path to the build configuration file.", +) +@click.option( + "--build-dir", + default="_build", + type=click.Path(), + help="Build output directory.", +) +@click.option( + "--filter", + "name_filter", + default=None, + help="Only run tests whose name contains this substring.", +) +@click.pass_obj +def test(log: Logger, config_path: str, build_dir: str, + name_filter: Optional[str]) -> None: + """Build and run the project's tests. + + Step six of the golden path. Delegates to whichever runner the project + already uses -- ctest for a CMake tree, `cargo test`, `meson test`, or + `make test` -- rather than imposing a test framework on the project. + """ + log.header("ebuild — Test") + + build_path = Path(build_dir) + + try: + log.step("Loading configuration...") + cfg = load_config(config_path) + log.info(f"Project: {cfg.name} v{cfg.version}") + except FileNotFoundError: + log.error( + f"No {config_path} here. Run this from a project directory, or " + f"pass --config." + ) + raise SystemExit(1) + except (ConfigError, RecipeError) as e: + log.error(f"Configuration error: {e}") + raise SystemExit(1) + + native = [t for t in cfg.targets if t.target_type == "test"] + if native: + _run_native_tests(cfg, native, build_path, log, name_filter) + return + + runner = _resolve_test_runner(cfg.source_dir, build_path, name_filter) + if runner is None: + log.error( + "No test runner found for this project.\n" + " ebuild test drives the project's own runner. Add one of:\n" + " - CMake with enable_testing() + add_test() -> ctest\n" + " - a 'test' target in the Makefile -> make test\n" + " - Cargo.toml -> cargo test\n" + " - meson.build -> meson test" + ) + raise SystemExit(1) + + name, argv, cwd = runner + log.step(f"Running tests with {name}...") + log.info(" ".join(argv)) + + try: + result = subprocess.run(argv, cwd=str(cwd)) + except FileNotFoundError: + log.error( + f"{name} is not installed or not on PATH, so the tests cannot be " + f"run here." + ) + raise SystemExit(1) + + if result.returncode != 0: + log.error(f"Tests failed ({name} exited {result.returncode}).") + raise SystemExit(result.returncode) + + log.success("All tests passed.") + + +def _run_native_tests( + cfg: "ProjectConfig", + targets: List[Any], + build_path: Path, + log: Logger, + name_filter: Optional[str], +) -> None: + """Build and run the project's own ``test`` targets. + + A scaffolded project has no CMake tree and no Makefile, so there is no + external runner to delegate to. The test binaries are ordinary ebuild + targets; build them the same way `ebuild build` does, then run each one + and treat a non-zero exit as a failure. + """ + selected = [t for t in targets if not name_filter or name_filter in t.name] + if not selected: + log.error(f"No test target matches --filter {name_filter!r}.") + raise SystemExit(1) + + log.step("Building test targets...") + _configure_ninja_backend(cfg, build_path, log, suggest_build=False) + + from ebuild.build.dispatch import ninja_command + + # Ninja addresses targets by their output path, and `ebuild build` drives + # it with -f from the project root, so the same form is used here. + argv = ( + ninja_command() + + ["-f", str(build_path / "build.ninja")] + + [str(build_path / t.name) for t in selected] + ) + result = subprocess.run(argv) + if result.returncode != 0: + log.error("Test targets failed to build.") + raise SystemExit(result.returncode) + + failures: List[str] = [] + for target in selected: + binary = build_path / target.name + if not binary.is_file(): + log.error(f"{target.name}: built, but no binary at {binary}") + failures.append(target.name) + continue + + log.step(f"Running {target.name}...") + run = subprocess.run([str(binary)]) + if run.returncode == 0: + log.success(f" {target.name}: passed") + else: + log.error(f" {target.name}: exited {run.returncode}") + failures.append(target.name) + + if failures: + log.error(f"{len(failures)} of {len(selected)} test targets failed: " + + ", ".join(failures)) + raise SystemExit(1) + + log.success(f"All {len(selected)} test targets passed.") + + +def _resolve_test_runner( + source_dir: Path, + build_dir: Path, + name_filter: Optional[str], +) -> Optional[Tuple[str, List[str], Path]]: + """Pick the test runner this project already uses. + + Returns ``(display_name, argv, cwd)``, or None when the project declares + no tests. Ordered so that an explicit CMake test registry wins over a + generic `make test` target in the same tree. + """ + if (build_dir / "CTestTestfile.cmake").is_file(): + argv = ["ctest", "--output-on-failure"] + if name_filter: + argv += ["-R", name_filter] + return "ctest", argv, build_dir + + if (source_dir / "Cargo.toml").is_file(): + argv = ["cargo", "test"] + if name_filter: + argv += [name_filter] + return "cargo test", argv, source_dir + + if (build_dir / "meson-info").is_dir(): + argv = ["meson", "test", "-C", str(build_dir)] + if name_filter: + argv += ["--suite", name_filter] + return "meson test", argv, source_dir + + makefile = next( + (source_dir / n for n in ("Makefile", "makefile", "GNUmakefile") + if (source_dir / n).is_file()), + None, + ) + if makefile is not None: + text = makefile.read_text(encoding="utf-8", errors="replace") + if re.search(r"^test\s*:", text, re.MULTILINE): + return "make test", ["make", "-C", str(source_dir), "test"], source_dir + + return None + + +@cli.command() +@click.option("--port", default=None, + help="Serial device (e.g. /dev/ttyUSB0). Auto-detected if omitted.") +@click.option("--baud", default=115200, type=int, help="Baud rate.") +@click.pass_obj +def monitor(log: Logger, port: Optional[str], baud: int) -> None: + """Attach a serial monitor to the target device. + + Step eight of the golden path -- the step that shows a developer their + first firmware run actually produced output. + """ + log.header("ebuild — Monitor") + log.info(f"Board: {_selected_board()}") + + if port is None: + candidates = _serial_ports() + if not candidates: + log.error( + "No serial device found.\n" + " Looked for /dev/ttyUSB*, /dev/ttyACM*, /dev/tty.usb*.\n" + " Connect the board, or name the device with --port." + ) + raise SystemExit(1) + if len(candidates) > 1: + log.error( + "More than one serial device is connected, so ebuild will not " + "guess which one is the board:\n" + + "\n".join(f" {c}" for c in candidates) + + "\n Choose one with --port." + ) + raise SystemExit(1) + port = candidates[0] + log.info(f"Auto-detected {port}") + + log.step(f"Opening {port} at {baud} baud... (Ctrl-C to exit)") + + try: + import serial # type: ignore[import-untyped] + except ImportError: + log.error( + "pyserial is not installed, so the monitor cannot open the port.\n" + " Install it with: pip install pyserial" + ) + raise SystemExit(1) + + try: + with serial.Serial(port, baud, timeout=0.2) as conn: + while True: + chunk = conn.read(4096) + if chunk: + sys.stdout.write(chunk.decode("utf-8", errors="replace")) + sys.stdout.flush() + except KeyboardInterrupt: + log.info("") + log.success("Monitor closed.") + except Exception as e: + log.error(f"Serial error on {port}: {e}") + raise SystemExit(1) + + +def _serial_ports() -> List[str]: + """Serial devices that look like an attached development board.""" + found: List[str] = [] + for pattern in ("/dev/ttyUSB*", "/dev/ttyACM*", "/dev/tty.usb*"): + found.extend(sorted(glob.glob(pattern))) + return found diff --git a/ebuild/core/config.py b/ebuild/core/config.py index 36955459..9e00f308 100644 --- a/ebuild/core/config.py +++ b/ebuild/core/config.py @@ -42,7 +42,12 @@ class PackageDep: @dataclass class TargetConfig: - """A single build target (executable, static_library, or shared_library).""" + """A single build target. + + A ``test`` target links exactly like an ``executable``; the type exists so + ``ebuild test`` can tell which binaries it is meant to run without + guessing from the target's name. + """ name: str target_type: str @@ -54,7 +59,10 @@ class TargetConfig: depends: List[str] = field(default_factory=list) uses: List[str] = field(default_factory=list) - VALID_TYPES = ("executable", "static_library", "shared_library") + VALID_TYPES = ("executable", "test", "static_library", "shared_library") + + #: Target types that produce a runnable binary rather than an archive. + EXECUTABLE_TYPES = ("executable", "test") def validate(self) -> None: if not self.name: diff --git a/ebuild/deps/__init__.py b/ebuild/deps/__init__.py index 41a272be..900da51c 100644 --- a/ebuild/deps/__init__.py +++ b/ebuild/deps/__init__.py @@ -24,12 +24,12 @@ "repos": { "eos": { "url": DEFAULT_EOS_REPO_URL, - "branch": "main", + "branch": "master", "path": None, }, "eboot": { "url": DEFAULT_EBOOT_REPO_URL, - "branch": "main", + "branch": "master", "path": None, }, }, diff --git a/ebuild/deps/manager.py b/ebuild/deps/manager.py index 64a31444..851c186c 100644 --- a/ebuild/deps/manager.py +++ b/ebuild/deps/manager.py @@ -118,7 +118,7 @@ def setup( Args: repo_name: ``"eos"`` or ``"eboot"``. url: Git URL override. Falls back to config → default. - branch: Branch/tag override. Falls back to config → ``"main"``. + branch: Branch/tag override. Falls back to config → ``"master"``. path: If given, register this local path instead of cloning. shallow: Use ``--depth 1`` for faster clones (default *True*). @@ -143,7 +143,7 @@ def setup( # Clone to cache effective_url = repo_cfg.get("url") or self._default_url(repo_name) - effective_branch = repo_cfg.get("branch") or "main" + effective_branch = repo_cfg.get("branch") or "master" dest = self.cache_dir / repo_name if dest.exists(): @@ -279,7 +279,7 @@ def status(self) -> List[Dict[str, Any]]: info: Dict[str, Any] = { "name": name, "url": repo_cfg.get("url", self._default_url(name)), - "branch": repo_cfg.get("branch", "main"), + "branch": repo_cfg.get("branch", "master"), "config_path": repo_cfg.get("path"), "cached": cached.is_dir(), "cache_location": str(cached) if cached.is_dir() else None, diff --git a/templates/bare-metal/build.yaml.template b/templates/bare-metal/build.yaml.template index 04ecef13..88ea5aa6 100644 --- a/templates/bare-metal/build.yaml.template +++ b/templates/bare-metal/build.yaml.template @@ -16,4 +16,16 @@ targets: - src/main.c includes: - src/ + uses: + - eos + depends: [] + + - name: test_{{PROJECT_NAME}} + type: test + sources: + - tests/test_main.c + includes: + - src/ + uses: + - eos depends: [] diff --git a/templates/bare-metal/test_main.c.template b/templates/bare-metal/test_main.c.template new file mode 100644 index 00000000..fe7fca9d --- /dev/null +++ b/templates/bare-metal/test_main.c.template @@ -0,0 +1,59 @@ +/** + * {{PROJECT_NAME}} — smoke test + * + * Generated by: ebuild new {{PROJECT_NAME}} + * + * Runs on the host via `ebuild test`. Its job is to fail when the build + * wiring is wrong -- a missing EoS checkout, a bad include path, a link-order + * problem -- so that those are caught here rather than on the bench. + * + * It deliberately does not assert that eos_hal_init() succeeds. A host build + * has no HAL backend registered, so eos_hal_init() returns -1 by design; the + * board build is where that call is expected to return 0. + */ + +#include +#include + +static int failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + failures++; \ + } \ + } while (0) + +int main(void) +{ + /* Linking proves the EoS headers and libraries resolved. Calling proves + * the dispatch layer is present and does not fault with no backend. */ + int status = eos_hal_init(); + printf("eos_hal_init() -> %d%s\n", status, + status == 0 ? "" : " (no backend on a host build — expected)"); + + /* The HAL must tolerate calls with no backend rather than crashing: + * that is what lets this test run at all on a development machine. */ + eos_gpio_config_t led = { + .pin = 13, + .mode = EOS_GPIO_OUTPUT, + .pull = EOS_GPIO_PULL_NONE, + .speed = EOS_GPIO_SPEED_LOW, + }; + eos_gpio_init(&led); + eos_gpio_toggle(led.pin); + + /* A tick source is the one thing every board must provide; on the host + * it is monotonic and must not run backwards. */ + uint32_t t0 = eos_get_tick_ms(); + uint32_t t1 = eos_get_tick_ms(); + CHECK(t1 >= t0); + + if (failures == 0) { + printf("ok — {{PROJECT_NAME}} smoke test passed\n"); + return 0; + } + printf("%d check(s) failed\n", failures); + return 1; +} diff --git a/templates/ble-sensor/build.yaml.template b/templates/ble-sensor/build.yaml.template index 04ecef13..88ea5aa6 100644 --- a/templates/ble-sensor/build.yaml.template +++ b/templates/ble-sensor/build.yaml.template @@ -16,4 +16,16 @@ targets: - src/main.c includes: - src/ + uses: + - eos + depends: [] + + - name: test_{{PROJECT_NAME}} + type: test + sources: + - tests/test_main.c + includes: + - src/ + uses: + - eos depends: [] diff --git a/templates/ble-sensor/test_main.c.template b/templates/ble-sensor/test_main.c.template new file mode 100644 index 00000000..fe7fca9d --- /dev/null +++ b/templates/ble-sensor/test_main.c.template @@ -0,0 +1,59 @@ +/** + * {{PROJECT_NAME}} — smoke test + * + * Generated by: ebuild new {{PROJECT_NAME}} + * + * Runs on the host via `ebuild test`. Its job is to fail when the build + * wiring is wrong -- a missing EoS checkout, a bad include path, a link-order + * problem -- so that those are caught here rather than on the bench. + * + * It deliberately does not assert that eos_hal_init() succeeds. A host build + * has no HAL backend registered, so eos_hal_init() returns -1 by design; the + * board build is where that call is expected to return 0. + */ + +#include +#include + +static int failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + failures++; \ + } \ + } while (0) + +int main(void) +{ + /* Linking proves the EoS headers and libraries resolved. Calling proves + * the dispatch layer is present and does not fault with no backend. */ + int status = eos_hal_init(); + printf("eos_hal_init() -> %d%s\n", status, + status == 0 ? "" : " (no backend on a host build — expected)"); + + /* The HAL must tolerate calls with no backend rather than crashing: + * that is what lets this test run at all on a development machine. */ + eos_gpio_config_t led = { + .pin = 13, + .mode = EOS_GPIO_OUTPUT, + .pull = EOS_GPIO_PULL_NONE, + .speed = EOS_GPIO_SPEED_LOW, + }; + eos_gpio_init(&led); + eos_gpio_toggle(led.pin); + + /* A tick source is the one thing every board must provide; on the host + * it is monotonic and must not run backwards. */ + uint32_t t0 = eos_get_tick_ms(); + uint32_t t1 = eos_get_tick_ms(); + CHECK(t1 >= t0); + + if (failures == 0) { + printf("ok — {{PROJECT_NAME}} smoke test passed\n"); + return 0; + } + printf("%d check(s) failed\n", failures); + return 1; +} diff --git a/templates/linux-app/build.yaml.template b/templates/linux-app/build.yaml.template index 04ecef13..88ea5aa6 100644 --- a/templates/linux-app/build.yaml.template +++ b/templates/linux-app/build.yaml.template @@ -16,4 +16,16 @@ targets: - src/main.c includes: - src/ + uses: + - eos + depends: [] + + - name: test_{{PROJECT_NAME}} + type: test + sources: + - tests/test_main.c + includes: + - src/ + uses: + - eos depends: [] diff --git a/templates/linux-app/test_main.c.template b/templates/linux-app/test_main.c.template new file mode 100644 index 00000000..fe7fca9d --- /dev/null +++ b/templates/linux-app/test_main.c.template @@ -0,0 +1,59 @@ +/** + * {{PROJECT_NAME}} — smoke test + * + * Generated by: ebuild new {{PROJECT_NAME}} + * + * Runs on the host via `ebuild test`. Its job is to fail when the build + * wiring is wrong -- a missing EoS checkout, a bad include path, a link-order + * problem -- so that those are caught here rather than on the bench. + * + * It deliberately does not assert that eos_hal_init() succeeds. A host build + * has no HAL backend registered, so eos_hal_init() returns -1 by design; the + * board build is where that call is expected to return 0. + */ + +#include +#include + +static int failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + failures++; \ + } \ + } while (0) + +int main(void) +{ + /* Linking proves the EoS headers and libraries resolved. Calling proves + * the dispatch layer is present and does not fault with no backend. */ + int status = eos_hal_init(); + printf("eos_hal_init() -> %d%s\n", status, + status == 0 ? "" : " (no backend on a host build — expected)"); + + /* The HAL must tolerate calls with no backend rather than crashing: + * that is what lets this test run at all on a development machine. */ + eos_gpio_config_t led = { + .pin = 13, + .mode = EOS_GPIO_OUTPUT, + .pull = EOS_GPIO_PULL_NONE, + .speed = EOS_GPIO_SPEED_LOW, + }; + eos_gpio_init(&led); + eos_gpio_toggle(led.pin); + + /* A tick source is the one thing every board must provide; on the host + * it is monotonic and must not run backwards. */ + uint32_t t0 = eos_get_tick_ms(); + uint32_t t1 = eos_get_tick_ms(); + CHECK(t1 >= t0); + + if (failures == 0) { + printf("ok — {{PROJECT_NAME}} smoke test passed\n"); + return 0; + } + printf("%d check(s) failed\n", failures); + return 1; +} diff --git a/templates/rtos-app/build.yaml.template b/templates/rtos-app/build.yaml.template index 04ecef13..88ea5aa6 100644 --- a/templates/rtos-app/build.yaml.template +++ b/templates/rtos-app/build.yaml.template @@ -16,4 +16,16 @@ targets: - src/main.c includes: - src/ + uses: + - eos + depends: [] + + - name: test_{{PROJECT_NAME}} + type: test + sources: + - tests/test_main.c + includes: + - src/ + uses: + - eos depends: [] diff --git a/templates/rtos-app/test_main.c.template b/templates/rtos-app/test_main.c.template new file mode 100644 index 00000000..fe7fca9d --- /dev/null +++ b/templates/rtos-app/test_main.c.template @@ -0,0 +1,59 @@ +/** + * {{PROJECT_NAME}} — smoke test + * + * Generated by: ebuild new {{PROJECT_NAME}} + * + * Runs on the host via `ebuild test`. Its job is to fail when the build + * wiring is wrong -- a missing EoS checkout, a bad include path, a link-order + * problem -- so that those are caught here rather than on the bench. + * + * It deliberately does not assert that eos_hal_init() succeeds. A host build + * has no HAL backend registered, so eos_hal_init() returns -1 by design; the + * board build is where that call is expected to return 0. + */ + +#include +#include + +static int failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + failures++; \ + } \ + } while (0) + +int main(void) +{ + /* Linking proves the EoS headers and libraries resolved. Calling proves + * the dispatch layer is present and does not fault with no backend. */ + int status = eos_hal_init(); + printf("eos_hal_init() -> %d%s\n", status, + status == 0 ? "" : " (no backend on a host build — expected)"); + + /* The HAL must tolerate calls with no backend rather than crashing: + * that is what lets this test run at all on a development machine. */ + eos_gpio_config_t led = { + .pin = 13, + .mode = EOS_GPIO_OUTPUT, + .pull = EOS_GPIO_PULL_NONE, + .speed = EOS_GPIO_SPEED_LOW, + }; + eos_gpio_init(&led); + eos_gpio_toggle(led.pin); + + /* A tick source is the one thing every board must provide; on the host + * it is monotonic and must not run backwards. */ + uint32_t t0 = eos_get_tick_ms(); + uint32_t t1 = eos_get_tick_ms(); + CHECK(t1 >= t0); + + if (failures == 0) { + printf("ok — {{PROJECT_NAME}} smoke test passed\n"); + return 0; + } + printf("%d check(s) failed\n", failures); + return 1; +} diff --git a/templates/safety-critical/build.yaml.template b/templates/safety-critical/build.yaml.template index 5a38d917..01317eb0 100644 --- a/templates/safety-critical/build.yaml.template +++ b/templates/safety-critical/build.yaml.template @@ -20,6 +20,8 @@ targets: - src/ cflags: ["-mcpu=cortex-r5", "-mfloat-abi=hard", "-mfpu=vfpv3-d16", "-Os"] ldflags: ["-nostartfiles"] + uses: + - eos depends: [] firmware: @@ -27,3 +29,13 @@ firmware: core: {{CORE}} tick_rate: 1000 watchdog_ms: 100 + + - name: test_{{PROJECT_NAME}} + type: test + sources: + - tests/test_main.c + includes: + - src/ + uses: + - eos + depends: [] diff --git a/templates/safety-critical/test_main.c.template b/templates/safety-critical/test_main.c.template new file mode 100644 index 00000000..fe7fca9d --- /dev/null +++ b/templates/safety-critical/test_main.c.template @@ -0,0 +1,59 @@ +/** + * {{PROJECT_NAME}} — smoke test + * + * Generated by: ebuild new {{PROJECT_NAME}} + * + * Runs on the host via `ebuild test`. Its job is to fail when the build + * wiring is wrong -- a missing EoS checkout, a bad include path, a link-order + * problem -- so that those are caught here rather than on the bench. + * + * It deliberately does not assert that eos_hal_init() succeeds. A host build + * has no HAL backend registered, so eos_hal_init() returns -1 by design; the + * board build is where that call is expected to return 0. + */ + +#include +#include + +static int failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + failures++; \ + } \ + } while (0) + +int main(void) +{ + /* Linking proves the EoS headers and libraries resolved. Calling proves + * the dispatch layer is present and does not fault with no backend. */ + int status = eos_hal_init(); + printf("eos_hal_init() -> %d%s\n", status, + status == 0 ? "" : " (no backend on a host build — expected)"); + + /* The HAL must tolerate calls with no backend rather than crashing: + * that is what lets this test run at all on a development machine. */ + eos_gpio_config_t led = { + .pin = 13, + .mode = EOS_GPIO_OUTPUT, + .pull = EOS_GPIO_PULL_NONE, + .speed = EOS_GPIO_SPEED_LOW, + }; + eos_gpio_init(&led); + eos_gpio_toggle(led.pin); + + /* A tick source is the one thing every board must provide; on the host + * it is monotonic and must not run backwards. */ + uint32_t t0 = eos_get_tick_ms(); + uint32_t t1 = eos_get_tick_ms(); + CHECK(t1 >= t0); + + if (failures == 0) { + printf("ok — {{PROJECT_NAME}} smoke test passed\n"); + return 0; + } + printf("%d check(s) failed\n", failures); + return 1; +} diff --git a/templates/secure-boot/build.yaml.template b/templates/secure-boot/build.yaml.template index 04ecef13..88ea5aa6 100644 --- a/templates/secure-boot/build.yaml.template +++ b/templates/secure-boot/build.yaml.template @@ -16,4 +16,16 @@ targets: - src/main.c includes: - src/ + uses: + - eos + depends: [] + + - name: test_{{PROJECT_NAME}} + type: test + sources: + - tests/test_main.c + includes: + - src/ + uses: + - eos depends: [] diff --git a/templates/secure-boot/test_main.c.template b/templates/secure-boot/test_main.c.template new file mode 100644 index 00000000..fe7fca9d --- /dev/null +++ b/templates/secure-boot/test_main.c.template @@ -0,0 +1,59 @@ +/** + * {{PROJECT_NAME}} — smoke test + * + * Generated by: ebuild new {{PROJECT_NAME}} + * + * Runs on the host via `ebuild test`. Its job is to fail when the build + * wiring is wrong -- a missing EoS checkout, a bad include path, a link-order + * problem -- so that those are caught here rather than on the bench. + * + * It deliberately does not assert that eos_hal_init() succeeds. A host build + * has no HAL backend registered, so eos_hal_init() returns -1 by design; the + * board build is where that call is expected to return 0. + */ + +#include +#include + +static int failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + failures++; \ + } \ + } while (0) + +int main(void) +{ + /* Linking proves the EoS headers and libraries resolved. Calling proves + * the dispatch layer is present and does not fault with no backend. */ + int status = eos_hal_init(); + printf("eos_hal_init() -> %d%s\n", status, + status == 0 ? "" : " (no backend on a host build — expected)"); + + /* The HAL must tolerate calls with no backend rather than crashing: + * that is what lets this test run at all on a development machine. */ + eos_gpio_config_t led = { + .pin = 13, + .mode = EOS_GPIO_OUTPUT, + .pull = EOS_GPIO_PULL_NONE, + .speed = EOS_GPIO_SPEED_LOW, + }; + eos_gpio_init(&led); + eos_gpio_toggle(led.pin); + + /* A tick source is the one thing every board must provide; on the host + * it is monotonic and must not run backwards. */ + uint32_t t0 = eos_get_tick_ms(); + uint32_t t1 = eos_get_tick_ms(); + CHECK(t1 >= t0); + + if (failures == 0) { + printf("ok — {{PROJECT_NAME}} smoke test passed\n"); + return 0; + } + printf("%d check(s) failed\n", failures); + return 1; +} diff --git a/tests/ebuild/test_dispatch.py b/tests/ebuild/test_dispatch.py index aa71c394..c6061472 100644 --- a/tests/ebuild/test_dispatch.py +++ b/tests/ebuild/test_dispatch.py @@ -65,21 +65,25 @@ def test_cmake_takes_priority_over_makefile(self, tmp_path): class TestUnknownBackend: - """Unknown backends must raise ValueError rather than silently skip.""" + """Unknown backends must raise rather than silently skip. + + All three methods raise RuntimeError for this one condition, so a caller + can guard the whole dispatcher with a single ``except RuntimeError``. + """ def test_configure_unknown_raises(self, tmp_path): d = BackendDispatcher(tmp_path, tmp_path / "build") - with pytest.raises(ValueError, match="Unknown build backend 'bazel'"): + with pytest.raises(RuntimeError, match="bazel"): d.configure("bazel") def test_build_unknown_raises(self, tmp_path): d = BackendDispatcher(tmp_path, tmp_path / "build") - with pytest.raises(ValueError, match="Unknown build backend"): + with pytest.raises(RuntimeError, match="gradle"): d.build("gradle") def test_clean_unknown_raises(self, tmp_path): d = BackendDispatcher(tmp_path, tmp_path / "build") - with pytest.raises(ValueError, match="Unknown build backend"): + with pytest.raises(RuntimeError, match="Unknown build backend"): d.clean("scons") diff --git a/tests/unit/test_golden_path_commands.py b/tests/unit/test_golden_path_commands.py new file mode 100644 index 00000000..635b7c08 --- /dev/null +++ b/tests/unit/test_golden_path_commands.py @@ -0,0 +1,200 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""The MVP golden path must stay walkable. + +The MVP is defined as a developer running eight commands in order and +reaching a verified first firmware run. Each of those commands has to exist, +and the scaffolding has to produce a project they succeed on. These are the +regression guards for the steps that were missing or broken. +""" + +from pathlib import Path + +import pytest +import yaml +from click.testing import CliRunner + +from ebuild.cli.commands import ( + _cached_repo_libraries, + _record_board_selection, + _resolve_test_runner, + _selected_board, + cli, +) +from ebuild.core.config import TargetConfig + + +GOLDEN_PATH_COMMANDS = [ + "setup", "new", "configure", "build", "test", "flash", "monitor", +] + + +@pytest.mark.ebuild +class TestGoldenPathCommandsExist: + """Every command the MVP walk names must be registered on the CLI.""" + + @pytest.mark.parametrize("name", GOLDEN_PATH_COMMANDS) + def test_command_is_registered(self, name): + assert name in cli.commands, ( + f"'ebuild {name}' is step of the MVP golden path but is not a " + f"registered command" + ) + + @pytest.mark.parametrize("name", GOLDEN_PATH_COMMANDS) + def test_command_help_runs(self, name): + result = CliRunner().invoke(cli, [name, "--help"]) + assert result.exit_code == 0, result.output + + def test_configure_accepts_board(self): + """`ebuild configure --board ` is the documented step four.""" + result = CliRunner().invoke(cli, ["configure", "--help"]) + assert result.exit_code == 0 + assert "--board" in result.output + + +@pytest.mark.ebuild +class TestBoardSelection: + """--board has to outlive the configure process: the next step is a + bare `ebuild build`, which can only find the board by reading it back.""" + + def test_board_round_trips_through_eos_yaml(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + Path("eos.yaml").write_text("system:\n kind: baremetal\n", encoding="utf-8") + + _record_board_selection("stm32f4", _SilentLog()) + + assert _selected_board() == "stm32f4" + data = yaml.safe_load(Path("eos.yaml").read_text(encoding="utf-8")) + assert data["system"]["board"] == "stm32f4" + # Recording a board must not discard the rest of the file. + assert data["system"]["kind"] == "baremetal" + + def test_board_defaults_when_unset(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert _selected_board() == "generic" + + def test_board_without_a_project_is_an_error(self, tmp_path, monkeypatch): + """Refuse rather than write an eos.yaml into whatever directory the + developer happened to be standing in.""" + monkeypatch.chdir(tmp_path) + with pytest.raises(SystemExit): + _record_board_selection("stm32f4", _SilentLog()) + assert not Path("eos.yaml").exists() + + +@pytest.mark.ebuild +class TestTestTargetType: + """`test` is a distinct target type so `ebuild test` knows which binaries + to run without pattern-matching target names.""" + + def test_test_type_is_valid(self): + TargetConfig(name="t", target_type="test", sources=["a.c"]).validate() + + def test_unknown_type_still_rejected(self): + from ebuild.core.config import ConfigError + with pytest.raises(ConfigError): + TargetConfig(name="t", target_type="fixture", sources=["a.c"]).validate() + + def test_test_target_links_like_an_executable(self, tmp_path): + """A test target must produce a runnable binary, not an archive.""" + from types import SimpleNamespace + from ebuild.build.ninja_backend import NinjaBackend + from ebuild.core.config import ProjectConfig + + cfg = ProjectConfig( + name="p", version="1", source_dir=tmp_path, + targets=[TargetConfig(name="t_smoke", target_type="test", + sources=["t.c"])], + ) + NinjaBackend(cfg, tmp_path / "b", + SimpleNamespace(cc="cc", cxx="c++", ar="ar")).generate() + ninja = (tmp_path / "b" / "build.ninja").read_text(encoding="utf-8") + + edge = next(l for l in ninja.splitlines() + if l.startswith("build ") and "t_smoke" in l and ".o" not in l.split(":")[0]) + assert ": link " in edge + assert ": ar_rule" not in edge + + +@pytest.mark.ebuild +class TestExternalTestRunners: + """Projects that already have a runner keep using it.""" + + def test_ctest_wins_when_a_cmake_test_registry_exists(self, tmp_path): + build = tmp_path / "b" + build.mkdir() + (build / "CTestTestfile.cmake").touch() + name, argv, cwd = _resolve_test_runner(tmp_path, build, None) + assert name == "ctest" + assert "--output-on-failure" in argv + + def test_filter_is_passed_through(self, tmp_path): + build = tmp_path / "b" + build.mkdir() + (build / "CTestTestfile.cmake").touch() + _, argv, _ = _resolve_test_runner(tmp_path, build, "kernel") + assert argv[-2:] == ["-R", "kernel"] + + def test_make_test_requires_an_actual_test_target(self, tmp_path): + """A Makefile with no `test:` rule is not a test runner; claiming it + is would make `ebuild test` report success for a project with no + tests at all.""" + (tmp_path / "Makefile").write_text("all:\n\techo hi\n", encoding="utf-8") + assert _resolve_test_runner(tmp_path, tmp_path / "b", None) is None + + def test_make_test_is_used_when_declared(self, tmp_path): + (tmp_path / "Makefile").write_text("test:\n\techo ok\n", encoding="utf-8") + name, argv, _ = _resolve_test_runner(tmp_path, tmp_path / "b", None) + assert name == "make test" + + def test_no_runner_for_a_bare_directory(self, tmp_path): + assert _resolve_test_runner(tmp_path, tmp_path / "b", None) is None + + +@pytest.mark.ebuild +class TestCachedRepoLibraries: + """Headers alone leave the developer at an undefined-reference wall, so a + cached repo has to offer libraries too -- and must degrade quietly when + it cannot.""" + + def test_non_cmake_repo_yields_nothing(self, tmp_path): + assert _cached_repo_libraries(tmp_path) == ([], []) + + def test_prebuilt_archives_are_reused(self, tmp_path): + (tmp_path / "CMakeLists.txt").touch() + build = tmp_path / "_ebuild" / "core" + build.mkdir(parents=True) + (build / "libeos_kernel.a").touch() + + lib_dirs, libs = _cached_repo_libraries(tmp_path) + + assert lib_dirs == [build] + assert libs == ["eos_kernel"] + + def test_non_lib_archives_are_not_offered_as_l_flags(self, tmp_path): + """-l wants the name with the lib prefix stripped; an archive that is + not named libX.a has no -l spelling and must be skipped.""" + (tmp_path / "CMakeLists.txt").touch() + build = tmp_path / "_ebuild" + build.mkdir(parents=True) + (build / "strays.a").touch() + (build / "libgood.a").touch() + + _, libs = _cached_repo_libraries(tmp_path) + + assert libs == ["good"] + + +class _SilentLog: + """Minimal Logger stand-in: these tests assert on state, not output.""" + + verbose = False + + def _noop(self, *a, **k): + return None + + info = step = success = warning = debug = header = _noop + + def error(self, *a, **k): + return None diff --git a/tests/unit/test_ninja_backend.py b/tests/unit/test_ninja_backend.py index e973ca06..66cdf9c9 100644 --- a/tests/unit/test_ninja_backend.py +++ b/tests/unit/test_ninja_backend.py @@ -19,9 +19,9 @@ def _toolchain(): class TestNinjaBackendSharedLibrary(unittest.TestCase): """A shared_library target must link with the platform's shared-object - flag and get the same -L/-l wiring as executables. Previously it fell - through to the generic `link` rule with no flags and no libs at all, - silently producing a broken artifact.""" + flag and get the same -L/-l wiring as executables. Previously it used the + link_shared rule but emitted no ldflags and no libs line at all, so any + -L/-l from `uses` and any target ldflags were silently dropped.""" def setUp(self): self._tmpdir = tempfile.TemporaryDirectory() @@ -40,9 +40,12 @@ def test_shared_library_gets_shared_flag(self): shared_flag = "-dynamiclib" if sys.platform == "darwin" else "-shared" self.assertIn(shared_flag, ninja) - # It must use the `link` rule (compiler driver), not `ar_rule`. + # It must go through a compiler-driver rule, not the `ar` archiver. + # link_shared is that rule, and it carries the shared-object flag so + # the flag is never repeated in the edge's ldflags. lib_line = next(line for line in ninja.splitlines() if "libmylib" in line and line.startswith("build")) - self.assertIn(": link ", lib_line) + self.assertIn(": link_shared ", lib_line) + self.assertNotIn(": ar_rule", lib_line) def test_shared_library_gets_lib_dirs_and_libs(self): target = TargetConfig( @@ -62,8 +65,15 @@ def test_static_library_unaffected(self): ninja = self._generate("static", target) self.assertIn(": ar_rule", ninja) - self.assertNotIn("-shared", ninja) - self.assertNotIn("-dynamiclib", ninja) + + # The link_shared *rule* is always declared in the preamble, so the + # bare string "-shared" is present in every generated file. What must + # be absent is any build *edge* that uses it. + edges = [line for line in ninja.splitlines() if line.startswith("build ")] + self.assertTrue(edges, "no build edges were generated") + for edge in edges: + self.assertNotIn(": link_shared ", edge) + self.assertNotIn(": link ", edge) if __name__ == "__main__": diff --git a/tests/unit/test_sources_are_importable.py b/tests/unit/test_sources_are_importable.py new file mode 100644 index 00000000..36170552 --- /dev/null +++ b/tests/unit/test_sources_are_importable.py @@ -0,0 +1,140 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 EoS Contributors +"""Every shipped Python file must parse, and the CLI must import. + +Python does not parse a module until something imports it, so a syntax error +can sit on master indefinitely — reachable only when a developer runs the +command that touches it. That is what happened to ``ebuild/build/dispatch.py``, +which carried two consecutive ``else:`` blocks and made ``ebuild build`` fail +with a SyntaxError for every user, on the first build of a new project. + +Unit tests that mock the build layer would not have caught it. These tests +compile the files instead of trusting that some other test imported them. +""" + +from __future__ import annotations + +import ast +import importlib +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +PACKAGE_ROOT = REPO_ROOT / "ebuild" + + +def _python_sources() -> list[Path]: + return sorted( + p for p in PACKAGE_ROOT.rglob("*.py") + if "__pycache__" not in p.parts + ) + + +def test_package_root_exists() -> None: + assert PACKAGE_ROOT.is_dir(), f"{PACKAGE_ROOT} not found" + assert _python_sources(), "no Python sources discovered — the glob is wrong" + + +@pytest.mark.parametrize( + "source", _python_sources(), ids=lambda p: str(p.relative_to(PACKAGE_ROOT)) +) +def test_source_parses(source: Path) -> None: + """Parse every file. A SyntaxError here is a broken command for a user.""" + text = source.read_text(encoding="utf-8") + try: + ast.parse(text, filename=str(source)) + except SyntaxError as exc: + rel = source.relative_to(REPO_ROOT) + pytest.fail(f"{rel}:{exc.lineno}: {exc.msg}") + + +def test_cli_module_imports() -> None: + """The CLI must import cleanly, including its lazy command modules. + + Skipped where the runtime dependencies are absent — this test is about the + source being valid, not about the environment being provisioned. + + Importing ebuild.cli.commands alone is not enough: several commands import + their implementation inside the function body, which is exactly where the + dispatch.py breakage hid. + """ + pytest.importorskip("click", reason="ebuild CLI runtime dependency not installed") + + for module in ( + "ebuild.cli.commands", + "ebuild.build.dispatch", + "ebuild.deps.manager", + ): + importlib.import_module(module) + + +def test_cli_help_runs() -> None: + """`python -m ebuild --help` must exit 0. + + The end-to-end check: if this fails, the tool does not start at all. + """ + pytest.importorskip("click", reason="ebuild CLI runtime dependency not installed") + + result = subprocess.run( + [sys.executable, "-m", "ebuild", "--help"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=120, + ) + assert result.returncode == 0, ( + f"`python -m ebuild --help` exited {result.returncode}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + assert "Usage:" in result.stdout + + +@pytest.mark.parametrize("command", ["setup", "new", "configure", "build", "flash"]) +def test_mvp_commands_expose_help(command: str) -> None: + """Each command named in the MVP developer walk must at least start. + + `--help` forces click to construct the command and import whatever it + declares at module scope, so a broken command surfaces here rather than in + front of a new developer. + """ + pytest.importorskip("click", reason="ebuild CLI runtime dependency not installed") + + result = subprocess.run( + [sys.executable, "-m", "ebuild", command, "--help"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=120, + ) + assert result.returncode == 0, ( + f"`ebuild {command} --help` exited {result.returncode}\n{result.stderr}" + ) + + +def test_default_repo_branch_exists_upstream() -> None: + """The cached-repo default branch must actually exist on the remote. + + `ebuild setup` is the first command a new developer runs. It cloned + branch "main" while both repositories default to "master", so setup failed + for everyone, every time. A hardcoded branch name is a fact about a remote, + and facts about remotes go stale. + """ + from ebuild.deps import DEFAULT_CONFIG + + for name, cfg in DEFAULT_CONFIG["repos"].items(): + url, branch = cfg["url"], cfg["branch"] + result = subprocess.run( + ["git", "ls-remote", "--heads", url, branch], + capture_output=True, + text=True, + timeout=120, + ) + if result.returncode != 0: + pytest.skip(f"cannot reach {url}: {result.stderr.strip()}") + assert result.stdout.strip(), ( + f"default branch '{branch}' for repo '{name}' does not exist at {url}. " + "ebuild setup will fail for every new developer." + )