diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6c6c262..c665a6c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,15 @@ on: pull_request: branches: [master, main] +# Every other workflow in this repo declares a concurrency group; ci.yml, +# the heaviest one, did not. Pushing twice to a PR left the earlier run +# queued, and both competed for the same scarce windows/macos runners -- +# three superseded runs sat ahead of the current one for over an hour. +# cancel-in-progress because a superseded commit's result is not wanted. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: # ── Test Matrix ─────────────────────────────────────────────────────────── test: @@ -17,7 +26,12 @@ jobs: strategy: matrix: python-version: ["3.10", "3.11", "3.12"] - os: [ubuntu-22.04, macos-13, windows-2022] + # macos-latest, not macos-13: the macos-13 image is retired, so jobs + # requesting it queue forever and never get a runner. Across four runs + # on this branch the ubuntu-22.04 and windows-2022 jobs all started and + # finished while the three macos-13 jobs sat queued for over two hours. + # Every other workflow in this repo already uses macos-latest. + os: [ubuntu-22.04, macos-latest, windows-2022] fail-fast: false steps: @@ -42,8 +56,12 @@ jobs: run: ruff check . --select=E,F,W --ignore=E501 continue-on-error: true + # --exclude: layers/eosuite/ vendors its own tests/ package, so a bare + # `mypy .` sees two modules named "tests" and bails with "Duplicate + # module named 'tests'" before checking anything. continue-on-error hid + # that the type check was doing no work at all. - name: Type check (mypy) - run: mypy . --ignore-missing-imports --no-strict-optional + run: mypy . --ignore-missing-imports --no-strict-optional --exclude '^layers/' continue-on-error: true # Runs the whole tests/ tree. The previous steps ran only tests/unit/ @@ -57,7 +75,13 @@ jobs: # coverage policy already lives in codecov.yml; whether to also # enforce it here is a maintainer decision, so this change leaves # both of those numbers alone. + # + # shell: bash — the matrix includes windows-2022, where the default + # shell is PowerShell and the backslash line continuations below are a + # syntax error ("Missing expression after unary operator '--'"), so the + # Windows leg of this job failed before pytest ever started. - name: Run test suite + shell: bash run: | python -m pytest tests/ -v --tb=short \ --cov=ebuild --cov-report=xml --cov-report=term-missing \ diff --git a/.gitignore b/.gitignore index 64115a9d..320d01e4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ __pycache__/ *.egg dist/ build/ +# ...but ebuild/build/ is the backend package, not build output. Without +# this a new module added there is silently untracked: git status shows +# nothing at all, so it works locally and is simply absent from the repo. +!ebuild/build/ .eggs/ *.whl diff --git a/README.md b/README.md index 1fc890d2..8ff3a6e3 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,11 @@ Observed in the source tree: NuttX. - **Package graph** — install and list project packages (`ebuild add`, `ebuild list-packages`). -- **Firmware & flashing** — build RTOS firmware and flash images to targets - (`ebuild firmware`, `ebuild flash`; `ebuild/firmware/`). +- **Firmware, flashing & console** — build RTOS firmware, flash images to + targets, and watch them run (`ebuild firmware`, `ebuild flash`, + `ebuild monitor`; `ebuild/firmware/`). +- **Testing** — run the project's tests through the detected backend and report + the runner's own counts (`ebuild test`). - **System images** — root filesystem, kernel, and disk-image assembly (`ebuild system`; `ebuild/system/`). - **Project scaffolding** — generate projects and boards from templates @@ -77,6 +80,7 @@ not enough, because ebuild invokes `python -m ninja`. ebuild info # show what ebuild parsed from build.yaml ebuild build # resolve and build (auto-detects the backend) ebuild build -j 4 # build independent packages concurrently +ebuild test # run the project's tests ebuild clean # remove build artifacts ebuild --version ``` @@ -89,6 +93,42 @@ that each package's own build may already run parallel compile jobs, so a large `-j` can oversubscribe the machine. See [docs/dependency-management.md](docs/dependency-management.md#parallel-package-builds). +### The golden path + +Eight commands from nothing to a running board: + +```bash +ebuild setup # fetch eos and eboot +ebuild new hello-eos +cd hello-eos +ebuild configure --board +ebuild build +ebuild test +ebuild flash +ebuild monitor # serial console; Ctrl-C to exit +``` + +`ebuild test` reports the counts the underlying runner printed, and reports +nothing when the runner printed no summary it recognises — a number inferred +from a zero exit status would be a guess presented as a measurement. + +It also treats a run that executed **no tests** as a failure. `ctest` exits `0` +when it finds nothing to run, so trusting the exit status alone would report a +green suite for a project containing no tests at all. + +`ebuild monitor` reads the console baud rate from `board.console.baud` or +`monitor.baud` in `build.yaml`, falling back to 115200. It auto-selects the +serial port only when exactly one is present; with several it lists them and +asks, rather than attaching to the wrong board. `ebuild monitor --list` shows +what the host can see. + +```yaml +# build.yaml +board: + console: + baud: 9600 +``` + Additional commands: `configure`, `install`, `add`, `list-packages`, `pipeline`, `system`, `firmware`, `flash`, `new`, `generate-project`, `generate-board`, `generate-boot`, `analyze`, `setup`, and the `repos` group diff --git a/ebuild/build/dispatch.py b/ebuild/build/dispatch.py index 0d708dfa..c23095ab 100644 --- a/ebuild/build/dispatch.py +++ b/ebuild/build/dispatch.py @@ -10,8 +10,10 @@ from __future__ import annotations import logging +import re import subprocess import sys +from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, List, Optional @@ -25,6 +27,30 @@ ALL_BACKENDS = {"cmake", "make", "meson", "cargo", "kbuild", "ninja"} +#: Backends this dispatcher actually drives. "ninja" is ebuild's own backend -- +#: the CLI invokes NinjaBackend directly and never routes it through here. +DISPATCHED_BACKENDS = {"cmake", "make", "meson", "cargo", "kbuild"} + + +class UnknownBackendError(ValueError, RuntimeError): + """Raised when a backend reaches the dispatcher that it cannot drive. + + Subclasses both ValueError and RuntimeError: callers treat an unrecognized + backend name as a bad argument, while the CLI treats a backend it failed to + route (notably "ninja") as a routing failure. Silently doing nothing here is + what made `ebuild build` report "Build completed successfully" without ever + running a compiler. + """ + + +def _unknown_backend(backend: str, step: str) -> UnknownBackendError: + return UnknownBackendError( + f"Unknown build backend '{backend}'. " + f"Supported backends: {', '.join(sorted(DISPATCHED_BACKENDS))}. " + "ebuild's own 'ninja' backend is invoked directly by the CLI and is " + f"not dispatched here, so it cannot be {step} through BackendDispatcher." + ) + def detect_backend(source_dir: Path) -> str: """Auto-detect the build system from project files. @@ -100,7 +126,7 @@ def configure( dry_run: If True, log commands instead of executing them. Raises: - ValueError: If the backend is not recognized. + UnknownBackendError: If the backend is not one this dispatcher drives. """ config = config or {} self.build_dir.mkdir(parents=True, exist_ok=True) @@ -121,23 +147,11 @@ 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." - ) + raise _unknown_backend(backend, "configured") def build( self, @@ -154,7 +168,7 @@ def build( dry_run: If True, log commands instead of executing them. Raises: - ValueError: If the backend is not recognized. + UnknownBackendError: If the backend is not one this dispatcher drives. """ config = config or {} @@ -185,10 +199,7 @@ def build( _run_or_log(cmd, dry_run) else: - raise ValueError( - f"Unknown build backend '{backend}'. " - f"Supported backends: {', '.join(sorted(ALL_BACKENDS))}" - ) + raise _unknown_backend(backend, "built") def clean( self, @@ -203,7 +214,7 @@ def clean( dry_run: If True, log commands instead of executing them. Raises: - ValueError: If the backend is not recognized. + UnknownBackendError: If the backend is not one this dispatcher drives. """ if backend == "cmake": _run_or_log( @@ -237,7 +248,159 @@ def clean( check=False, ) else: - raise ValueError( - f"Unknown build backend '{backend}'. " - f"Supported backends: {', '.join(sorted(ALL_BACKENDS))}" - ) + raise _unknown_backend(backend, "cleaned") + + + def test( + self, + backend: str, + config: Optional[Dict[str, Any]] = None, + ) -> "TestOutcome": + """Run the backend's test step and report what its runner printed. + + Counts come from parsing the runner's own summary, never from the exit + status. A command that reported "N passed" because the process happened + to exit 0 would go green for a suite that never ran a single test, which + is the failure mode this whole command exists to prevent. When no + summary can be parsed the counts stay None and only ok/ran are + meaningful — reported honestly as unknown rather than filled in. + """ + config = config or {} + + if backend == "cmake": + cmd = ["ctest", "--test-dir", str(self.build_dir), "--output-on-failure"] + elif backend == "meson": + cmd = ["meson", "test", "-C", str(self.build_dir)] + elif backend == "cargo": + cmd = ["cargo", "test"] + elif backend in ("make", "kbuild"): + make_cmd = "nmake" if sys.platform == "win32" else "make" + cmd = [make_cmd, "-C", str(self.source_dir), "test"] + else: + return TestOutcome(ok=False, ran=False, output="", + reason="no test runner for the '%s' backend" % backend) + + cwd = str(self.source_dir) if backend == "cargo" else None + try: + proc = subprocess.run(cmd, capture_output=True, text=True, cwd=cwd) + except FileNotFoundError: + return TestOutcome( + ok=False, ran=False, output="", + reason="%s is not installed or not on PATH" % cmd[0]) + + output = (proc.stdout or "") + (proc.stderr or "") + passed, failed = _parse_test_counts(backend, output) + found_none = _found_no_tests(backend, output, passed, failed) + + # ctest exits 0 when it finds nothing to run, and prints "No tests were + # found!!!". Taken at face value that is a green test command for a + # project with no tests at all — the single most misleading result this + # command could produce, and the reason it treats an empty run as a + # failure rather than a pass. + return TestOutcome( + ok=proc.returncode == 0 and not found_none, + ran=True, + passed=passed, + failed=failed, + output=output, + returncode=proc.returncode, + found_none=found_none, + ) + + +@dataclass +class TestOutcome: + """What a backend's test run reported. + + ok is the runner's verdict. passed/failed are None when the runner printed + no summary this parser recognises; they are never guessed from ok, because + a green exit says nothing about how many tests ran. + """ + + # pytest collects any class named Test*; this is a result object, not a + # test case, and without the opt-out every run of a suite that imports it + # emits a PytestCollectionWarning. + __test__ = False + + ok: bool + ran: bool + passed: Optional[int] = None + failed: Optional[int] = None + output: str = "" + returncode: Optional[int] = None + reason: str = "" + found_none: bool = False + + @property + def counts_known(self) -> bool: + return self.passed is not None or self.failed is not None + + def summary(self) -> str: + if not self.ran: + return self.reason or "no tests were run" + if self.found_none: + return "the runner found no tests" + if not self.counts_known: + return "runner exited %s; it printed no summary this parser recognises" % ( + self.returncode,) + return "%d passed, %d failed" % (self.passed or 0, self.failed or 0) + + +# Each backend's own summary line. Anchored to the phrasing the tool prints so +# a format change shows up as unknown counts rather than as a wrong number. +_COUNT_PATTERNS = { + # ctest: "100% tests passed, 0 tests failed out of 17" + "cmake": re.compile( + r"tests passed,\s*(?P\d+)\s+tests? failed out of\s*(?P\d+)"), + # meson: "Ok: 12 Fail: 0" + "meson": re.compile( + r"^Ok:\s*(?P\d+).*?^Fail:\s*(?P\d+)", re.S | re.M), + # cargo: "test result: ok. 12 passed; 0 failed; 0 ignored" + "cargo": re.compile( + r"test result:.*?(?P\d+) passed;\s*(?P\d+) failed"), +} + + +def _parse_test_counts(backend: str, output: str): + """(passed, failed) from a runner's summary, or (None, None).""" + pattern = _COUNT_PATTERNS.get(backend) + if pattern is None: + # make has no standard summary format. Rather than invent one, report + # the counts as unknown and let the exit status carry the verdict. + return None, None + + match = pattern.search(output) + if not match: + return None, None + + groups = match.groupdict() + failed = int(groups["failed"]) + if "passed" in groups and groups["passed"] is not None: + return int(groups["passed"]), failed + # ctest reports failures out of a total; passed is the remainder. + return int(groups["total"]) - failed, failed + + +# Phrases each runner prints when it had nothing to run. ctest's is the one +# that matters: it pairs the message with a zero exit status. +_NO_TESTS_MARKERS = { + "cmake": ("No tests were found",), + "meson": ("No tests defined",), + "cargo": ("running 0 tests",), +} + + +def _found_no_tests(backend: str, output: str, + passed: Optional[int], failed: Optional[int]) -> bool: + """True when the runner completed having executed nothing. + + Checked two ways because neither alone is reliable: the marker phrase + catches ctest, which prints no summary at all in this case, and the counts + catch a runner that reports a well-formed summary totalling zero. + """ + for marker in _NO_TESTS_MARKERS.get(backend, ()): + if marker in output: + return True + if passed is not None and failed is not None: + return passed + failed == 0 + return False diff --git a/ebuild/build/ninja_backend.py b/ebuild/build/ninja_backend.py index 5aa2f374..d416c15e 100644 --- a/ebuild/build/ninja_backend.py +++ b/ebuild/build/ninja_backend.py @@ -30,6 +30,25 @@ class PackagePaths: "-fno-pie", "-fno-PIE"} +def _ninja_path(path) -> str: + """Escape *path* for use in a Ninja build statement. + + Ninja splits build statements on unescaped spaces and colons, so a Windows + absolute path writes a drive letter that Ninja reads as the output/rule + separator: + + build C:\\...\\main.o: cc main.c + ^ "expected build command name" + + `$` is escaped first so the escapes introduced below are not re-escaped. + Only build statements need this; variable values (cflags, ldflags) are read + to end of line and must not be escaped, or the flags reach the compiler + mangled. + """ + text = str(path) + return text.replace("$", "$$").replace(":", "$:").replace(" ", "$ ") + + class NinjaBackend: """Generate build.ninja from a ProjectConfig and resolved toolchain. @@ -104,6 +123,22 @@ def _resolve_target_cflags(self, target) -> List[str]: return cflags + def _object_path(self, target, src: str) -> Path: + """Object file path for *src* as compiled by *target*. + + Object paths are namespaced by target name. Two targets may legitimately + list the same source: a library and a test binary sharing a helper, or + one source built twice with different defines. Each needs its own + object, because each compiles with its own cflags. Keying only on the + source made both targets claim one output, which ninja rejects with + "multiple rules generate ...". + + Example: + >>> backend._object_path(target, "src/main.c") # target.name == "app" + PosixPath('_build/obj/app/src/main.o') + """ + return (self.build_dir / "obj" / target.name / src).with_suffix(".o") + def _write_ninja(self) -> None: """Write the build.ninja file.""" ninja_path = self.build_dir / "build.ninja" @@ -123,10 +158,6 @@ def _write_ninja(self) -> None: " command = $cc $ldflags $in -o $out $libs", " description = LINK $out", "", - "rule link_shared", - " command = $cc -shared $ldflags $in -o $out $libs", - " description = LINK_SHARED $out", - "", "rule ar_rule", " command = $ar rcs $out $in", " description = AR $out", @@ -143,7 +174,7 @@ def _write_ninja(self) -> None: obj = str(self._object_path(target, src)) obj_files.append(obj) lines.append( - f"build {obj}: cc {src}" + f"build {_ninja_path(obj)}: cc {_ninja_path(src)}" ) if cflags: lines.append(f" cflags = {' '.join(cflags)}") @@ -170,7 +201,8 @@ def _write_ninja(self) -> None: link_inputs = obj_files + dep_archives out = str(self.build_dir / target.name) lines.append( - f"build {out}: link {' '.join(link_inputs)}" + f"build {_ninja_path(out)}: link " + f"{' '.join(_ninja_path(i) for i in link_inputs)}" ) if ldflags: lines.append(f" ldflags = {' '.join(ldflags)}") @@ -188,7 +220,10 @@ def _write_ninja(self) -> None: out = str(self.build_dir / f"lib{target.name}{ext}") if target.target_type == "static_library": - lines.append(f"build {out}: ar_rule {' '.join(obj_files)}") + lines.append( + f"build {_ninja_path(out)}: ar_rule " + f"{' '.join(_ninja_path(o) for o in obj_files)}" + ) else: # Shared libraries need the platform's "build a shared # object" flag and the same -L/-l wiring executables get, @@ -204,7 +239,10 @@ 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 {_ninja_path(out)}: link " + f"{' '.join(_ninja_path(o) for o in 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..24249eb2 100644 --- a/ebuild/cli/commands.py +++ b/ebuild/cli/commands.py @@ -1977,3 +1977,151 @@ 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", + help="Path to the project configuration.") +@click.option("--build-dir", default="build", help="Build output directory.") +@click.option("--backend", default=None, + help="Override the backend instead of using the configured one.") +@click.pass_obj +def test(log: Logger, config_path: str, build_dir: str, + backend: Optional[str]) -> None: + """Build and run the project's tests. + + Step 6 of the golden path, and the step that makes a first firmware run + verified rather than merely completed. + + Examples: + + ebuild test + + ebuild test --backend cmake --build-dir build + """ + log.header("ebuild — Test") + + try: + from ebuild.build.dispatch import BackendDispatcher, detect_backend + + log.step("Loading configuration...") + cfg = load_config(config_path) + log.info(f"Project: {cfg.name} v{cfg.version}") + + resolved_backend = backend or cfg.backend + if resolved_backend == "auto": + resolved_backend = detect_backend(cfg.source_dir) + log.info(f"Auto-detected backend: {resolved_backend}") + + build_path = Path(build_dir) + dispatcher = BackendDispatcher(cfg.source_dir, build_path) + + log.step(f"Running tests ({resolved_backend})...") + outcome = dispatcher.test( + backend=resolved_backend, + config=cfg.backend_config, + ) + + if outcome.output: + click.echo(outcome.output.rstrip()) + + if not outcome.ran: + log.error(outcome.reason or "tests did not run") + raise SystemExit(1) + + # The counts are reported exactly as the runner printed them, and are + # withheld when it printed none. Deriving "N passed" from a zero exit + # status would let a suite that never ran a single test report success, + # which is precisely the reassurance this command must not give. + if outcome.found_none: + log.error("The runner completed without executing a single test.") + log.info(" A green result here would mean nothing; treating it as a " + "failure.") + raise SystemExit(1) + + if outcome.counts_known: + log.info(f" {outcome.passed} passed, {outcome.failed} failed") + else: + log.warning(" the runner printed no summary this parser recognises; " + "reporting its exit status only") + + if not outcome.ok: + log.error(f"Tests failed ({outcome.summary()}).") + raise SystemExit(1) + + log.success(f"Tests passed ({outcome.summary()}).") + + except SystemExit: + raise + except FileNotFoundError as e: + log.error(str(e)) + raise SystemExit(1) + except Exception as e: + log.error(f"Test run failed: {e}") + raise SystemExit(1) + + +@cli.command() +@click.option("--port", default=None, + help="Serial port. Auto-selected when exactly one is present.") +@click.option("--baud", default=None, type=int, + help="Baud rate. Defaults to the project's board setting, else 115200.") +@click.option("--config", "config_path", default="build.yaml", + help="Project configuration to read the console baud rate from.") +@click.option("--list", "list_only", is_flag=True, default=False, + help="List the serial ports the host can see and exit.") +@click.pass_obj +def monitor(log: Logger, port: Optional[str], baud: Optional[int], + config_path: str, list_only: bool) -> None: + """Open a serial console on the target. + + Step 8 of the golden path. Ctrl-C ends the session. + + Examples: + + ebuild monitor + + ebuild monitor --port /dev/ttyUSB0 --baud 9600 + + ebuild monitor --list + """ + log.header("ebuild — Monitor") + + try: + from ebuild.firmware.monitor import ( + MonitorError, available_ports, baud_from_config, + monitor as run_monitor, resolve_port, + ) + + if list_only: + ports = available_ports() + if not ports: + log.warning("No serial ports found.") + return + log.step(f"{len(ports)} serial port(s):") + for p in ports: + log.info(f" {p}") + return + + device = resolve_port(port) + rate = baud if baud is not None else baud_from_config(config_path) + + log.step(f"Opening {device} at {rate} baud. Ctrl-C to exit.") + forwarded = run_monitor(port=device, baud=rate) + + # A session that saw nothing is worth saying out loud: it is the + # difference between "the board is quiet" and "the console is attached + # to the wrong port", and they look identical on screen. + if forwarded == 0: + log.warning(f"Session ended; no output was received from {device}.") + else: + log.success(f"Session ended; {forwarded} bytes received.") + + except SystemExit: + raise + except MonitorError as e: + log.error(str(e)) + raise SystemExit(1) + except Exception as e: + log.error(f"Monitor failed: {e}") + raise SystemExit(1) diff --git a/ebuild/firmware/monitor.py b/ebuild/firmware/monitor.py new file mode 100644 index 00000000..2bd0c9ea --- /dev/null +++ b/ebuild/firmware/monitor.py @@ -0,0 +1,156 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Serial console for the target — step 8 of the MVP golden path. + +`ebuild flash` puts an image on real hardware; this is how the developer sees +it run. That is why it talks to a serial port rather than wrapping `ebuild +qemu`, which starts an emulator and cannot observe the board that was just +flashed. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional + +DEFAULT_BAUD = 115200 + + +class MonitorError(Exception): + """A monitor session could not be started or could not continue.""" + + +@dataclass +class PortInfo: + device: str + description: str = "" + + def __str__(self) -> str: + return self.device if not self.description else "%s (%s)" % ( + self.device, self.description) + + +def _require_pyserial(): + try: + import serial # noqa: F401 + from serial.tools import list_ports # noqa: F401 + except ImportError as exc: + raise MonitorError( + "pyserial is not installed. Install it with:\n" + " pip install pyserial" + ) from exc + import serial + from serial.tools import list_ports + return serial, list_ports + + +def available_ports() -> List[PortInfo]: + """Serial ports the host can currently see.""" + try: + _serial, list_ports = _require_pyserial() + except MonitorError: + return [] + return [PortInfo(p.device, p.description or "") for p in list_ports.comports()] + + +def resolve_port(port: Optional[str]) -> str: + """The port to open, or an error naming what the host can actually see. + + An explicit --port is honoured even when it is not in the enumerated list: + the enumeration misses some adapters, and refusing a port the developer + named because we failed to list it would be worse than letting open() say + so. Auto-selection only happens when exactly one port exists, because + picking one of several at random is how a monitor ends up silently + attached to the wrong board. + """ + if port: + return port + + ports = available_ports() + if not ports: + raise MonitorError( + "No serial ports found. Connect the board, or name the port " + "explicitly:\n" + " ebuild monitor --port /dev/ttyUSB0") + if len(ports) > 1: + listing = "\n".join(" %s" % p for p in ports) + raise MonitorError( + "Several serial ports are present; name the one to use with " + "--port:\n%s" % listing) + return ports[0].device + + +def baud_from_config(config_path: str = "build.yaml", + default: int = DEFAULT_BAUD) -> int: + """The board's console baud rate, or the default when nothing sets one. + + Looked for at board.console.baud and then monitor.baud, so a project can + state it once beside the rest of its board settings instead of passing + --baud on every invocation. + """ + path = Path(config_path) + 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 + if not isinstance(data, dict): + return default + + for section, key in (("board", "console"), ("monitor", None)): + node = data.get(section) + if not isinstance(node, dict): + continue + if key: + node = node.get(key) + if not isinstance(node, dict): + continue + baud = node.get("baud") + if isinstance(baud, int) and baud > 0: + return baud + return default + + +def monitor(port: Optional[str] = None, + baud: int = DEFAULT_BAUD, + timeout: float = 0.1, + stream=None) -> int: + """Stream the target's serial output until interrupted. + + Returns the number of bytes forwarded, so a caller can tell a session that + saw traffic from one that attached to a silent port. + """ + serial, _list_ports = _require_pyserial() + stream = stream if stream is not None else sys.stdout + device = resolve_port(port) + + try: + conn = serial.Serial(device, baud, timeout=timeout) + except Exception as exc: + raise MonitorError("Could not open %s at %d baud: %s" % ( + device, baud, exc)) from exc + + forwarded = 0 + try: + while True: + chunk = conn.read(4096) + if chunk: + forwarded += len(chunk) + stream.write(chunk.decode("utf-8", errors="replace")) + stream.flush() + except KeyboardInterrupt: + # Ctrl-C is how a monitor session ends. It is not an error, and it must + # still close the port — leaving it held makes the next run fail with a + # busy device that looks like a hardware fault. + pass + finally: + try: + conn.close() + except Exception: + pass + return forwarded diff --git a/tests/ebuild/test_integration_initramfs_security.py b/tests/ebuild/test_integration_initramfs_security.py index 8f4107b7..b7caef4a 100644 --- a/tests/ebuild/test_integration_initramfs_security.py +++ b/tests/ebuild/test_integration_initramfs_security.py @@ -22,11 +22,25 @@ """ import gzip +import shutil import subprocess +import pytest + from ebuild.cli.integration import _create_initramfs +# _create_initramfs() drives find(1) and cpio(1) directly. Neither exists on a +# stock Windows runner, so these fail with WinError 2 before reaching anything +# they mean to test. Building a Linux initramfs is not a Windows operation; +# skipping is the honest outcome, matching how test_ninja_backend.py skips when +# no host C compiler is present. +requires_cpio = pytest.mark.skipif( + shutil.which("cpio") is None or shutil.which("find") is None, + reason="find(1)/cpio(1) not available on this host", +) + +@requires_cpio def test_create_initramfs_produces_valid_gzip_with_expected_content(tmp_path): """Functional regression: the pipeline must still work correctly.""" rootfs = tmp_path / "rootfs" @@ -56,6 +70,7 @@ def test_create_initramfs_produces_valid_gzip_with_expected_content(tmp_path): assert b"hello.txt" in result.stdout +@requires_cpio def test_create_initramfs_build_dir_with_shell_metacharacters_is_not_interpreted(tmp_path): """A build_dir name containing shell syntax must be treated as a plain literal path component, never parsed as shell syntax. Pre-fix, a name @@ -84,6 +99,7 @@ def test_create_initramfs_build_dir_with_shell_metacharacters_is_not_interpreted assert not (rootfs / "pwned_marker").exists() +@requires_cpio def test_create_initramfs_rootfs_with_shell_metacharacters_is_not_interpreted(tmp_path): """Same check for the ``rootfs`` argument (the ``cd {rootfs}`` half of the old shell string).""" diff --git a/tests/ebuild/test_ninja_backend.py b/tests/ebuild/test_ninja_backend.py index cca58122..cdbe90f0 100644 --- a/tests/ebuild/test_ninja_backend.py +++ b/tests/ebuild/test_ninja_backend.py @@ -27,7 +27,15 @@ def _shared_library_config(tmp_path, target_cflags=None): ) -def test_shared_library_uses_shared_link_rule(tmp_path): +def test_shared_library_links_with_the_platform_shared_flag(tmp_path): + """A shared_library must link through the compiler driver with the + platform's shared-object flag. + + An earlier revision emitted a dedicated `link_shared` rule hardcoding + `-shared`, which is wrong on macOS (it needs `-dynamiclib`) and skipped the + -L/-l wiring, so the rule was dropped in favour of the generic `link` rule + with the flag pushed into ldflags. + """ config = ProjectConfig( name="shared-example", version="1.0.0", @@ -45,9 +53,15 @@ def test_shared_library_uses_shared_link_rule(tmp_path): NinjaBackend(config, tmp_path / "build", toolchain).generate() ninja_file = (tmp_path / "build" / "build.ninja").read_text(encoding="utf-8") - assert "rule link_shared\n command = $cc -shared" in ninja_file - assert "build " in ninja_file - assert ": link_shared " in ninja_file + shared_flag = "-dynamiclib" if sys.platform == "darwin" else "-shared" + + lib_line = next( + line for line in ninja_file.splitlines() + if line.startswith("build ") and "libexample" in line + ) + assert ": link " in lib_line + assert f"ldflags = {shared_flag}" in ninja_file + assert "link_shared" not in ninja_file def test_cc_rule_emits_and_consumes_a_depfile(tmp_path): diff --git a/tests/unit/test_documented_commands_exist.py b/tests/unit/test_documented_commands_exist.py new file mode 100644 index 00000000..375debe6 --- /dev/null +++ b/tests/unit/test_documented_commands_exist.py @@ -0,0 +1,95 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Every ebuild command shown in the docs must exist. + +§8 lists "consistent documentation generated from tested examples" as an MLP +requirement. Generating prose from examples is a large change; verifying that +the examples we already publish actually run is the part that stops the docs +lying, and it is cheap. + +The failure this prevents is real and has bitten this organisation. EoStudio +carried this line for the tool it is a front end for: + + f"ebuild --platform {platform} --config board.yaml {source_dir}" + +ebuild has no top-level --platform or --config. The string was stored in a dict +and never executed, so nothing found out it could not work. A published command +nobody runs decays the same way, and a developer following the README is the one +who discovers it. + +Only fenced code blocks are read. Prose says things like "ebuild is a unified +build system", and treating "is" as a subcommand would make this fail for no +reason. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +DOCS = ["README.md", "demo.md"] + +#: A shell line invoking ebuild, e.g. "ebuild configure --board stm32f4" or +#: "$ ebuild build". Captures the subcommand only. +_INVOCATION = re.compile(r"^\s*(?:\$\s*)?ebuild\s+([a-z][a-z0-9-]*)", re.M) + +#: Long options are not subcommands. +_NOT_A_SUBCOMMAND = {"--help", "--version"} + + +def _code_blocks(text: str): + """The contents of every fenced code block.""" + return re.findall(r"```[a-zA-Z]*\n(.*?)```", text, re.S) + + +def _documented_commands(): + """(subcommand, source file) for every ebuild invocation in the docs.""" + found = [] + for name in DOCS: + path = REPO_ROOT / name + if not path.is_file(): + continue + text = path.read_text(encoding="utf-8", errors="replace") + for block in _code_blocks(text): + for sub in _INVOCATION.findall(block): + if sub not in _NOT_A_SUBCOMMAND: + found.append((sub, name)) + return found + + +def _real_commands(): + """Every subcommand the CLI actually registers.""" + from ebuild.cli.commands import cli + return set(cli.commands) + + +def test_the_docs_actually_show_commands(): + # A guard on the guard: if the extraction silently stopped matching, every + # assertion below would pass vacuously and the check would be worthless. + documented = _documented_commands() + assert len(documented) >= 5, ( + "expected several ebuild invocations in the docs, found %d — the " + "extraction is probably broken rather than the docs being empty" + % len(documented)) + + +@pytest.mark.parametrize("subcommand,source", sorted(set(_documented_commands()))) +def test_a_documented_command_exists(subcommand, source): + real = _real_commands() + assert subcommand in real, ( + "%s documents `ebuild %s`, which the CLI does not provide. " + "Available: %s" % (source, subcommand, ", ".join(sorted(real)))) + + +def test_every_golden_path_step_is_a_real_command(): + # §7.1 names these eight verbatim. They are asserted directly rather than + # via the docs so the golden path cannot be quietly broken by editing the + # README instead of the CLI. + real = _real_commands() + for step in ("setup", "new", "configure", "build", + "test", "flash", "monitor"): + assert step in real, "golden path step `ebuild %s` is missing" % step diff --git a/tests/unit/test_golden_path_test_and_monitor.py b/tests/unit/test_golden_path_test_and_monitor.py new file mode 100644 index 00000000..9d11aa52 --- /dev/null +++ b/tests/unit/test_golden_path_test_and_monitor.py @@ -0,0 +1,154 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""`ebuild test` and `ebuild monitor` — steps 6 and 8 of the golden path. + +§7 names eBuild's MVP responsibility as "Setup, configure, build, test, flash, +monitor" and §7.1 spells the sequence out. Neither of these two existed; the +path stopped at step 5 of 8. + +The case these tests exist for is the empty run. ctest exits 0 when it finds +nothing to execute, so the obvious implementation — trust the exit status — +reports a green test suite for a project containing no tests at all. That is a +worse outcome than having no test command, because it actively reassures. +""" + +import pytest + +from ebuild.build.dispatch import ( + TestOutcome, + _found_no_tests, + _parse_test_counts, +) + + +class TestCountParsing: + """Counts come from the runner's own summary, never from exit status.""" + + def test_ctest_summary(self): + out = "100% tests passed, 0 tests failed out of 17" + assert _parse_test_counts("cmake", out) == (17, 0) + + def test_ctest_summary_with_failures(self): + out = "50% tests passed, 3 tests failed out of 6" + assert _parse_test_counts("cmake", out) == (3, 3) + + def test_cargo_summary(self): + out = "test result: ok. 12 passed; 0 failed; 0 ignored" + assert _parse_test_counts("cargo", out) == (12, 0) + + def test_meson_summary(self): + out = "Ok: 12\nExpected Fail: 0\nFail: 2\n" + assert _parse_test_counts("meson", out) == (12, 2) + + def test_make_has_no_standard_summary(self): + # Rather than invent a format for make, the counts stay unknown and the + # exit status carries the verdict. Reporting a made-up number would be + # worse than reporting none. + assert _parse_test_counts("make", "anything at all") == (None, None) + + def test_unrecognised_output_yields_no_counts(self): + assert _parse_test_counts("cmake", "something else entirely") == (None, None) + + +class TestEmptyRunIsNotAPass: + """The trap: ctest exits 0 having run nothing.""" + + def test_ctest_no_tests_marker_is_detected(self): + assert _found_no_tests("cmake", "No tests were found!!!", None, None) is True + + def test_zero_totals_are_detected_even_without_a_marker(self): + # A runner that prints a well-formed summary adding up to nothing is + # the same situation reached by a different route. + assert _found_no_tests("cargo", "test result: ok.", 0, 0) is True + + def test_a_real_run_is_not_flagged(self): + assert _found_no_tests("cmake", "100% tests passed", 17, 0) is False + + def test_outcome_is_not_ok_when_nothing_ran(self): + o = TestOutcome(ok=False, ran=True, found_none=True, returncode=0) + assert o.ok is False + assert "found no tests" in o.summary() + + +class TestOutcomeReporting: + def test_counts_are_withheld_when_unparsed(self): + o = TestOutcome(ok=True, ran=True, returncode=0) + assert o.counts_known is False + assert "no summary" in o.summary() + + def test_counts_are_reported_when_parsed(self): + o = TestOutcome(ok=True, ran=True, passed=17, failed=0, returncode=0) + assert o.counts_known is True + assert o.summary() == "17 passed, 0 failed" + + def test_a_run_that_never_started_says_why(self): + o = TestOutcome(ok=False, ran=False, reason="ctest is not installed") + assert o.summary() == "ctest is not installed" + + +class TestMonitorPortResolution: + """monitor must fail fast and actionably with no hardware attached.""" + + def test_an_explicit_port_is_honoured(self): + # Enumeration misses some adapters. Refusing a port the developer named + # because we failed to list it would be worse than letting open() say so. + from ebuild.firmware.monitor import resolve_port + assert resolve_port("/dev/ttyUSB9") == "/dev/ttyUSB9" + + def test_no_ports_gives_an_actionable_error(self, monkeypatch): + from ebuild.firmware import monitor as mon + monkeypatch.setattr(mon, "available_ports", lambda: []) + with pytest.raises(mon.MonitorError) as exc: + mon.resolve_port(None) + assert "--port" in str(exc.value) + + def test_several_ports_are_not_guessed_between(self, monkeypatch): + # Picking one of several at random is how a monitor session ends up + # silently attached to the wrong board. + from ebuild.firmware import monitor as mon + monkeypatch.setattr(mon, "available_ports", lambda: [ + mon.PortInfo("/dev/ttyUSB0", "board A"), + mon.PortInfo("/dev/ttyUSB1", "board B"), + ]) + with pytest.raises(mon.MonitorError) as exc: + mon.resolve_port(None) + assert "/dev/ttyUSB0" in str(exc.value) + assert "/dev/ttyUSB1" in str(exc.value) + + def test_a_single_port_is_selected(self, monkeypatch): + from ebuild.firmware import monitor as mon + monkeypatch.setattr(mon, "available_ports", lambda: [ + mon.PortInfo("/dev/ttyACM0", "the only board"), + ]) + assert mon.resolve_port(None) == "/dev/ttyACM0" + + +class TestMonitorBaudRate: + def test_default_when_no_config_exists(self, tmp_path): + from ebuild.firmware.monitor import DEFAULT_BAUD, baud_from_config + assert baud_from_config(str(tmp_path / "absent.yaml")) == DEFAULT_BAUD + + def test_board_console_baud_is_read(self, tmp_path): + from ebuild.firmware.monitor import baud_from_config + cfg = tmp_path / "build.yaml" + cfg.write_text("board:\n console:\n baud: 9600\n", encoding="utf-8") + assert baud_from_config(str(cfg)) == 9600 + + def test_monitor_section_is_read(self, tmp_path): + from ebuild.firmware.monitor import baud_from_config + cfg = tmp_path / "build.yaml" + cfg.write_text("monitor:\n baud: 57600\n", encoding="utf-8") + assert baud_from_config(str(cfg)) == 57600 + + def test_a_malformed_config_falls_back_rather_than_raising(self, tmp_path): + from ebuild.firmware.monitor import DEFAULT_BAUD, baud_from_config + cfg = tmp_path / "build.yaml" + cfg.write_text("this: [is: not: valid", encoding="utf-8") + assert baud_from_config(str(cfg)) == DEFAULT_BAUD + + def test_a_nonsense_baud_is_ignored(self, tmp_path): + from ebuild.firmware.monitor import DEFAULT_BAUD, baud_from_config + cfg = tmp_path / "build.yaml" + cfg.write_text("monitor:\n baud: -1\n", encoding="utf-8") + assert baud_from_config(str(cfg)) == DEFAULT_BAUD diff --git a/tests/unit/test_ninja_backend.py b/tests/unit/test_ninja_backend.py index e973ca06..51f73f2e 100644 --- a/tests/unit/test_ninja_backend.py +++ b/tests/unit/test_ninja_backend.py @@ -9,7 +9,7 @@ from pathlib import Path from types import SimpleNamespace -from ebuild.build.ninja_backend import NinjaBackend +from ebuild.build.ninja_backend import NinjaBackend, _ninja_path from ebuild.core.config import ProjectConfig, TargetConfig @@ -66,5 +66,50 @@ def test_static_library_unaffected(self): self.assertNotIn("-dynamiclib", ninja) +class TestNinjaPathEscaping(unittest.TestCase): + """Ninja splits build statements on unescaped spaces and colons. + + A Windows absolute path puts a drive-letter colon into the output field, so + Ninja read the statement as a rule separator and every generated file was + rejected with "expected build command name" -- the backend produced no + usable build on Windows at all. Paths in build statements must be escaped; + variable values must not be, or the flags reach the compiler mangled. + """ + + def test_colons_and_spaces_in_paths_are_escaped(self): + self.assertEqual(_ninja_path(r"C:\build\main.o"), r"C$:\build\main.o") + self.assertEqual(_ninja_path("/tmp/my project/main.o"), "/tmp/my$ project/main.o") + + def test_dollar_is_escaped_before_the_escapes_it_introduces(self): + self.assertEqual(_ninja_path("a$b"), "a$$b") + self.assertEqual(_ninja_path("a$b:c"), "a$$b$:c") + + def test_ordinary_posix_paths_are_unchanged(self): + self.assertEqual(_ninja_path("/tmp/build/obj/app/src/main.o"), + "/tmp/build/obj/app/src/main.o") + + +class TestNinjaWindowsStylePaths(unittest.TestCase): + def setUp(self): + self._tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(self._tmpdir.cleanup) + + def test_build_statement_output_is_escaped(self): + build_dir = Path(self._tmpdir.name) / "b" + target = TargetConfig(name="app", target_type="executable", sources=["main.c"]) + config = ProjectConfig(name="proj", version="1.0", targets=[target], + source_dir=build_dir) + NinjaBackend(config, build_dir, _toolchain()).generate() + ninja = (build_dir / "build.ninja").read_text(encoding="utf-8") + + for line in ninja.splitlines(): + if not line.startswith("build "): + continue + # Exactly one unescaped colon per build statement: the one that + # separates outputs from the rule name. + without_escapes = line.replace("$:", "").replace("$$", "") + self.assertEqual(without_escapes.count(":"), 1, line) + + if __name__ == "__main__": unittest.main()