Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,32 @@
## [Unreleased]

### Fixed
- **`ebuild test` now finds Windows test binaries.** The Ninja edge for a
native `type: test` target already carried the platform suffix
(`_exe_suffix()` names it `<name>.exe` on Windows), but `ebuild test`
rebuilt the path itself without that suffix, asked ninja to build
`<name>`, and then looked for that unsuffixed path. Ninja reported an
unknown target and the runner reported "built, but no binary". Both
steps now go through the same `executable_output_path()` the edge itself
uses (`ebuild/build/ninja_backend.py`, `ebuild/cli/commands.py`).
- **`ebuild package` now finds the Windows binary too.** It looked up
`<build_dir>/<name>` directly instead of through `executable_output_path()`,
so on Windows it reported "No built artifact" after a build that had
succeeded. Now uses the same helper as `ebuild test`
(`ebuild/cli/commands.py`).
- **`ebuild build`'s flash/RAM report went silent on Windows.**
`_report_footprint` also looked up `<build_dir>/<name>` directly, so on
Windows the artifact was never found and the function returned with no
diagnostic — the report just never appeared, with no indication it was
skipped rather than not applicable. Now uses `executable_output_path()`
and logs at debug level when it has nothing to measure
(`ebuild/cli/commands.py`).
- **`pytest` collection no longer aborts on Windows without gcc.**
`tests/ebuild/test_build_dir_resolution.py` evaluated
`subprocess.run(["gcc", "--version"])` in a `skipif`. When gcc is not
installed, Windows raises `FileNotFoundError` instead of a non-zero
returncode, which pytest treats as a collection ERROR and stops the
suite. The probe now uses `shutil.which` and catches `OSError`.
- **A path containing a space produced a silently wrong `build.ninja`.** Paths
were written into build statements unescaped, but Ninja ends the output list
at the first unescaped `:` and splits on unescaped spaces. A build directory
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ no `add_test()` still produces a `CTestTestfile.cmake` — so the runner is foun
ctest prints `No tests were found!!!`, and trusting the exit status would report
a green suite for a project with no tests at all.

Projects that declare `type: test` targets in `build.yaml` are built and run
directly (no ctest/cargo/meson). On Windows those binaries are named
`<target>.exe`, matching the Ninja edge; `ebuild test` now asks ninja for that
path rather than the unsuffixed name.

## Test

```bash
Expand Down
39 changes: 38 additions & 1 deletion TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,44 @@ Status is one of: `todo`, `in-progress`, `blocked`, `review`, `done`.

| ID | Task | Owner | Mode | Status | Depends on |
|----|------|-------|------|--------|------------|
| T-002 | Fix Windows Ninja test-target path parsing | backend | Maintenance | todo | none |
| T-002 | Fix Windows Ninja test-target path parsing | backend | Maintenance | review | none |
| T-003 | `ebuild package` looks for the unsuffixed binary on Windows (`_build/app` rather than `_build/app.exe`) | backend | Maintenance | review | none |
| T-004 | `_report_footprint` (the flash/RAM report `ebuild build` prints) looks for the unsuffixed binary on Windows, and fails silently rather than logging why | backend | Maintenance | review | none |
| T-005 | Move `executable_output_path()` out of the Ninja-specific backend into a backend-neutral module (`ebuild/build/layout.py`), re-exported from `ninja_backend` for compatibility | backend | Maintenance | todo | none |

### Evidence (self-reported by implementer; pending independent review per `.ai/reviewer.md` — "if you implemented it, you do not approve it")

- **T-002**: `ebuild/cli/commands.py:2624` and `:2633` (the Ninja target-list
argv and the binary that gets executed in `_run_native_tests`) both use
`executable_output_path()` instead of rebuilding the path themselves.
Covered by
`tests/unit/test_golden_path_commands.py::TestTestTargetType::test_native_runner_asks_ninja_for_the_linked_binary`,
which forces `_exe_suffix()` to `.exe`; confirmed to fail against the
pre-fix argv construction and pass against the fix.
- **T-003**: Was deferred out of T-002 for reviewability, then folded back in
once `executable_output_path()` existed: `ebuild/cli/commands.py` now calls
it at the `package` artifact lookup instead of `Path(build_dir) / name`.
Covered by
`tests/unit/test_package_efw.py::TestCommandPacks::test_it_finds_the_windows_suffixed_artifact`,
which forces `_exe_suffix()` to `.exe` so it exercises the Windows path on
any host, and also caught the fix's ripple effect on the suite's own
real-Windows host: existing `test_package_efw.py` fixtures wrote an
unsuffixed stand-in binary, which the fixed lookup could no longer find
natively (`_exe_suffix()` returns `.exe` there unforced), so those
fixtures now build the artifact through `executable_output_path()` too.
- **T-004**: Third of three `build_dir / name` call sites, and the only one
with no diagnostic on the early-return path. `ebuild/cli/commands.py:516`
now uses `executable_output_path()`, and the bare `return` on a missing
artifact now logs at debug level, matching the function's other two early
exits. Covered by
`tests/unit/test_footprint.py::TestCLIFootprintReport::test_looks_up_the_windows_suffixed_artifact`,
which forces `_exe_suffix()` to `.exe` and chdirs into `tmp_path` so the
process cwd's own `eos.yaml`/`board.yaml`, if any, cannot change what it
measures; confirmed to fail against the pre-fix lookup (no report
emitted) and pass against the fix.
- **Suite result** (single run, both changes present, this Windows host):
**560 passed, 6 skipped, exit code 0**. Supersedes any other count quoted
for T-003 or T-004 elsewhere in this repo or in PR #110's description.

## Completed

Expand Down
27 changes: 26 additions & 1 deletion ebuild/build/ninja_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,31 @@ def _exe_suffix() -> str:
return ".exe" if sys.platform == "win32" else ""


def executable_output_path(build_dir: Path, target_name: str) -> Path:
"""Return the linked binary path NinjaBackend emits for *target_name*.

Args:
build_dir: Directory that contains ``build.ninja`` and the linked
outputs.
target_name: The ``name`` of an ``executable`` or ``test`` target.

Returns:
``build_dir / target_name`` on POSIX, or that path with ``.exe``
appended on Windows -- the same path the Ninja edge in
``_write_ninja`` already names via ``_exe_suffix()``. A consumer
that rebuilds this path independently instead of calling this
function can silently drop the suffix and go looking for a binary
the edge never produced.

Example:
>>> from pathlib import Path
>>> executable_output_path(Path("_build"), "hello").name in (
... "hello", "hello.exe")
True
"""
return Path(build_dir) / (target_name + _exe_suffix())


def _shared_flag() -> str:
"""The flag that makes the compiler driver emit a shared object.

Expand Down Expand Up @@ -230,7 +255,7 @@ def _write_ninja(self) -> None:

link_inputs = obj_files + dep_archives
out = escape_ninja_path(
self.build_dir / (target.name + _exe_suffix()))
executable_output_path(self.build_dir, target.name))
lines.append(
f"build {out}: link " f"{' '.join(link_inputs)}"
)
Expand Down
20 changes: 14 additions & 6 deletions ebuild/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@
import yaml

from ebuild import __version__
from ebuild.build.ninja_backend import NinjaBackend, PackagePaths
from ebuild.build.ninja_backend import (
NinjaBackend,
PackagePaths,
executable_output_path,
)
from ebuild.build.toolchain import resolve_toolchain
from ebuild.cli.integration import register_commands as _register_integration_commands
from ebuild.cli.logger import Logger
Expand Down Expand Up @@ -509,8 +513,9 @@ def _report_footprint(cfg: "ProjectConfig", build_path: Path, log: Logger) -> No
if not binaries:
return

artifact = build_path / binaries[0].name
artifact = executable_output_path(build_path, binaries[0].name)
if not artifact.is_file():
log.debug(f"no artifact at {artifact}; skipping footprint")
return

prefix = getattr(cfg.toolchain, "target", None) or "host"
Expand Down Expand Up @@ -2526,7 +2531,7 @@ def package(log: Logger, config_path: str, build_dir: str,
log.error("No executable target in build.yaml — nothing to package.")
raise SystemExit(1)

artifact = Path(build_dir) / binaries[0].name
artifact = executable_output_path(Path(build_dir), binaries[0].name)
if not artifact.is_file():
log.error(f"No built artifact at {artifact}. Run 'ebuild build' first.")
raise SystemExit(1)
Expand Down Expand Up @@ -2609,11 +2614,14 @@ def _run_native_tests(
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.
# it with -f from the project root, so the same form is used here. The
# path must include the platform suffix: on Windows the edge is
# ``<name>.exe``, and asking ninja to build ``<name>`` is an unknown
# target.
argv = (
ninja_command()
+ ["-f", str(build_path / "build.ninja")]
+ [str(build_path / t.name) for t in selected]
+ [str(executable_output_path(build_path, t.name)) for t in selected]
)
result = subprocess.run(argv)
if result.returncode != 0:
Expand All @@ -2622,7 +2630,7 @@ def _run_native_tests(

failures: List[str] = []
for target in selected:
binary = build_path / target.name
binary = executable_output_path(build_path, target.name)
if not binary.is_file():
log.error(f"{target.name}: built, but no binary at {binary}")
failures.append(target.name)
Expand Down
27 changes: 26 additions & 1 deletion tests/ebuild/test_build_dir_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from __future__ import annotations

import os
import shutil
import subprocess
import textwrap
from pathlib import Path
Expand All @@ -35,6 +36,7 @@
from click.testing import CliRunner

from ebuild.cli import commands
from tests.support import gcc_is_missing


pytestmark = pytest.mark.needs_yaml
Expand Down Expand Up @@ -241,9 +243,32 @@ def test_configure_and_build_from_outside_agree_on_the_build_dir(

# ── end to end, with a real compiler ────────────────────────

# `gcc_is_missing()` lives in tests/support.py, shared with
# tests/unit/test_footprint.py -- both need to know whether the host can
# link a real binary before running a skipif against it.


def test_gcc_probe_does_not_raise_when_gcc_cannot_start(monkeypatch):
"""Collection must stay a skip, not an ERROR, if gcc is absent."""
monkeypatch.setattr(
shutil, "which", lambda name: r"C:\missing\gcc.exe"
)

def boom(*args, **kwargs):
raise FileNotFoundError(2, "The system cannot find the file specified")

monkeypatch.setattr(subprocess, "run", boom)
assert gcc_is_missing() is True


def test_gcc_probe_reports_missing_when_which_finds_nothing(monkeypatch):
"""The common case on a bare Windows host: no gcc on PATH at all."""
monkeypatch.setattr(shutil, "which", lambda name: None)
assert gcc_is_missing() is True


@pytest.mark.skipif(
subprocess.run(["gcc", "--version"], capture_output=True).returncode != 0,
gcc_is_missing(),
reason="needs a working gcc to link the executable",
)
def test_end_to_end_build_from_outside_produces_the_binary(tmp_path, monkeypatch):
Expand Down
28 changes: 28 additions & 0 deletions tests/support.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 EoS Project

"""Shared pytest helpers for the ebuild test suite."""

import shutil
import subprocess


def gcc_is_missing() -> bool:
"""True when this host cannot run gcc.

``subprocess.run(['gcc', ...])`` raises ``FileNotFoundError`` on Windows
when gcc is not installed. Evaluating that directly in ``skipif`` is not
a skip: it aborts collection of the file and, with default pytest, the
suite. Used by both ``tests/ebuild/test_build_dir_resolution.py`` and
``tests/unit/test_footprint.py`` -- previously each carried its own copy
of this probe, one of them the version this existed to fix.
"""
gcc = shutil.which("gcc")
if gcc is None:
return True
try:
return subprocess.run(
[gcc, "--version"], capture_output=True
).returncode != 0
except OSError:
return True
60 changes: 60 additions & 0 deletions tests/unit/test_footprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
measure,
over_budget,
)
from tests.support import gcc_is_missing


class TestAccounting:
Expand All @@ -57,6 +58,10 @@ def test_a_pure_bss_buffer_costs_ram_but_not_flash(self):


class TestMeasure:
@pytest.mark.skipif(
gcc_is_missing(),
reason="needs a working gcc to link the executable",
)
def test_measures_a_real_binary(self, tmp_path):
src = tmp_path / "m.c"
src.write_text("static char buf[4096];\nint main(void){return buf[0];}\n")
Expand Down Expand Up @@ -203,6 +208,61 @@ def test_units(self, n, expected):
assert format_size(n) == expected


class TestCLIFootprintReport:
"""`_report_footprint` (`ebuild/cli/commands.py`) is the CLI-level
consumer wired into `ebuild build`. Like `ebuild test` and `ebuild
package`, it has to look up the same suffixed binary NinjaBackend
linked."""

def test_looks_up_the_windows_suffixed_artifact(self, tmp_path, monkeypatch):
"""On Windows the linked binary is `<name>.exe`; the report must
look there, not at the unsuffixed name NinjaBackend never produces
on that platform -- the third of three call sites this rule applies
to, and the only one that failed silently.

`_exe_suffix()` is forced to `.exe` rather than switching on
`sys.platform`, so this exercises the Windows path -- and fails
against the pre-fix code -- on any host the suite runs on.

`_report_footprint` also reads `eos.yaml`/`board.yaml` relative to
the process cwd (via `_selected_board`/`_board_config`), which this
test does not exercise -- chdir into `tmp_path` so a real cwd
carrying either file cannot change what this test measures.
"""
from types import SimpleNamespace

from ebuild.build import ninja_backend
from ebuild.cli import commands
from ebuild.core.config import ProjectConfig, TargetConfig

monkeypatch.chdir(tmp_path)
monkeypatch.setattr(ninja_backend, "_exe_suffix", lambda: ".exe")
monkeypatch.setattr(
"ebuild.build.footprint.find_size_tool",
lambda prefix: "/usr/bin/size")
monkeypatch.setattr(
"ebuild.build.footprint.measure",
lambda artifact, tool=None: Footprint(text=1000, data=200, bss=500))

cfg = ProjectConfig(
name="app", version="1", source_dir=tmp_path,
targets=[TargetConfig(name="app", target_type="executable",
sources=["a.c"])],
)
build = tmp_path / "b"
build.mkdir()
(build / "app.exe").write_bytes(b"\x7fELF")

logs = []
log = SimpleNamespace(
info=logs.append, debug=lambda *a, **k: None,
warning=lambda *a, **k: None)

commands._report_footprint(cfg, build, log)

assert any("Flash" in line for line in logs), logs


def _completed(returncode=0, stdout="", stderr=""):
return subprocess.CompletedProcess(
args=["size"], returncode=returncode, stdout=stdout, stderr=stderr)
Expand Down
Loading