From cdfdb976c84e32c8224eb9db3ed478264d534b5c Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Mon, 27 Apr 2026 22:32:19 +0200 Subject: [PATCH] test runner, cross-target runners, lib/exe split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - subprocess-isolated test runner (replaces V1 dlopen-RunTest); Pass/Fail/Crash/Timeout/Skipped outcomes via :Test partition - TestRunner abstraction with command templates: Local, Ssh, SshWin (cmd.exe-shell), QemuUser, FromEnv; probe-based skip when runner unreachable - transitive PCM-path propagation in Build(); resolveImport walks deps recursively; depResults cache keyed by PcmDir() so per-target builds don't collide - cfg.sysroot threaded through BuildStdPcm + base compile/link command (enables aarch64 cross via Arch Linux ARM rootfs) - lib + exe split: project.cpp defines crafterBuildLib (LibraryStatic) + crafterBuildExe (Executable depending on it); build.sh produces lib/libcrafter-build.a alongside bin/crafter-build for downstream static-link consumers - Windows DLL+launcher: CRAFTER_API macro, /EXPORT flag for project.dll's CrafterBuildProject; Crafter::Run as the real entry point with main.cpp as a thin wrapper - 18 tests: HelloWorld/WithModule/Defines/CrossProjectModule/ Diamond × (Linux + sshwin:winvm), plus Incremental, BuildError, Libraries, RunnerClassification, QemuUser, SshRunner, WindowsViaSsh, CrossArchAarch64 - single ./bin/crafter-build test runs everything; Windows variants skip gracefully if winvm SSH alias unreachable Co-Authored-By: Claude Opus 4.7 --- .gitignore | 1 + PKGBUILD | 2 + README.md | 126 ++++- build.cmd | 26 +- build.sh | 24 + implementations/Crafter.Build-Clang.cpp | 66 ++- implementations/Crafter.Build-Platform.cpp | 72 ++- implementations/Crafter.Build-Test.cpp | 475 ++++++++++++++++++ interfaces/Crafter.Build-Api.h | 11 + interfaces/Crafter.Build-Clang.cppm | 50 +- interfaces/Crafter.Build-External.cppm | 4 +- interfaces/Crafter.Build-Implementation.cppm | 8 +- interfaces/Crafter.Build-Interface.cppm | 14 +- interfaces/Crafter.Build-Platform.cppm | 10 +- interfaces/Crafter.Build-Shader.cppm | 8 +- interfaces/Crafter.Build-Test.cppm | 46 ++ interfaces/Crafter.Build.cppm | 3 +- project.cpp | 208 ++++++-- tests/BuildError.cpp | 46 ++ tests/CrossArchAarch64.cpp | 86 ++++ tests/Incremental.cpp | 76 +++ tests/Libraries.cpp | 66 +++ tests/QemuUser.cpp | 65 +++ tests/RunnerClassification.cpp | 61 +++ tests/ShouldCompile.cpp | 29 -- tests/SshRunner.cpp | 63 +++ tests/TestUtil.h | 36 ++ tests/WindowsViaSsh.cpp | 85 ++++ tests/fixtures/build-error/main.cpp | 3 + tests/fixtures/build-error/project.cpp | 22 + tests/fixtures/cross-arch-aarch64/main.cpp | 6 + tests/fixtures/cross-arch-aarch64/project.cpp | 24 + tests/fixtures/cross-project/lib/Foo.cppm | 5 + tests/fixtures/cross-project/main.cpp | 7 + tests/fixtures/defines/main.cpp | 8 + tests/fixtures/diamond/B/B.cppm | 6 + tests/fixtures/diamond/C/C.cppm | 6 + tests/fixtures/diamond/X/X.cppm | 5 + tests/fixtures/diamond/main.cpp | 9 + tests/fixtures/hello-world/main.cpp | 7 + .../incremental/interfaces/Greeter.cppm | 6 + tests/fixtures/incremental/main.cpp | 7 + tests/fixtures/incremental/project.cpp | 22 + .../fixtures/libraries/greetlib/GreetLib.cppm | 6 + tests/fixtures/libraries/main.cpp | 8 + tests/fixtures/libraries/mathlib/MathLib.cppm | 5 + tests/fixtures/libraries/project.cpp | 47 ++ tests/fixtures/qemu-runner/project.cpp | 27 + tests/fixtures/qemu-runner/tests/Hello.cpp | 6 + .../runner-classification/project.cpp | 33 ++ .../runner-classification/tests/Crash.cpp | 4 + .../runner-classification/tests/Fail.cpp | 1 + .../runner-classification/tests/Hang.cpp | 3 + .../runner-classification/tests/Pass.cpp | 1 + tests/fixtures/ssh-runner/project.cpp | 27 + tests/fixtures/ssh-runner/tests/Hello.cpp | 6 + tests/fixtures/windows-via-ssh/main.cpp | 6 + tests/fixtures/windows-via-ssh/project.cpp | 30 ++ .../with-module/interfaces/Greeter.cppm | 6 + tests/fixtures/with-module/main.cpp | 7 + 60 files changed, 2029 insertions(+), 104 deletions(-) create mode 100644 implementations/Crafter.Build-Test.cpp create mode 100644 interfaces/Crafter.Build-Api.h create mode 100644 interfaces/Crafter.Build-Test.cppm create mode 100644 tests/BuildError.cpp create mode 100644 tests/CrossArchAarch64.cpp create mode 100644 tests/Incremental.cpp create mode 100644 tests/Libraries.cpp create mode 100644 tests/QemuUser.cpp create mode 100644 tests/RunnerClassification.cpp delete mode 100644 tests/ShouldCompile.cpp create mode 100644 tests/SshRunner.cpp create mode 100644 tests/TestUtil.h create mode 100644 tests/WindowsViaSsh.cpp create mode 100644 tests/fixtures/build-error/main.cpp create mode 100644 tests/fixtures/build-error/project.cpp create mode 100644 tests/fixtures/cross-arch-aarch64/main.cpp create mode 100644 tests/fixtures/cross-arch-aarch64/project.cpp create mode 100644 tests/fixtures/cross-project/lib/Foo.cppm create mode 100644 tests/fixtures/cross-project/main.cpp create mode 100644 tests/fixtures/defines/main.cpp create mode 100644 tests/fixtures/diamond/B/B.cppm create mode 100644 tests/fixtures/diamond/C/C.cppm create mode 100644 tests/fixtures/diamond/X/X.cppm create mode 100644 tests/fixtures/diamond/main.cpp create mode 100644 tests/fixtures/hello-world/main.cpp create mode 100644 tests/fixtures/incremental/interfaces/Greeter.cppm create mode 100644 tests/fixtures/incremental/main.cpp create mode 100644 tests/fixtures/incremental/project.cpp create mode 100644 tests/fixtures/libraries/greetlib/GreetLib.cppm create mode 100644 tests/fixtures/libraries/main.cpp create mode 100644 tests/fixtures/libraries/mathlib/MathLib.cppm create mode 100644 tests/fixtures/libraries/project.cpp create mode 100644 tests/fixtures/qemu-runner/project.cpp create mode 100644 tests/fixtures/qemu-runner/tests/Hello.cpp create mode 100644 tests/fixtures/runner-classification/project.cpp create mode 100644 tests/fixtures/runner-classification/tests/Crash.cpp create mode 100644 tests/fixtures/runner-classification/tests/Fail.cpp create mode 100644 tests/fixtures/runner-classification/tests/Hang.cpp create mode 100644 tests/fixtures/runner-classification/tests/Pass.cpp create mode 100644 tests/fixtures/ssh-runner/project.cpp create mode 100644 tests/fixtures/ssh-runner/tests/Hello.cpp create mode 100644 tests/fixtures/windows-via-ssh/main.cpp create mode 100644 tests/fixtures/windows-via-ssh/project.cpp create mode 100644 tests/fixtures/with-module/interfaces/Greeter.cppm create mode 100644 tests/fixtures/with-module/main.cpp diff --git a/.gitignore b/.gitignore index 328b7c5..a47aaf6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ build/ bin/ +lib/*.a share/crafter-build/ .claude \ No newline at end of file diff --git a/PKGBUILD b/PKGBUILD index 622311c..326ac2b 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -20,6 +20,8 @@ build() { package() { cd "$startdir" install -Dm755 bin/crafter-build "$pkgdir/usr/bin/crafter-build" + install -Dm644 lib/libcrafter-build.a "$pkgdir/usr/lib/libcrafter-build.a" install -dm755 "$pkgdir/usr/share/crafter-build" install -m644 share/crafter-build/*.cppm "$pkgdir/usr/share/crafter-build/" + install -m644 share/crafter-build/*.h "$pkgdir/usr/share/crafter-build/" } diff --git a/README.md b/README.md index 752f3fb..9082b48 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A C++26 modules build system. Project descriptions are written in C++ — no JSO ## Status - **Linux**: working end-to-end, packageable as a distro package. Verified on Arch Linux via [PKGBUILD](PKGBUILD) in a fresh container. -- **Windows**: bootstrap builds and runs; cross-EXE/DLL symbol-resolution work in progress. +- **Windows**: working end-to-end. Bootstrap (`build.cmd`) produces `crafter-build.exe` + `crafter-build.dll` + `crafter-build.lib`; user `project.cpp` links against the import lib via the host-controlled DLL boundary. ## Quick start (Linux) @@ -79,12 +79,15 @@ Clones live at `~/.cache/crafter.build/external/-/`, keyed by `(url, ``` /bin/crafter-build # the executable +/lib/libcrafter-build.a # static archive for downstream consumers /share/crafter-build/*.cppm # module sources (rebuilt on user machine on first run) ~/.cache/crafter.build/-/ # std.pcm + Crafter.Build*.pcm, built locally ``` Sources ship instead of PCMs because libc++ ABI varies between machines — the user's machine builds its own PCMs against its own libc++ on first run (~6-8s one-time cost), making the install resilient to libc++ version differences across distros. +The static archive is for downstream projects that want to use Crafter.Build *as a library* (construct `Configuration` objects, call `Build()`, etc., directly in C++) without invoking the CLI. Link with `-lcrafter-build` plus the project's external dep libs (`-lSPIRV -lglslang -lOSDependent -lMachineIndependent -lglslang-default-resource-limits -lGenericCodeGen`) and `-ldl` (for `LoadProject`'s dlopen path on Linux). On Windows the equivalent is `bin/crafter-build-static.lib`. + ## Build incrementality Per-import precise tracking for both within-project and cross-project module dependencies: @@ -95,16 +98,128 @@ Per-import precise tracking for both within-project and cross-project module dep Diamond deps (`A → {B, C}; B → X; C → X`) build `X` exactly once via a `std::shared_future` cache. +## Tests + +Tests are normal executables — any `int main()`, exit code `0` = pass, nonzero = fail, captured stdout+stderr is the failure message. There's no Crafter-specific entry-point convention, so a GoogleTest/Catch2/doctest binary works as-is. + +Declare tests by pushing onto `cfg.tests` in your `project.cpp`: + +```cpp +Test t; +t.config.path = "./"; +t.config.name = "VectorMath"; +t.config.outputName = "VectorMath"; +t.config.target = "x86_64-pc-linux-gnu"; +t.config.type = ConfigurationType::Executable; +std::array ifaces = {}; +std::array impls = { "tests/VectorMath" }; +t.config.GetInterfacesAndImplementations(ifaces, impls); +cfg.tests.push_back(std::move(t)); +``` + +Each test runs in its own subprocess so a segfault doesn't take the runner down. Default timeout is 60 s per test (`--timeout=N` overrides). Tests run in parallel up to `hardware_concurrency()`; pass `--jobs=N` to override. Per-test stdout+stderr is buffered and printed atomically on completion so parallel output stays readable, and is also written to `build/test-logs/.log` when a test fails, crashes, or times out. + +```bash +crafter-build test # build + run all tests +crafter-build test 'Vector*' # glob-filter test names +crafter-build test --list # enumerate without running +crafter-build test --jobs=1 # serial mode +crafter-build test --timeout=5 # override per-test timeout +``` + +Outcomes are reported as ✅ pass, ❌ fail, 💥 crash (with the signal name), or ⏱ timeout. The runner exits 0 only if every test passed. + +### Cross-target test runners + +`Test::runner` runs the test binary somewhere other than this machine. Three factories ship today (Linux host only): + +```cpp +t.runner = TestRunner::Local(); // default +t.runner = TestRunner::QemuUser("qemu-aarch64"); // cross-arch via qemu user-mode +t.runner = TestRunner::Ssh("winvm", "/tmp/crafter-test"); // cross-OS via scp + ssh +``` + +`TestRunner::Ssh(host, remoteDir)` ships the test's whole `bin/--/` output dir via `scp -r`, runs the binary over `ssh`, then cleans up the remote temp dir. Each test invocation gets a unique remote subdir to keep parallel runs isolated. Assumes Unix-like shell on the remote (`mkdir -p`, `rm -rf`); for Windows remotes (e.g. `cmd.exe`) construct `TestRunner` directly with your own command templates — `copy`, `exec`, `cleanup` are public string fields with `{bundle}`, `{remote_bundle}`, `{bin}`, `{bin_name}`, `{args}` placeholders. + +`TestRunner::QemuUser(qemuBin)` simply wraps ` {bin} {args}` — same machine, just a command prefix. Pair it with `t.config.target = "aarch64-linux-gnu"` (plus an aarch64 sysroot installed on the host) for cross-arch Linux testing. + +For cross-arch builds, set `cfg.sysroot` on the `Configuration` to point at a sysroot for the target. `BuildStdPcm` and the compile/link commands both consume it: `--sysroot=` is passed to clang, and `/usr/share/libc++/v1/std.cppm` is precompiled instead of the host's. + +For aarch64 specifically, the cheapest sysroot is an extracted [Arch Linux ARM rootfs](http://os.archlinuxarm.org/) plus `libc++` + `libc++abi` + `gcc` installed into it via `pacman --root --config --dbpath /var/lib/pacman -Sy …` (point pacman at ALARM's mirrors with an explicit config to avoid pulling host-arch packages). Then `cfg.sysroot = "";` plus a manual `TestRunner` whose exec template prepends `QEMU_LD_PREFIX= qemu-aarch64 …` so qemu-user finds `ld-linux-aarch64.so.1`. See `tests/fixtures/cross-arch-aarch64/` for a working example. + +For `t.config.target = "x86_64-w64-mingw32"` (Windows from Linux) — V2's existing build pipeline cross-compiles on Linux as long as `mingw-w64` is installed. Combine with `TestRunner::Ssh("winvm", ...)` for end-to-end Windows-target testing from a Linux host. Full MSVC ABI (`x86_64-pc-windows-msvc`) cross-compile from Linux is not yet wired; use MinGW for now. + +#### Global config: `TestRunner::FromEnv` + +To avoid hardcoding hosts in every project's `project.cpp`, use `TestRunner::FromEnv(target)`: + +```cpp +t.runner = TestRunner::FromEnv(t.config.target); +``` + +This reads the env var `CRAFTER_BUILD_RUNNER_` (target triple with `-` and `.` replaced by `_`). Set it once per shell or in CI: + +```bash +export CRAFTER_BUILD_RUNNER_x86_64_w64_mingw32=ssh:winvm:/tmp/crafter-test +export CRAFTER_BUILD_RUNNER_aarch64_linux_gnu=qemu:qemu-aarch64 +``` + +Spec grammar: `local`, `qemu:`, `ssh:`, `ssh::`, `sshwin:`, `sshwin::` (cmd.exe-shell variant for Windows remotes). Unset → falls back to the second arg of `FromEnv` (defaults to `Local()`). + +For anything more elaborate (rsync, ssh ControlMaster, custom flags, env vars on the spawn, `QEMU_LD_PREFIX` for sysroots) — construct `TestRunner` manually with your own templates. The factories are sugar over the same fields. + +Not yet supported: WSL2 / Windows-host execution (the runner currently requires a Linux host because `RunCommandWithTimeout` is Linux-only), MSVC ABI cross-compile from Linux. + +#### Skipping unreachable runners + +`TestRunner` carries a `probe` command that runs once per `RunTests` invocation (cached by `runner.name`); if it exits non-zero, every Test using that runner reports as `⏭ Skipped: runner '' not available` and the suite continues with everything else. The factories set sensible defaults: `Ssh`/`SshWin` probe via `ssh -q -o BatchMode=yes -o ConnectTimeout=5 …`, `QemuUser` via `which `, `Local` is unconditionally available. + +Effect: if you don't have a Windows VM at all, you don't have to do anything — the Windows variants in the in-tree suite skip cleanly and the Linux variants pass. If you do have one, set up an SSH host alias matching what the project.cpp expects (in this repo: `winvm` in `~/.ssh/config`) and the Windows variants run automatically. Skipped tests count toward the summary but do not fail the suite (`AllPassed()` returns true on skip-only runs). + +#### Per-(target, runner) tests + +The 5 simple tests `HelloWorld`, `WithModule`, `Defines`, `CrossProjectModule`, `Diamond` are declared **directly** in the project's `project.cpp`, one entry per `(target, runner)` pair: + +```cpp +std::vector targets = { + { "x86_64-pc-linux-gnu", TestRunner::Local(), {} }, + { "x86_64-w64-mingw32", TestRunner::SshWin("winvm", "C:/temp/crafter-tests"), {"-lstdc++exp"} }, +}; +``` + +So `./bin/crafter-build test` (no env vars) spawns one Test per `(target, runner)` pair, all in one report. With the `winvm` SSH alias reachable: + +``` +✅ HelloWorld (5ms) +✅ HelloWorld (sshwin:winvm) (2348ms) +✅ WithModule (3ms) +✅ WithModule (sshwin:winvm) (2204ms) +… +18 passed +``` + +Without `winvm` reachable, the SshWin variants skip: + +``` +✅ HelloWorld (5ms) +⏭ HelloWorld (sshwin:winvm) skipped: runner 'sshwin:winvm' not available +… +``` + +Local runner emits no parenthetical (existing default for the common case); cross-target runners show their name (e.g. `sshwin:winvm`, `qemu:qemu-aarch64`) so you see at a glance which environment ran each test. + +The 8 outer-driver tests (Incremental, BuildError, RunnerClassification, QemuUser, SshRunner, WindowsViaSsh, CrossArchAarch64, Libraries) stay one-line in the report because they're meta-tests — the test driver is the test, validating host-side build behavior or runner integration. They're not multi-target. + ## Architecture -- **Modules**: `Crafter.Build:Shader` / `:Platform` / `:Interface` / `:Implementation` / `:External` / `:Clang` partitions, re-exported by the `Crafter.Build` umbrella. +- **Modules**: `Crafter.Build:Shader` / `:Platform` / `:Interface` / `:Implementation` / `:External` / `:Clang` / `:Test` partitions, re-exported by the `Crafter.Build` umbrella. - **Build process**: parallel — interface PCMs, implementation `.o` files, external dep clones, dep-config recursive builds, and shader compilations all spawn threads, sync at well-defined join points. -- **`LoadProject`** compiles `project.cpp` to `/build/project.so` and `dlopen`s it. The host exe is linked with `-Wl,--export-dynamic` so the project's undefined symbols resolve against the running binary. +- **`LoadProject`** compiles `project.cpp` to a shared object (`.so` on Linux, `.dll` on Windows) and loads it. On Linux the host exe is linked with `-Wl,--export-dynamic` so the project's undefined symbols resolve back into the running binary. On Windows the host is split into `crafter-build.dll` (everything) + a thin `crafter-build.exe` launcher; the public `Crafter::*` API is annotated with a `CRAFTER_API` macro (`__declspec(dllexport)` when the host is built, `__declspec(dllimport)` when project.dll consumes it via the rebuilt user-cache PCMs), and the host passes `-Wl,/EXPORT:CrafterBuildProject` when linking project.dll so user `project.cpp` files don't need platform-gated annotations. ## Compatibility / portability - Linux: x86_64. Tested on Arch in a clean container. -- Windows: bootstrap works (build.cmd produces working exe); the `LoadProject` path needs the in-progress DLL+launcher refactor to support cross-EXE/DLL symbol resolution. +- Windows: x86_64 MSVC ABI. `build.cmd` produces `bin/crafter-build.{exe,dll,lib}`; userprojs verified end-to-end (`project.cpp` → `project.dll` loaded by the host → `userapp.exe` built and run). The `CRAFTER_BUILD_MARCH` and `CRAFTER_BUILD_MTUNE` env vars override the default `-march=native` / `-mtune=native` for distro CI builds: @@ -114,9 +229,6 @@ CRAFTER_BUILD_MARCH=x86-64-v3 CRAFTER_BUILD_MTUNE=generic ./build.sh ## Roadmap -- [ ] Windows DLL+launcher refactor (in progress) -- [ ] Configuration inheritance (`extends:`-style merging from base configs) -- [ ] Test runner (dlopen-based test loading) - [ ] Target-triple auto-detection (`clang -print-target-triple`) - [ ] Prefer system glslang when available, fall back to git+cmake - [ ] Order-preserving deduplication of `BuildResult.libs` (current `unordered_set` doesn't preserve link order — only matters for cyclic static libs) diff --git a/build.cmd b/build.cmd index e08c181..10e6a6f 100644 --- a/build.cmd +++ b/build.cmd @@ -1,6 +1,7 @@ @echo OFF mkdir build 2>nul mkdir bin 2>nul +mkdir lib 2>nul mkdir share\crafter-build 2>nul copy /Y interfaces\Crafter.Build.cppm share\crafter-build\ @@ -10,6 +11,8 @@ copy /Y interfaces\Crafter.Build-Interface.cppm share\crafter-build\ copy /Y interfaces\Crafter.Build-Implementation.cppm share\crafter-build\ copy /Y interfaces\Crafter.Build-External.cppm share\crafter-build\ copy /Y interfaces\Crafter.Build-Clang.cppm share\crafter-build\ +copy /Y interfaces\Crafter.Build-Test.cppm share\crafter-build\ +copy /Y interfaces\Crafter.Build-Api.h share\crafter-build\ if not exist .\build\glslang\NUL git clone https://github.com/KhronosGroup/glslang.git .\build\glslang @@ -45,6 +48,7 @@ clang++ %common_options% -fmodule-output interfaces\Crafter.Build-Interface.cppm clang++ %common_options% -fmodule-output interfaces\Crafter.Build-Implementation.cppm -o .\build\Crafter.Build-Implementation.o clang++ %common_options% -fmodule-output interfaces\Crafter.Build-External.cppm -o .\build\Crafter.Build-External.o clang++ %common_options% -fmodule-output interfaces\Crafter.Build-Clang.cppm -o .\build\Crafter.Build-Clang.o +clang++ %common_options% -fmodule-output interfaces\Crafter.Build-Test.cppm -o .\build\Crafter.Build-Test.o clang++ %common_options% -fmodule-output interfaces\Crafter.Build.cppm -o .\build\Crafter.Build.o clang++ %common_options% .\implementations\Crafter.Build-Shader.cpp -o .\build\Crafter.Build-Shader_impl.o @@ -53,11 +57,11 @@ clang++ %common_options% .\implementations\Crafter.Build-Interface.cpp -o .\buil clang++ %common_options% .\implementations\Crafter.Build-Implementation.cpp -o .\build\Crafter.Build-Implementation_impl.o clang++ %common_options% .\implementations\Crafter.Build-External.cpp -o .\build\Crafter.Build-External_impl.o clang++ %common_options% .\implementations\Crafter.Build-Clang.cpp -o .\build\Crafter.Build-Clang_impl.o +clang++ %common_options% .\implementations\Crafter.Build-Test.cpp -o .\build\Crafter.Build-Test_impl.o clang++ %common_options% .\implementations\main.cpp -o .\build\main.o REM Step 1: link all impl .o files into crafter-build.dll, generating crafter-build.lib import lib clang++ %useLibcLinker% -shared -std=c++26 -O3 -march=%CRAFTER_BUILD_MARCH% -mtune=%CRAFTER_BUILD_MTUNE% -L.\build -fuse-ld=lld ^ - -Wl,-export-all-symbols ^ -Wl,/IMPLIB:.\bin\crafter-build.lib ^ -lSPIRV -lGenericCodeGen -lglslang -lOSDependent -lMachineIndependent -lglslang-default-resource-limits ^ .\build\Crafter.Build-Shader.o ^ @@ -66,6 +70,7 @@ clang++ %useLibcLinker% -shared -std=c++26 -O3 -march=%CRAFTER_BUILD_MARCH% -mtu .\build\Crafter.Build-Implementation.o ^ .\build\Crafter.Build-External.o ^ .\build\Crafter.Build-Clang.o ^ + .\build\Crafter.Build-Test.o ^ .\build\Crafter.Build.o ^ .\build\Crafter.Build-Shader_impl.o ^ .\build\Crafter.Build-Platform_impl.o ^ @@ -73,6 +78,7 @@ clang++ %useLibcLinker% -shared -std=c++26 -O3 -march=%CRAFTER_BUILD_MARCH% -mtu .\build\Crafter.Build-Implementation_impl.o ^ .\build\Crafter.Build-External_impl.o ^ .\build\Crafter.Build-Clang_impl.o ^ + .\build\Crafter.Build-Test_impl.o ^ -o .\bin\crafter-build.dll REM Step 2: link the launcher exe against crafter-build.lib @@ -81,4 +87,22 @@ clang++ %useLibcLinker% -std=c++26 -O3 -march=%CRAFTER_BUILD_MARCH% -mtune=%CRAF .\bin\crafter-build.lib ^ -o .\bin\crafter-build.exe +REM Step 3: bundle the same .o set as a static archive for downstream consumers +llvm-lib.exe /OUT:.\lib\crafter-build-static.lib ^ + .\build\Crafter.Build-Shader.o ^ + .\build\Crafter.Build-Platform.o ^ + .\build\Crafter.Build-Interface.o ^ + .\build\Crafter.Build-Implementation.o ^ + .\build\Crafter.Build-External.o ^ + .\build\Crafter.Build-Clang.o ^ + .\build\Crafter.Build-Test.o ^ + .\build\Crafter.Build.o ^ + .\build\Crafter.Build-Shader_impl.o ^ + .\build\Crafter.Build-Platform_impl.o ^ + .\build\Crafter.Build-Interface_impl.o ^ + .\build\Crafter.Build-Implementation_impl.o ^ + .\build\Crafter.Build-External_impl.o ^ + .\build\Crafter.Build-Clang_impl.o ^ + .\build\Crafter.Build-Test_impl.o + copy /Y "%LIBCXX_DIR%\lib\c++.dll" ".\bin\c++.dll" diff --git a/build.sh b/build.sh index 2101479..366349f 100755 --- a/build.sh +++ b/build.sh @@ -1,5 +1,6 @@ mkdir -p build mkdir -p bin +mkdir -p lib mkdir -p share/crafter-build cp interfaces/Crafter.Build.cppm share/crafter-build/ @@ -9,6 +10,8 @@ cp interfaces/Crafter.Build-Interface.cppm share/crafter-build/ cp interfaces/Crafter.Build-Implementation.cppm share/crafter-build/ cp interfaces/Crafter.Build-External.cppm share/crafter-build/ cp interfaces/Crafter.Build-Clang.cppm share/crafter-build/ +cp interfaces/Crafter.Build-Test.cppm share/crafter-build/ +cp interfaces/Crafter.Build-Api.h share/crafter-build/ git clone https://github.com/KhronosGroup/glslang.git ./build/glslang @@ -42,6 +45,7 @@ clang++ $common_options -fmodule-output interfaces/Crafter.Build-Interface.cppm clang++ $common_options -fmodule-output interfaces/Crafter.Build-Implementation.cppm -o ./build/Crafter.Build-Implementation.o clang++ $common_options -fmodule-output interfaces/Crafter.Build-External.cppm -o ./build/Crafter.Build-External.o clang++ $common_options -fmodule-output interfaces/Crafter.Build-Clang.cppm -o ./build/Crafter.Build-Clang.o +clang++ $common_options -fmodule-output interfaces/Crafter.Build-Test.cppm -o ./build/Crafter.Build-Test.o clang++ $common_options -fmodule-output interfaces/Crafter.Build.cppm -o ./build/Crafter.Build.o clang++ $common_options ./implementations/Crafter.Build-Shader.cpp -o ./build/Crafter.Build-Shader_impl.o @@ -50,8 +54,26 @@ clang++ $common_options ./implementations/Crafter.Build-Interface.cpp -o ./build clang++ $common_options ./implementations/Crafter.Build-Implementation.cpp -o ./build/Crafter.Build-Implementation_impl.o clang++ $common_options ./implementations/Crafter.Build-External.cpp -o ./build/Crafter.Build-External_impl.o clang++ $common_options ./implementations/Crafter.Build-Clang.cpp -o ./build/Crafter.Build-Clang_impl.o +clang++ $common_options ./implementations/Crafter.Build-Test.cpp -o ./build/Crafter.Build-Test_impl.o clang++ $common_options ./implementations/main.cpp -o ./build/main.o +ar rcs ./lib/libcrafter-build.a \ + ./build/Crafter.Build-Shader.o \ + ./build/Crafter.Build-Platform.o \ + ./build/Crafter.Build-Interface.o \ + ./build/Crafter.Build-Implementation.o \ + ./build/Crafter.Build-External.o \ + ./build/Crafter.Build-Clang.o \ + ./build/Crafter.Build-Test.o \ + ./build/Crafter.Build.o \ + ./build/Crafter.Build-Shader_impl.o \ + ./build/Crafter.Build-Platform_impl.o \ + ./build/Crafter.Build-Interface_impl.o \ + ./build/Crafter.Build-Implementation_impl.o \ + ./build/Crafter.Build-External_impl.o \ + ./build/Crafter.Build-Clang_impl.o \ + ./build/Crafter.Build-Test_impl.o + clang++ -std=c++26 -stdlib=libc++ -O3 -march=$MARCH -mtune=$MTUNE -fuse-ld=lld \ -Wl,--export-dynamic \ -L./build \ @@ -61,6 +83,7 @@ clang++ -std=c++26 -stdlib=libc++ -O3 -march=$MARCH -mtune=$MTUNE -fuse-ld=lld \ ./build/Crafter.Build-Implementation.o \ ./build/Crafter.Build-External.o \ ./build/Crafter.Build-Clang.o \ + ./build/Crafter.Build-Test.o \ ./build/Crafter.Build.o \ ./build/Crafter.Build-Shader_impl.o \ ./build/Crafter.Build-Platform_impl.o \ @@ -68,6 +91,7 @@ clang++ -std=c++26 -stdlib=libc++ -O3 -march=$MARCH -mtune=$MTUNE -fuse-ld=lld \ ./build/Crafter.Build-Implementation_impl.o \ ./build/Crafter.Build-External_impl.o \ ./build/Crafter.Build-Clang_impl.o \ + ./build/Crafter.Build-Test_impl.o \ ./build/main.o \ -lSPIRV -lGenericCodeGen -lglslang -lOSDependent -lMachineIndependent -lglslang-default-resource-limits \ -ldl \ diff --git a/implementations/Crafter.Build-Clang.cpp b/implementations/Crafter.Build-Clang.cpp index eae9392..668e8b4 100644 --- a/implementations/Crafter.Build-Clang.cpp +++ b/implementations/Crafter.Build-Clang.cpp @@ -21,6 +21,7 @@ export module Crafter.Build:Clang_impl; import std; import :Clang; import :Platform; +import :Test; namespace fs = std::filesystem; using namespace Crafter; @@ -35,7 +36,10 @@ void Configuration::GetInterfacesAndImplementations(std::span interfac return true; } } - for(Configuration* depCfg : this->dependencies) { + + std::unordered_set seen; + std::function walk = [&](Configuration* depCfg) -> bool { + if (!seen.insert(depCfg).second) return false; for(const std::unique_ptr& depInterface : depCfg->interfaces) { if(depInterface->name == importName) { fs::path depPcmPath = (depCfg->PcmDir() / depInterface->path.filename()).string() + ".pcm"; @@ -43,6 +47,14 @@ void Configuration::GetInterfacesAndImplementations(std::span interfac return true; } } + for(Configuration* sub : depCfg->dependencies) { + if (walk(sub)) return true; + } + return false; + }; + + for(Configuration* depCfg : this->dependencies) { + if (walk(depCfg)) return true; } return false; }; @@ -314,6 +326,10 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map repack(false); - for(Configuration* dep : config.dependencies) { - for (const auto& entry : fs::recursive_directory_iterator(dep->path)) { - if (entry.is_directory() && entry.path().filename() == "include") { - command += " -I" + entry.path().string(); + { + std::unordered_set seen; + std::function addFlags = [&](Configuration* dep) { + if (!seen.insert(dep).second) return; + for (const auto& entry : fs::recursive_directory_iterator(dep->path)) { + if (entry.is_directory() && entry.path().filename() == "include") { + command += " -I" + entry.path().string(); + } } + command += std::format(" -I{} -fprebuilt-module-path={}", dep->path.string(), dep->PcmDir().string()); + for (Configuration* sub : dep->dependencies) { + addFlags(sub); + } + }; + for (Configuration* dep : config.dependencies) { + addFlags(dep); } + } - command += std::format(" -I{} -fprebuilt-module-path={}", dep->path.string(), dep->PcmDir().string()); - + for(Configuration* dep : config.dependencies) { depThreads.emplace_back([&, dep](){ try { if (buildCancelled.load(std::memory_order_relaxed)) return; @@ -352,12 +379,13 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_mappath); + fs::path cacheKey = dep->PcmDir(); + auto it = depResults.find(cacheKey); if (it == depResults.end()) { isBuilder = true; promise = std::make_shared>(); resultFuture = promise->get_future().share(); - depResults.emplace(dep->path, resultFuture); + depResults.emplace(cacheKey, resultFuture); } else { resultFuture = it->second; } @@ -594,10 +622,23 @@ int Crafter::Run(int argc, char** argv) { std::vector projectArgs; projectArgs.reserve(argc); + bool runTests = false; + RunTestsOptions testOpts; + for (int i = 1; i < argc; ++i) { std::string_view arg = argv[i]; - if (arg.starts_with("--project=")) { + if (arg == "test") { + runTests = true; + } else if (arg.starts_with("--project=")) { projectFile = arg.substr(std::string_view("--project=").size()); + } else if (runTests && arg.starts_with("--jobs=")) { + testOpts.jobs = std::stoi(std::string(arg.substr(std::string_view("--jobs=").size()))); + } else if (runTests && arg.starts_with("--timeout=")) { + testOpts.timeoutOverride = std::chrono::seconds(std::stoi(std::string(arg.substr(std::string_view("--timeout=").size())))); + } else if (runTests && arg == "--list") { + testOpts.listOnly = true; + } else if (runTests && !arg.starts_with("-")) { + testOpts.globs.emplace_back(arg); } else { projectArgs.push_back(arg); } @@ -610,6 +651,11 @@ int Crafter::Run(int argc, char** argv) { Configuration config = LoadProject(projectFile, projectArgs); + if (runTests) { + TestSummary summary = RunTests(config, testOpts); + return summary.AllPassed() ? 0 : 1; + } + std::unordered_map> depResults; std::mutex depMutex; BuildResult result = Build(config, depResults, depMutex); diff --git a/implementations/Crafter.Build-Platform.cpp b/implementations/Crafter.Build-Platform.cpp index 6869df8..e6c84d5 100644 --- a/implementations/Crafter.Build-Platform.cpp +++ b/implementations/Crafter.Build-Platform.cpp @@ -59,7 +59,7 @@ std::string Crafter::RunCommand(const std::string_view cmd) { CommandResult Crafter::RunCommandChecked(std::string_view cmd) { std::array buffer; - CommandResult result{0, ""}; + CommandResult result{}; std::string with = "cmd /C \"" + std::string(cmd) + " 2>&1\""; @@ -76,6 +76,10 @@ CommandResult Crafter::RunCommandChecked(std::string_view cmd) { return result; } +CommandResult Crafter::RunCommandWithTimeout(std::string_view, std::chrono::seconds) { + throw std::runtime_error("RunCommandWithTimeout not yet implemented on Windows"); +} + std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) { std::string libcxx = std::getenv("LIBCXX_DIR"); std::string stdcppm = std::format("{}\\modules\\c++\\v1\\std.cppm", libcxx); @@ -99,13 +103,14 @@ std::string Crafter::GetBaseCommand(const Configuration& config) { } namespace { - constexpr std::array kCrafterBuildModules = { + constexpr std::array kCrafterBuildModules = { "Crafter.Build-Shader", "Crafter.Build-Platform", "Crafter.Build-Interface", "Crafter.Build-Implementation", "Crafter.Build-External", "Crafter.Build-Clang", + "Crafter.Build-Test", "Crafter.Build", }; @@ -121,7 +126,7 @@ namespace { } std::string cmd = std::format( "clang++ --target=x86_64-pc-windows-msvc -march=native -mtune=native " - "-std=c++26 -O3 " + "-std=c++26 -O3 -D CRAFTER_BUILD_DLL_IMPORT " "-nostdinc++ -nostdlib++ -isystem %LIBCXX_DIR%\\include\\c++\\v1 " "-Wno-reserved-identifier -Wno-reserved-module-identifier " "-fprebuilt-module-path={} " @@ -178,9 +183,11 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span buffer; - CommandResult result{0, ""}; + CommandResult result{}; std::string with = std::string(cmd) + " 2>&1"; FILE* pipe = popen(with.c_str(), "r"); @@ -243,7 +250,47 @@ CommandResult Crafter::RunCommandChecked(std::string_view cmd) { if (WIFEXITED(status)) { result.exitCode = WEXITSTATUS(status); } else if (WIFSIGNALED(status)) { - result.exitCode = 128 + WTERMSIG(status); + result.signal = WTERMSIG(status); + result.exitCode = 128 + result.signal; + result.crashed = true; + } else { + result.exitCode = -1; + } + return result; +} + +CommandResult Crafter::RunCommandWithTimeout(std::string_view cmd, std::chrono::seconds timeout) { + std::array buffer; + CommandResult result{}; + + std::string wrapped = std::format( + "timeout --kill-after=2 {} {} 2>&1", + timeout.count(), cmd); + + FILE* pipe = popen(wrapped.c_str(), "r"); + if (!pipe) throw std::runtime_error("popen() failed!"); + + while (fgets(buffer.data(), buffer.size(), pipe) != nullptr) { + result.output += buffer.data(); + } + + int status = pclose(pipe); + if (WIFEXITED(status)) { + int code = WEXITSTATUS(status); + if (code == 124) { + result.timedOut = true; + result.exitCode = 124; + } else if (code >= 128) { + result.signal = code - 128; + result.exitCode = code; + result.crashed = true; + } else { + result.exitCode = code; + } + } else if (WIFSIGNALED(status)) { + result.signal = WTERMSIG(status); + result.exitCode = 128 + result.signal; + result.crashed = true; } else { result.exitCode = -1; } @@ -276,8 +323,14 @@ std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) { return ""; } } else { - if(!fs::exists(stdPcm) || fs::last_write_time(stdPcm) < fs::last_write_time("/usr/share/libc++/v1/std.cppm")) { - return RunCommand(std::format("clang++ --target={} -std=c++26 -stdlib=libc++ -march={} -mtune={} -O3 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile /usr/share/libc++/v1/std.cppm -o {}", config.target, config.march, config.mtune, stdPcm.string())); + std::string stdCppm = config.sysroot.empty() + ? std::string("/usr/share/libc++/v1/std.cppm") + : std::format("{}/usr/share/libc++/v1/std.cppm", config.sysroot); + std::string sysrootFlag = config.sysroot.empty() + ? std::string() + : std::format(" --sysroot={}", config.sysroot); + if(!fs::exists(stdPcm) || fs::last_write_time(stdPcm) < fs::last_write_time(stdCppm)) { + return RunCommand(std::format("clang++ --target={} -std=c++26 -stdlib=libc++{} -march={} -mtune={} -O3 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile {} -o {}", config.target, sysrootFlag, config.march, config.mtune, stdCppm, stdPcm.string())); } else { return ""; } @@ -307,13 +360,14 @@ std::string Crafter::GetBaseCommand(const Configuration& config) { } namespace { - constexpr std::array kCrafterBuildModules = { + constexpr std::array kCrafterBuildModules = { "Crafter.Build-Shader", "Crafter.Build-Platform", "Crafter.Build-Interface", "Crafter.Build-Implementation", "Crafter.Build-External", "Crafter.Build-Clang", + "Crafter.Build-Test", "Crafter.Build", }; diff --git a/implementations/Crafter.Build-Test.cpp b/implementations/Crafter.Build-Test.cpp new file mode 100644 index 0000000..bb2b171 --- /dev/null +++ b/implementations/Crafter.Build-Test.cpp @@ -0,0 +1,475 @@ +/* +Crafter® Build +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License version 3.0 as published by the Free Software Foundation; + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +export module Crafter.Build:Test_impl; +import std; +import :Test; +import :Clang; +import :Platform; +namespace fs = std::filesystem; +using namespace Crafter; + +namespace { + bool TargetIsWindows(std::string_view target) { + return target.find("windows") != std::string_view::npos + || target.find("mingw") != std::string_view::npos; + } + + fs::path TestBinaryPath(const Configuration& cfg) { + fs::path outputDir = cfg.path/"bin"/std::format("{}-{}-{}", cfg.name, cfg.target, cfg.march); + return outputDir / (TargetIsWindows(cfg.target) ? cfg.outputName + ".exe" : cfg.outputName); + } + + bool MatchGlob(std::string_view glob, std::string_view name) { + std::size_t gi = 0, ni = 0, star = std::string_view::npos, mark = 0; + while (ni < name.size()) { + if (gi < glob.size() && (glob[gi] == '?' || glob[gi] == name[ni])) { + ++gi; ++ni; + } else if (gi < glob.size() && glob[gi] == '*') { + star = gi++; + mark = ni; + } else if (star != std::string_view::npos) { + gi = star + 1; + ni = ++mark; + } else { + return false; + } + } + while (gi < glob.size() && glob[gi] == '*') ++gi; + return gi == glob.size(); + } + + bool MatchAny(std::span globs, std::string_view name) { + if (globs.empty()) return true; + for (const auto& g : globs) { + if (MatchGlob(g, name)) return true; + } + return false; + } + + std::string ShellQuote(std::string_view s) { + std::string out; + out.reserve(s.size() + 2); + out.push_back('\''); + for (char c : s) { + if (c == '\'') out += "'\\''"; + else out.push_back(c); + } + out.push_back('\''); + return out; + } + + std::string JoinAndQuoteArgs(std::span args) { + std::string out; + for (const auto& a : args) { + if (!out.empty()) out.push_back(' '); + out += ShellQuote(a); + } + return out; + } + + std::string Substitute(std::string_view tmpl, const std::map& ph) { + std::string out; + out.reserve(tmpl.size()); + std::size_t i = 0; + while (i < tmpl.size()) { + if (tmpl[i] == '{') { + std::size_t end = tmpl.find('}', i + 1); + if (end != std::string_view::npos) { + std::string key(tmpl.substr(i, end - i + 1)); + if (auto it = ph.find(key); it != ph.end()) { + out += it->second; + i = end + 1; + continue; + } + } + } + out.push_back(tmpl[i++]); + } + return out; + } + + std::string SignalName(int sig) { + switch (sig) { + case 1: return "SIGHUP"; + case 2: return "SIGINT"; + case 4: return "SIGILL"; + case 6: return "SIGABRT"; + case 8: return "SIGFPE"; + case 9: return "SIGKILL"; + case 11: return "SIGSEGV"; + case 13: return "SIGPIPE"; + case 14: return "SIGALRM"; + case 15: return "SIGTERM"; + default: return std::format("signal {}", sig); + } + } + + void WriteLog(const fs::path& projectPath, const std::string& name, const std::string& output) { + fs::path logDir = projectPath / "build" / "test-logs"; + std::error_code ec; + fs::create_directories(logDir, ec); + if (ec) return; + std::ofstream(logDir / (name + ".log")) << output; + } + + void PrintResult(const TestResult& r, std::string_view runnerName) { + auto ms = r.duration.count(); + std::string runnerSuffix = (runnerName.empty() || runnerName == "local") + ? std::string() + : std::format(" ({})", runnerName); + switch (r.outcome) { + case TestOutcome::Pass: + std::println("✅ {}{} ({}ms)", r.name, runnerSuffix, ms); + break; + case TestOutcome::Fail: + std::println("❌ {}{} ({}ms) exit {}", r.name, runnerSuffix, ms, r.exitCode); + if (!r.output.empty()) { + for (auto line : std::views::split(r.output, '\n')) { + std::string_view sv(line.begin(), line.end()); + if (!sv.empty()) std::println(" {}", sv); + } + } + break; + case TestOutcome::Crash: + std::println("\U0001F4A5 {}{} ({}ms) crashed: {}", r.name, runnerSuffix, ms, SignalName(r.signal)); + if (!r.output.empty()) { + for (auto line : std::views::split(r.output, '\n')) { + std::string_view sv(line.begin(), line.end()); + if (!sv.empty()) std::println(" {}", sv); + } + } + break; + case TestOutcome::Timeout: + std::println("⏱ {}{} ({}ms) timeout", r.name, runnerSuffix, ms); + break; + case TestOutcome::Skipped: + std::println("⏭ {}{} skipped: {}", r.name, runnerSuffix, + r.output.empty() ? std::string("(no reason)") : r.output); + break; + } + } +} + +TestRunner TestRunner::Local() { + TestRunner r; + r.name = "local"; + return r; +} + +TestRunner TestRunner::Ssh(std::string host, std::string remoteDir) { + TestRunner r; + r.name = std::format("ssh:{}", host); + r.remoteDir = std::move(remoteDir); + r.copy = std::format("ssh -q {0} 'mkdir -p {{remote_bundle}}' && scp -r -q {{bundle}}/. {0}:{{remote_bundle}}/", host); + r.exec = std::format("ssh -q {} 'cd {{remote_bundle}} && ./{{bin_name}} {{args}}'", host); + r.cleanup = std::format("ssh -q {} 'rm -rf {{remote_bundle}}' || true", host); + r.probe = std::format("ssh -q -o BatchMode=yes -o ConnectTimeout=5 {} true > /dev/null 2>&1", host); + return r; +} + +TestRunner TestRunner::SshWin(std::string host, std::string remoteDir) { + TestRunner r; + r.name = std::format("sshwin:{}", host); + r.remoteDir = std::move(remoteDir); + // cmd.exe-friendly templates. Use backslash variants of remote paths because + // cmd's mkdir won't auto-create intermediate directories with forward + // slashes. scp tolerates either, so we keep forward slashes for the scp + // dest (which `{remote_bundle}` provides). 2>nul + `& rem ok` swallows + // mkdir's "already exists" error and forces exit code 0. + r.copy = std::format("ssh -q {0} \"mkdir {{remote_bundle_win}} 2>nul & rem ok\" && scp -r -q {{bundle}}/. {0}:{{remote_bundle}}/", host); + r.exec = std::format("ssh -q {} \"{{bin_win}} {{args}}\"", host); + r.cleanup = std::format("ssh -q {} \"rd /s /q {{remote_bundle_win}} 2>nul & rem ok\"", host); + r.probe = std::format("ssh -q -o BatchMode=yes -o ConnectTimeout=5 {} \"ver\" > /dev/null 2>&1", host); + return r; +} + +TestRunner TestRunner::QemuUser(std::string qemuBin) { + TestRunner r; + r.name = std::format("qemu:{}", qemuBin); + r.exec = std::format("{} {{bin}} {{args}}", qemuBin); + r.probe = std::format("which {} > /dev/null 2>&1", qemuBin); + return r; +} + +namespace { + std::string NormalizeTriple(std::string_view target) { + std::string out(target); + for (char& c : out) { + if (c == '-' || c == '.') c = '_'; + } + return out; + } + + std::optional ParseRunnerSpec(std::string_view spec) { + if (spec.empty()) return std::nullopt; + std::vector parts; + for (auto piece : std::views::split(spec, ':')) { + parts.emplace_back(std::string_view(piece.begin(), piece.end())); + } + if (parts.empty()) return std::nullopt; + if (parts[0] == "local") return TestRunner::Local(); + if (parts[0] == "qemu" && parts.size() == 2) return TestRunner::QemuUser(parts[1]); + if (parts[0] == "ssh" && parts.size() == 2) return TestRunner::Ssh(parts[1]); + if (parts[0] == "ssh" && parts.size() >= 3) { + std::string remote; + for (std::size_t i = 2; i < parts.size(); ++i) { + if (i > 2) remote.push_back(':'); + remote += parts[i]; + } + return TestRunner::Ssh(parts[1], remote); + } + if (parts[0] == "sshwin" && parts.size() == 2) return TestRunner::SshWin(parts[1]); + if (parts[0] == "sshwin" && parts.size() >= 3) { + std::string remote; + for (std::size_t i = 2; i < parts.size(); ++i) { + if (i > 2) remote.push_back(':'); + remote += parts[i]; + } + return TestRunner::SshWin(parts[1], remote); + } + throw std::runtime_error(std::format( + "TestRunner::FromEnv: unrecognized runner spec '{}'", spec)); + } +} + +TestRunner TestRunner::FromEnv(std::string_view target, TestRunner fallback) { + std::string envName = std::format("CRAFTER_BUILD_RUNNER_{}", NormalizeTriple(target)); + const char* v = std::getenv(envName.c_str()); + if (!v || !*v) return fallback; + if (auto r = ParseRunnerSpec(v)) return std::move(*r); + return fallback; +} + +TestResult Crafter::RunSingleTest(const Test& test, const fs::path& binary, std::chrono::seconds timeout) { + using namespace std::chrono_literals; + TestResult result; + result.name = test.config.name; + + std::map ph; + ph["{args}"] = JoinAndQuoteArgs(test.args); + ph["{bin_name}"] = binary.filename().string(); + ph["{bundle}"] = binary.parent_path().string(); + + auto start = std::chrono::steady_clock::now(); + CommandResult r; + + if (test.runner.exec.empty()) { + // Pure-local runner: spawn the binary directly. + std::string cmd = std::format("{} {}", ShellQuote(binary.string()), ph["{args}"]); + r = RunCommandWithTimeout(cmd, timeout); + } else if (test.runner.copy.empty()) { + // Prefix runner (qemu-user, wsl, ...): templated exec wraps a local binary. + ph["{bin}"] = binary.string(); + r = RunCommandWithTimeout(Substitute(test.runner.exec, ph), timeout); + } else { + // Transport runner (ssh, ...): copy bundle → exec remotely → cleanup. + std::string unique = std::format("{}-{}-{}", + test.config.name, + std::hash{}(std::this_thread::get_id()), + std::chrono::steady_clock::now().time_since_epoch().count()); + ph["{remote_bundle}"] = std::format("{}/{}", test.runner.remoteDir, unique); + ph["{bin}"] = std::format("{}/{}", ph["{remote_bundle}"], ph["{bin_name}"]); + // Backslash variants for cmd.exe-shell remotes (cmd's mkdir won't + // auto-create intermediate directories when path uses forward slashes). + ph["{remote_bundle_win}"] = ph["{remote_bundle}"]; + std::replace(ph["{remote_bundle_win}"].begin(), ph["{remote_bundle_win}"].end(), '/', '\\'); + ph["{bin_win}"] = ph["{bin}"]; + std::replace(ph["{bin_win}"].begin(), ph["{bin_win}"].end(), '/', '\\'); + + CommandResult cp = RunCommandWithTimeout(Substitute(test.runner.copy, ph), 5min); + if (cp.exitCode != 0) { + auto end = std::chrono::steady_clock::now(); + result.duration = std::chrono::duration_cast(end - start); + result.outcome = TestOutcome::Fail; + result.exitCode = cp.exitCode; + result.output = std::format("copy step failed (exit {}):\n{}", cp.exitCode, cp.output); + return result; + } + + r = RunCommandWithTimeout(Substitute(test.runner.exec, ph), timeout); + + if (!test.runner.cleanup.empty()) { + std::system(Substitute(test.runner.cleanup, ph).c_str()); + } + } + + auto end = std::chrono::steady_clock::now(); + result.duration = std::chrono::duration_cast(end - start); + result.output = std::move(r.output); + result.exitCode = r.exitCode; + result.signal = r.signal; + + if (r.timedOut) result.outcome = TestOutcome::Timeout; + else if (r.crashed) result.outcome = TestOutcome::Crash; + else if (r.exitCode != 0) result.outcome = TestOutcome::Fail; + else result.outcome = TestOutcome::Pass; + + return result; +} + +TestSummary Crafter::RunTests(Configuration& projectCfg, const RunTestsOptions& opts) { + TestSummary summary; + + std::vector filtered; + filtered.reserve(projectCfg.tests.size()); + for (auto& test : projectCfg.tests) { + if (MatchAny(opts.globs, test.config.name)) { + filtered.push_back(&test); + } + } + + if (opts.listOnly) { + for (auto* t : filtered) { + std::println("{}", t->config.name); + } + return summary; + } + + if (filtered.empty()) { + std::println("No tests matched."); + return summary; + } + + int jobs = opts.jobs > 0 + ? opts.jobs + : std::max(1u, std::thread::hardware_concurrency()); + jobs = std::min(jobs, static_cast(filtered.size())); + + std::unordered_map> depResults; + std::mutex depMutex; + std::mutex printMutex; + std::mutex probeMutex; + std::unordered_map probeCache; + std::atomic next{0}; + std::vector results(filtered.size()); + + auto runnerAvailable = [&](const TestRunner& runner) -> bool { + if (runner.probe.empty()) return true; + std::lock_guard lk(probeMutex); + if (auto it = probeCache.find(runner.name); it != probeCache.end()) { + return it->second; + } + bool ok = (std::system(runner.probe.c_str()) == 0); + probeCache[runner.name] = ok; + return ok; + }; + + auto worker = [&]() { + while (true) { + std::size_t i = next.fetch_add(1); + if (i >= filtered.size()) break; + Test& t = *filtered[i]; + TestResult r; + r.name = t.config.name; + + if (!runnerAvailable(t.runner)) { + r.outcome = TestOutcome::Skipped; + r.output = std::format("runner '{}' not available", t.runner.name); + { + std::lock_guard lk(printMutex); + PrintResult(r, t.runner.name); + } + results[i] = std::move(r); + continue; + } + + BuildResult br; + try { + br = Build(t.config, depResults, depMutex); + } catch (const std::exception& e) { + r.outcome = TestOutcome::Fail; + r.output = std::format("build threw: {}", e.what()); + r.exitCode = -1; + { + std::lock_guard lk(printMutex); + PrintResult(r, t.runner.name); + } + results[i] = std::move(r); + continue; + } + + if (!br.result.empty()) { + r.outcome = TestOutcome::Fail; + r.output = std::format("build failed: {}", br.result); + r.exitCode = -1; + { + std::lock_guard lk(printMutex); + PrintResult(r, t.runner.name); + } + results[i] = std::move(r); + continue; + } + + std::chrono::seconds timeout = opts.timeoutOverride.value_or(t.timeout); + fs::path binary = TestBinaryPath(t.config); + try { + r = RunSingleTest(t, binary, timeout); + } catch (const std::exception& e) { + r.outcome = TestOutcome::Fail; + r.output = std::format("runner threw: {}", e.what()); + r.exitCode = -1; + } + + if (r.outcome != TestOutcome::Pass && r.outcome != TestOutcome::Skipped && !r.output.empty()) { + WriteLog(projectCfg.path, r.name, r.output); + } + + { + std::lock_guard lk(printMutex); + PrintResult(r, t.runner.name); + } + results[i] = std::move(r); + } + }; + + std::vector threads; + threads.reserve(jobs); + for (int j = 0; j < jobs; ++j) { + threads.emplace_back(worker); + } + threads.clear(); // joins all jthreads + + for (auto& r : results) { + switch (r.outcome) { + case TestOutcome::Pass: summary.passed++; break; + case TestOutcome::Fail: summary.failed++; break; + case TestOutcome::Crash: summary.crashed++; break; + case TestOutcome::Timeout: summary.timedOut++; break; + case TestOutcome::Skipped: summary.skipped++; break; + } + } + summary.results = std::move(results); + + std::print("\n"); + std::vector parts; + if (summary.passed) parts.push_back(std::format("{} passed", summary.passed)); + if (summary.failed) parts.push_back(std::format("{} failed", summary.failed)); + if (summary.crashed) parts.push_back(std::format("{} crashed", summary.crashed)); + if (summary.timedOut) parts.push_back(std::format("{} timed out", summary.timedOut)); + if (summary.skipped) parts.push_back(std::format("{} skipped", summary.skipped)); + std::string joined; + for (std::size_t i = 0; i < parts.size(); ++i) { + if (i) joined += ", "; + joined += parts[i]; + } + std::println("{}", joined); + + return summary; +} diff --git a/interfaces/Crafter.Build-Api.h b/interfaces/Crafter.Build-Api.h new file mode 100644 index 0000000..f374a55 --- /dev/null +++ b/interfaces/Crafter.Build-Api.h @@ -0,0 +1,11 @@ +#pragma once + +#if defined(_WIN32) + #if defined(CRAFTER_BUILD_DLL_IMPORT) + #define CRAFTER_API __declspec(dllimport) + #else + #define CRAFTER_API __declspec(dllexport) + #endif +#else + #define CRAFTER_API +#endif diff --git a/interfaces/Crafter.Build-Clang.cppm b/interfaces/Crafter.Build-Clang.cppm index 62423e7..5265127 100644 --- a/interfaces/Crafter.Build-Clang.cppm +++ b/interfaces/Crafter.Build-Clang.cppm @@ -17,6 +17,8 @@ License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ +module; +#include "Crafter.Build-Api.h" export module Crafter.Build:Clang; import std; import :Shader; @@ -43,6 +45,39 @@ export namespace Crafter { LibraryDynamic, }; + struct Test; + + struct TestRunner { + std::string copy; + std::string exec; + std::string cleanup; + std::string remoteDir; + std::string name; + // Runs once per RunTests invocation (cached by `name`). Exit 0 = runner + // is available; non-zero = skip every Test using this runner with a + // "runner unavailable" message. Empty = always available (e.g., Local). + std::string probe; + + bool IsLocal() const { return exec.empty(); } + + static CRAFTER_API TestRunner Local(); + static CRAFTER_API TestRunner Ssh(std::string host, std::string remoteDir = "/tmp/crafter-tests"); + static CRAFTER_API TestRunner SshWin(std::string host, std::string remoteDir = "C:/temp/crafter-tests"); + static CRAFTER_API TestRunner QemuUser(std::string qemuBin); + static CRAFTER_API TestRunner FromEnv(std::string_view target, TestRunner fallback = Local()); + }; + + enum class TestOutcome { Pass, Fail, Crash, Timeout, Skipped }; + + struct TestResult { + std::string name; + TestOutcome outcome = TestOutcome::Pass; + int exitCode = 0; + int signal = 0; + std::chrono::milliseconds duration{0}; + std::string output; + }; + struct Configuration { fs::path path; std::string outputName; @@ -50,6 +85,7 @@ export namespace Crafter { std::string march = "native"; std::string mtune = "native"; std::string target; + std::string sysroot; bool debug = false; ConfigurationType type = ConfigurationType::Executable; std::vector> interfaces; @@ -63,7 +99,8 @@ export namespace Crafter { std::vector externalDependencies; std::vector compileFlags; std::vector linkFlags; - void GetInterfacesAndImplementations(std::span interfaces, std::span implementations); + std::vector tests; + CRAFTER_API void GetInterfacesAndImplementations(std::span interfaces, std::span implementations); fs::path PcmDir() const { return path / (type == ConfigurationType::Executable ? "build" : "bin") @@ -71,7 +108,14 @@ export namespace Crafter { } }; - BuildResult Build(Configuration& config, std::unordered_map>& depResults, std::mutex& depMutex); + struct Test { + Configuration config; + TestRunner runner; + std::chrono::seconds timeout{60}; + std::vector args; + }; - int Run(int argc, char** argv); + CRAFTER_API BuildResult Build(Configuration& config, std::unordered_map>& depResults, std::mutex& depMutex); + + CRAFTER_API int Run(int argc, char** argv); } \ No newline at end of file diff --git a/interfaces/Crafter.Build-External.cppm b/interfaces/Crafter.Build-External.cppm index 6274aee..df4e191 100644 --- a/interfaces/Crafter.Build-External.cppm +++ b/interfaces/Crafter.Build-External.cppm @@ -17,6 +17,8 @@ License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ +module; +#include "Crafter.Build-Api.h" export module Crafter.Build:External; import std; namespace fs = std::filesystem; @@ -49,7 +51,7 @@ export namespace Crafter { fs::file_time_type latestArtifact = fs::file_time_type::min(); }; - ExternalBuildResult BuildExternal( + CRAFTER_API ExternalBuildResult BuildExternal( const ExternalDependency& dep, std::atomic& cancelled); } diff --git a/interfaces/Crafter.Build-Implementation.cppm b/interfaces/Crafter.Build-Implementation.cppm index f6f1528..d9eb736 100644 --- a/interfaces/Crafter.Build-Implementation.cppm +++ b/interfaces/Crafter.Build-Implementation.cppm @@ -17,6 +17,8 @@ License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ +module; +#include "Crafter.Build-Api.h" export module Crafter.Build:Implementation; import std; namespace fs = std::filesystem; @@ -30,8 +32,8 @@ namespace Crafter { std::vector partitionDependencies; std::vector> externalModuleDependencies; fs::path path; - Implementation(fs::path&& path); - bool Check(const fs::path& buildDir, const fs::path& pcmDir, fs::file_time_type sourceFloor = fs::file_time_type::min()) const; - void Compile(const std::string_view clang, const fs::path& buildDir, std::atomic& buildCancelled, std::string& buildError) const; + CRAFTER_API Implementation(fs::path&& path); + CRAFTER_API bool Check(const fs::path& buildDir, const fs::path& pcmDir, fs::file_time_type sourceFloor = fs::file_time_type::min()) const; + CRAFTER_API void Compile(const std::string_view clang, const fs::path& buildDir, std::atomic& buildCancelled, std::string& buildError) const; }; } diff --git a/interfaces/Crafter.Build-Interface.cppm b/interfaces/Crafter.Build-Interface.cppm index 5066c76..7fcc7d4 100644 --- a/interfaces/Crafter.Build-Interface.cppm +++ b/interfaces/Crafter.Build-Interface.cppm @@ -17,6 +17,8 @@ License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ +module; +#include "Crafter.Build-Api.h" export module Crafter.Build:Interface; import std; namespace fs = std::filesystem; @@ -33,9 +35,9 @@ namespace Crafter { bool checked = false; std::string name; fs::path path; - ModulePartition(std::string&& name, fs::path&& path); - bool Check(const fs::path& pcmDir, fs::file_time_type sourceFloor = fs::file_time_type::min()); - void Compile(const std::string_view clang, const fs::path& pcmDir, const fs::path& buildDir, std::atomic& buildCancelled, std::string& buildError); + CRAFTER_API ModulePartition(std::string&& name, fs::path&& path); + CRAFTER_API bool Check(const fs::path& pcmDir, fs::file_time_type sourceFloor = fs::file_time_type::min()); + CRAFTER_API void Compile(const std::string_view clang, const fs::path& pcmDir, const fs::path& buildDir, std::atomic& buildCancelled, std::string& buildError); }; export class Module { @@ -46,8 +48,8 @@ namespace Crafter { std::vector> partitions; std::string name; fs::path path; - Module(std::string&& name, fs::path&& path); - bool Check(const fs::path& pcmDir, fs::file_time_type sourceFloor = fs::file_time_type::min()); - void Compile(const std::string_view clang, const fs::path& pcmDir, const fs::path& buildDir, std::atomic& buildCancelled, std::string& buildError); + CRAFTER_API Module(std::string&& name, fs::path&& path); + CRAFTER_API bool Check(const fs::path& pcmDir, fs::file_time_type sourceFloor = fs::file_time_type::min()); + CRAFTER_API void Compile(const std::string_view clang, const fs::path& pcmDir, const fs::path& buildDir, std::atomic& buildCancelled, std::string& buildError); }; } diff --git a/interfaces/Crafter.Build-Platform.cppm b/interfaces/Crafter.Build-Platform.cppm index f9c0d18..e50cecb 100644 --- a/interfaces/Crafter.Build-Platform.cppm +++ b/interfaces/Crafter.Build-Platform.cppm @@ -17,6 +17,8 @@ License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ +module; +#include "Crafter.Build-Api.h" export module Crafter.Build:Platform; import std; namespace fs = std::filesystem; @@ -24,13 +26,17 @@ namespace fs = std::filesystem; namespace Crafter { struct Configuration; struct CommandResult { - int exitCode; + int exitCode = 0; std::string output; + bool crashed = false; + bool timedOut = false; + int signal = 0; }; std::string BuildStdPcm(const Configuration& config, fs::path stdPcm); fs::path GetCacheDir(); std::string RunCommand(const std::string_view command); CommandResult RunCommandChecked(std::string_view command); + export CRAFTER_API CommandResult RunCommandWithTimeout(std::string_view command, std::chrono::seconds timeout); std::string GetBaseCommand(const Configuration& config); - export Configuration LoadProject(const fs::path& projectFile, std::span args); + export CRAFTER_API Configuration LoadProject(const fs::path& projectFile, std::span args); } \ No newline at end of file diff --git a/interfaces/Crafter.Build-Shader.cppm b/interfaces/Crafter.Build-Shader.cppm index fc599a9..94da9df 100644 --- a/interfaces/Crafter.Build-Shader.cppm +++ b/interfaces/Crafter.Build-Shader.cppm @@ -17,6 +17,8 @@ License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ +module; +#include "Crafter.Build-Api.h" export module Crafter.Build:Shader; import std; namespace fs = std::filesystem; @@ -44,8 +46,8 @@ namespace Crafter { fs::path path; std::string entrypoint; ShaderType type; - Shader(fs::path&& path, std::string&& entrypoint, ShaderType type); - bool Check(const fs::path& outputDir) const; - std::string Compile(const fs::path& outputDir) const; + CRAFTER_API Shader(fs::path&& path, std::string&& entrypoint, ShaderType type); + CRAFTER_API bool Check(const fs::path& outputDir) const; + CRAFTER_API std::string Compile(const fs::path& outputDir) const; }; } diff --git a/interfaces/Crafter.Build-Test.cppm b/interfaces/Crafter.Build-Test.cppm new file mode 100644 index 0000000..72a234f --- /dev/null +++ b/interfaces/Crafter.Build-Test.cppm @@ -0,0 +1,46 @@ +/* +Crafter® Build +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License version 3.0 as published by the Free Software Foundation; + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +module; +#include "Crafter.Build-Api.h" +export module Crafter.Build:Test; +import std; +import :Clang; + +export namespace Crafter { + struct RunTestsOptions { + std::vector globs; + int jobs = 0; + std::optional timeoutOverride; + bool listOnly = false; + }; + + struct TestSummary { + int passed = 0; + int failed = 0; + int crashed = 0; + int timedOut = 0; + int skipped = 0; + std::vector results; + bool AllPassed() const { return failed == 0 && crashed == 0 && timedOut == 0; } + }; + + CRAFTER_API TestSummary RunTests(Configuration& projectCfg, const RunTestsOptions& opts); + CRAFTER_API TestResult RunSingleTest(const Test& test, const std::filesystem::path& binary, std::chrono::seconds timeout); +} diff --git a/interfaces/Crafter.Build.cppm b/interfaces/Crafter.Build.cppm index 4555ab3..52e49cf 100644 --- a/interfaces/Crafter.Build.cppm +++ b/interfaces/Crafter.Build.cppm @@ -22,4 +22,5 @@ export import :Clang; export import :Platform; export import :Implementation; export import :Shader; -export import :External; \ No newline at end of file +export import :External; +export import :Test; \ No newline at end of file diff --git a/project.cpp b/project.cpp index b836b94..2bd6ce6 100644 --- a/project.cpp +++ b/project.cpp @@ -4,38 +4,41 @@ namespace fs = std::filesystem; using namespace Crafter; extern "C" Configuration CrafterBuildProject(std::span args) { - Configuration cfg; - cfg.path = "./"; - cfg.name = "crafter.build"; - cfg.outputName = "crafter-build"; - cfg.target = "x86_64-pc-linux-gnu"; - cfg.type = ConfigurationType::Executable; - + bool debug = false; for (std::string_view arg : args) { - if (arg == "--debug") cfg.debug = true; + if (arg == "--debug") debug = true; } - std::array interfaces = { - "interfaces/Crafter.Build", - "interfaces/Crafter.Build-Shader", - "interfaces/Crafter.Build-Platform", - "interfaces/Crafter.Build-Interface", - "interfaces/Crafter.Build-Implementation", - "interfaces/Crafter.Build-External", - "interfaces/Crafter.Build-Clang", - }; - std::array implementations = { - "implementations/Crafter.Build-Shader", - "implementations/Crafter.Build-Platform", - "implementations/Crafter.Build-Interface", - "implementations/Crafter.Build-Implementation", - "implementations/Crafter.Build-External", - "implementations/Crafter.Build-Clang", - "implementations/main", - }; - cfg.GetInterfacesAndImplementations(interfaces, implementations); - - ExternalDependency& glslang = cfg.externalDependencies.emplace_back(); + static auto crafterBuildLib = std::make_unique(); + crafterBuildLib->path = "./"; + crafterBuildLib->name = "crafter.build-lib"; + crafterBuildLib->outputName = "crafter-build"; + crafterBuildLib->target = "x86_64-pc-linux-gnu"; + crafterBuildLib->type = ConfigurationType::LibraryStatic; + crafterBuildLib->debug = debug; + { + std::array interfaces = { + "interfaces/Crafter.Build", + "interfaces/Crafter.Build-Shader", + "interfaces/Crafter.Build-Platform", + "interfaces/Crafter.Build-Interface", + "interfaces/Crafter.Build-Implementation", + "interfaces/Crafter.Build-External", + "interfaces/Crafter.Build-Clang", + "interfaces/Crafter.Build-Test", + }; + std::array implementations = { + "implementations/Crafter.Build-Shader", + "implementations/Crafter.Build-Platform", + "implementations/Crafter.Build-Interface", + "implementations/Crafter.Build-Implementation", + "implementations/Crafter.Build-External", + "implementations/Crafter.Build-Clang", + "implementations/Crafter.Build-Test", + }; + crafterBuildLib->GetInterfacesAndImplementations(interfaces, implementations); + } + ExternalDependency& glslang = crafterBuildLib->externalDependencies.emplace_back(); glslang.name = "glslang"; glslang.source.url = "https://github.com/KhronosGroup/glslang.git"; glslang.source.branch = "main"; @@ -44,8 +47,155 @@ extern "C" Configuration CrafterBuildProject(std::span a glslang.includeDirs = { "" }; glslang.libs = { "SPIRV", "GenericCodeGen", "glslang", "OSDependent", "MachineIndependent", "glslang-default-resource-limits" }; + Configuration cfg; + cfg.path = "./"; + cfg.name = "crafter.build-exe"; + cfg.outputName = "crafter-build"; + cfg.target = "x86_64-pc-linux-gnu"; + cfg.type = ConfigurationType::Executable; + cfg.debug = debug; + cfg.dependencies = { crafterBuildLib.get() }; + { + std::array interfaces = {}; + std::array implementations = { "implementations/main" }; + cfg.GetInterfacesAndImplementations(interfaces, implementations); + } cfg.linkFlags.push_back("-Wl,--export-dynamic"); cfg.linkFlags.push_back("-ldl"); + // ----- Single-driver, multi-target tests (each appears once per (target, runner)). ----- + + // Lifetime holders for per-target lib Configurations referenced by Tests. + static std::vector> testLibPool; + + struct TargetRunner { + std::string target; + TestRunner runner; + std::vector extraLinkFlags; + }; + std::vector targets = { + { "x86_64-pc-linux-gnu", TestRunner::Local(), {} }, + { "x86_64-w64-mingw32", TestRunner::SshWin("winvm", "C:/temp/crafter-tests"), {"-lstdc++exp"} }, + }; + + auto addPerTarget = [&](std::string name, auto buildOne) { + for (auto& tr : targets) { + Test t; + t.config.name = name; + t.config.target = tr.target; + t.config.type = ConfigurationType::Executable; + t.config.linkFlags.push_back("-fuse-ld=lld"); + for (auto& f : tr.extraLinkFlags) t.config.linkFlags.push_back(f); + buildOne(t, tr.target); + t.runner = tr.runner; + cfg.tests.push_back(std::move(t)); + } + }; + + addPerTarget("HelloWorld", [](Test& t, std::string_view) { + t.config.path = "tests/fixtures/hello-world/"; + t.config.outputName = "hello"; + std::array ifaces = {}; + std::array impls = { "main" }; + t.config.GetInterfacesAndImplementations(ifaces, impls); + }); + + addPerTarget("WithModule", [](Test& t, std::string_view) { + t.config.path = "tests/fixtures/with-module/"; + t.config.outputName = "hello-mod"; + std::array ifaces = { "interfaces/Greeter" }; + std::array impls = { "main" }; + t.config.GetInterfacesAndImplementations(ifaces, impls); + }); + + addPerTarget("Defines", [](Test& t, std::string_view) { + t.config.path = "tests/fixtures/defines/"; + t.config.outputName = "defines-app"; + std::array ifaces = {}; + std::array impls = { "main" }; + t.config.GetInterfacesAndImplementations(ifaces, impls); + t.config.defines.push_back({"CRAFTER_TEST_FOO", "42"}); + }); + + addPerTarget("CrossProjectModule", [&](Test& t, std::string_view target) { + auto fooLib = std::make_unique(); + fooLib->path = "tests/fixtures/cross-project/lib/"; + fooLib->name = std::format("Foo-cross-project-{}", target); + fooLib->outputName = "Foo"; + fooLib->target = std::string(target); + fooLib->type = ConfigurationType::LibraryStatic; + std::array libIfaces = { "Foo" }; + std::array libImpls = {}; + fooLib->GetInterfacesAndImplementations(libIfaces, libImpls); + Configuration* fooLibPtr = fooLib.get(); + testLibPool.push_back(std::move(fooLib)); + + t.config.path = "tests/fixtures/cross-project/"; + t.config.outputName = "cross-app"; + t.config.dependencies = { fooLibPtr }; + std::array mainIfaces = {}; + std::array mainImpls = { "main" }; + t.config.GetInterfacesAndImplementations(mainIfaces, mainImpls); + }); + + auto makeDiamondLib = [&](std::string_view dir, std::string_view modName, + std::string_view target, std::span deps) { + auto lib = std::make_unique(); + lib->path = std::format("tests/fixtures/diamond/{}/", dir); + lib->name = std::format("{}-diamond-{}", modName, target); + lib->outputName = std::string(modName); + lib->target = std::string(target); + lib->type = ConfigurationType::LibraryStatic; + lib->dependencies.assign(deps.begin(), deps.end()); + std::array ifaces = { fs::path(modName) }; + std::array impls = {}; + lib->GetInterfacesAndImplementations(ifaces, impls); + return lib; + }; + + addPerTarget("Diamond", [&](Test& t, std::string_view target) { + auto X = makeDiamondLib("X", "X", target, {}); + Configuration* xDeps[] = { X.get() }; + auto B = makeDiamondLib("B", "B", target, xDeps); + auto C = makeDiamondLib("C", "C", target, xDeps); + Configuration* mainDeps[] = { B.get(), C.get() }; + + t.config.path = "tests/fixtures/diamond/"; + t.config.outputName = "diamond-app"; + t.config.dependencies.assign(mainDeps, mainDeps + 2); + std::array ifaces = {}; + std::array impls = { "main" }; + t.config.GetInterfacesAndImplementations(ifaces, impls); + + testLibPool.push_back(std::move(X)); + testLibPool.push_back(std::move(B)); + testLibPool.push_back(std::move(C)); + }); + + // ----- Outer-driver tests (still need their own driver source for multi-step + // logic or meta-runner behavior). ----- + + auto addOuterDriverTest = [&](std::string name) { + Test t; + t.config.path = "./"; + t.config.name = name; + t.config.outputName = name; + t.config.target = "x86_64-pc-linux-gnu"; + t.config.type = ConfigurationType::Executable; + std::array empty = {}; + std::array impls = { fs::path("tests") / name }; + t.config.GetInterfacesAndImplementations(empty, impls); + cfg.tests.push_back(std::move(t)); + }; + + addOuterDriverTest("Incremental"); + addOuterDriverTest("BuildError"); + addOuterDriverTest("Libraries"); + addOuterDriverTest("RunnerClassification"); + addOuterDriverTest("QemuUser"); + addOuterDriverTest("SshRunner"); + addOuterDriverTest("CrossArchAarch64"); + addOuterDriverTest("WindowsViaSsh"); + return cfg; } diff --git a/tests/BuildError.cpp b/tests/BuildError.cpp new file mode 100644 index 0000000..d7b8375 --- /dev/null +++ b/tests/BuildError.cpp @@ -0,0 +1,46 @@ +/* +Crafter® Build +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +LGPL-3.0-only. +*/ + +import std; +#include "TestUtil.h" +namespace fs = std::filesystem; +using namespace TestUtil; + +int main() { + try { + fs::path projectRoot = fs::current_path(); + fs::path src = projectRoot / "tests" / "fixtures" / "build-error"; + fs::path crafterBuild = projectRoot / "bin" / "crafter-build"; + + fs::path work = CopyFixtureToTemp("BuildError", src); + + auto build = RunInDir(work, std::format("'{}'", crafterBuild.string())); + if (build.exitCode == 0) { + std::println(std::cerr, "expected nonzero exit, got 0; build output:\n{}", build.output); + return 1; + } + + // diagnostic must surface the unresolved name; fragile-ish but recognizable + if (build.output.find("undefined_symbol_xyzzy_oqv") == std::string::npos) { + std::println(std::cerr, "diagnostic missing unresolved-name reference:\n{}", build.output); + return 1; + } + + // and the artifact must NOT have been produced + fs::path artifact = work / "bin" / "broken-x86_64-pc-linux-gnu-native" / "broken"; + if (fs::exists(artifact)) { + std::println(std::cerr, "artifact unexpectedly produced at {}", artifact.string()); + return 1; + } + + return 0; + } catch (const std::exception& e) { + std::println(std::cerr, "test exception: {}", e.what()); + return 1; + } +} diff --git a/tests/CrossArchAarch64.cpp b/tests/CrossArchAarch64.cpp new file mode 100644 index 0000000..d2e2e3d --- /dev/null +++ b/tests/CrossArchAarch64.cpp @@ -0,0 +1,86 @@ +/* +Crafter® Build +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +LGPL-3.0-only. + +End-to-end cross-arch build through the V2 pipeline: +the fixture's project.cpp targets aarch64-linux-gnu with cfg.sysroot pointing at +the Arch Linux ARM rootfs at /opt/aarch64-rootfs. crafter-build cross-compiles +the C++ source (with the libc++ std module from the sysroot), produces a real +aarch64 ELF, and qemu-aarch64 (with QEMU_LD_PREFIX pointing at the sysroot so it +can find ld-linux-aarch64.so.1) executes it. +*/ + +import std; +#include "TestUtil.h" +namespace fs = std::filesystem; +using namespace TestUtil; + +namespace { + bool ToolPresent(std::string_view name) { + std::string cmd = std::format("which {} > /dev/null 2>&1", name); + return std::system(cmd.c_str()) == 0; + } +} + +int main() { + try { + const fs::path sysroot = "/opt/aarch64-rootfs"; + if (!fs::exists(sysroot / "usr/share/libc++/v1/std.cppm")) { + std::println("(skipped: aarch64 sysroot missing at {} — see README)", sysroot.string()); + return 0; + } + if (!ToolPresent("qemu-aarch64")) { + std::println("(skipped: qemu-aarch64 not on PATH)"); + return 0; + } + + fs::path projectRoot = fs::current_path(); + fs::path src = projectRoot / "tests" / "fixtures" / "cross-arch-aarch64"; + fs::path crafterBuild = projectRoot / "bin" / "crafter-build"; + + fs::path work = CopyFixtureToTemp("CrossArchAarch64", src); + + // Build through the V2 pipeline. The fixture's project.cpp pins + // cfg.target = "aarch64-linux-gnu" and cfg.sysroot = "/opt/aarch64-rootfs". + auto build = RunInDir(work, std::format("'{}'", crafterBuild.string())); + if (build.exitCode != 0) { + std::println(std::cerr, "build failed (rc={}):\n{}", build.exitCode, build.output); + return 1; + } + + fs::path artifact = work / "bin" / "aarch-hello-aarch64-linux-gnu-armv8-a" / "aarch-hello"; + if (!fs::exists(artifact)) { + std::println(std::cerr, "expected artifact missing at {}\nbuild log:\n{}", + artifact.string(), build.output); + return 1; + } + + // Defend against silent host-arch fallback. + auto probe = RunInDir(work, std::format("file '{}'", artifact.string())); + if (probe.output.find("ARM aarch64") == std::string::npos) { + std::println(std::cerr, "artifact is not ARM aarch64 ELF:\n{}", probe.output); + return 1; + } + + // Run via qemu-aarch64. QEMU_LD_PREFIX tells qemu where the target's + // dynamic linker (ld-linux-aarch64.so.1) and shared libs live. + auto run = RunInDir(work, std::format( + "QEMU_LD_PREFIX={} qemu-aarch64 '{}'", + sysroot.string(), artifact.string())); + if (run.exitCode != 0) { + std::println(std::cerr, "qemu-aarch64 run failed (rc={}):\n{}", run.exitCode, run.output); + return 1; + } + if (run.output != "hi from 64-bit aarch64\n") { + std::println(std::cerr, "output mismatch:\n expected: \"hi from 64-bit aarch64\\n\"\n got: {:?}", run.output); + return 1; + } + return 0; + } catch (const std::exception& e) { + std::println(std::cerr, "test exception: {}", e.what()); + return 1; + } +} diff --git a/tests/Incremental.cpp b/tests/Incremental.cpp new file mode 100644 index 0000000..8825f41 --- /dev/null +++ b/tests/Incremental.cpp @@ -0,0 +1,76 @@ +/* +Crafter® Build +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +LGPL-3.0-only. +*/ + +import std; +#include "TestUtil.h" +namespace fs = std::filesystem; +using namespace std::chrono_literals; +using namespace TestUtil; + +int main() { + try { + fs::path projectRoot = fs::current_path(); + fs::path src = projectRoot / "tests" / "fixtures" / "incremental"; + fs::path crafterBuild = projectRoot / "bin" / "crafter-build"; + + fs::path work = CopyFixtureToTemp("Incremental", src); + fs::path buildDir = work / "build" / "hello-mod-x86_64-pc-linux-gnu-native"; + fs::path greeterObj = buildDir / "Greeter.o"; + fs::path mainObj = buildDir / "main_impl.o"; + std::string buildCmd = std::format("'{}'", crafterBuild.string()); + + // 1. cold build + if (auto r = RunInDir(work, buildCmd); r.exitCode != 0) { + std::println(std::cerr, "cold build failed (rc={}):\n{}", r.exitCode, r.output); + return 1; + } + if (!fs::exists(greeterObj) || !fs::exists(mainObj)) { + std::println(std::cerr, "expected .o files missing after cold build"); + return 1; + } + auto greeter_t1 = fs::last_write_time(greeterObj); + auto main_t1 = fs::last_write_time(mainObj); + + // 2. no-op rebuild: nothing should be regenerated + if (auto r = RunInDir(work, buildCmd); r.exitCode != 0) { + std::println(std::cerr, "no-op rebuild failed (rc={}):\n{}", r.exitCode, r.output); + return 1; + } + auto greeter_t2 = fs::last_write_time(greeterObj); + auto main_t2 = fs::last_write_time(mainObj); + if (greeter_t2 != greeter_t1) { + std::println(std::cerr, "no-op rebuild regenerated Greeter.o"); + return 1; + } + if (main_t2 != main_t1) { + std::println(std::cerr, "no-op rebuild regenerated main_impl.o"); + return 1; + } + + // 3. touch main.cpp: only main_impl.o should regenerate + fs::last_write_time(work / "main.cpp", std::chrono::file_clock::now() + 2s); + if (auto r = RunInDir(work, buildCmd); r.exitCode != 0) { + std::println(std::cerr, "rebuild after touch failed (rc={}):\n{}", r.exitCode, r.output); + return 1; + } + auto greeter_t3 = fs::last_write_time(greeterObj); + auto main_t3 = fs::last_write_time(mainObj); + if (greeter_t3 != greeter_t1) { + std::println(std::cerr, "touching main.cpp unnecessarily rebuilt Greeter.o"); + return 1; + } + if (main_t3 <= main_t1) { + std::println(std::cerr, "touching main.cpp did NOT rebuild main_impl.o"); + return 1; + } + return 0; + } catch (const std::exception& e) { + std::println(std::cerr, "test exception: {}", e.what()); + return 1; + } +} diff --git a/tests/Libraries.cpp b/tests/Libraries.cpp new file mode 100644 index 0000000..8977eac --- /dev/null +++ b/tests/Libraries.cpp @@ -0,0 +1,66 @@ +/* +Crafter® Build +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +LGPL-3.0-only. +*/ + +import std; +#include "TestUtil.h" +namespace fs = std::filesystem; +using namespace TestUtil; + +int main() { + try { + fs::path projectRoot = fs::current_path(); + fs::path src = projectRoot / "tests" / "fixtures" / "libraries"; + fs::path crafterBuild = projectRoot / "bin" / "crafter-build"; + + fs::path work = CopyFixtureToTemp("Libraries", src); + + auto build = RunInDir(work, std::format("'{}'", crafterBuild.string())); + if (build.exitCode != 0) { + std::println(std::cerr, "build failed (rc={}):\n{}", build.exitCode, build.output); + return 1; + } + + fs::path staticArchive = work / "mathlib" / "bin" / "MathLib-x86_64-pc-linux-gnu-native" / "libMathLib.a"; + fs::path dynamicSO = work / "greetlib" / "bin" / "GreetLib-x86_64-pc-linux-gnu-native" / "libGreetLib.so"; + fs::path artifact = work / "bin" / "libs-app-x86_64-pc-linux-gnu-native" / "libs-app"; + + if (!fs::exists(staticArchive)) { + std::println(std::cerr, "static archive missing at {}", staticArchive.string()); + return 1; + } + if (!fs::exists(dynamicSO)) { + std::println(std::cerr, "dynamic .so missing at {}", dynamicSO.string()); + return 1; + } + if (!fs::exists(artifact)) { + std::println(std::cerr, "exe missing at {}\nbuild log:\n{}", artifact.string(), build.output); + return 1; + } + + // The exe linked against a dynamic .so needs LD_LIBRARY_PATH or rpath to find it. + // Build() already passes -Wl,-rpath,'$ORIGIN' for shared libs, but the .so lives in + // greetlib/bin/... while the exe lives in bin/libs-app-... — different dirs. Set + // LD_LIBRARY_PATH explicitly. + auto run = RunInDir(work, std::format( + "LD_LIBRARY_PATH='{}' '{}'", + dynamicSO.parent_path().string(), artifact.string())); + if (run.exitCode != 0) { + std::println(std::cerr, "artifact exited nonzero (rc={}):\n{}", run.exitCode, run.output); + return 1; + } + + if (run.output != "hi=42\n") { + std::println(std::cerr, "output mismatch:\n expected: \"hi=42\\n\"\n got: {:?}", run.output); + return 1; + } + return 0; + } catch (const std::exception& e) { + std::println(std::cerr, "test exception: {}", e.what()); + return 1; + } +} diff --git a/tests/QemuUser.cpp b/tests/QemuUser.cpp new file mode 100644 index 0000000..0958981 --- /dev/null +++ b/tests/QemuUser.cpp @@ -0,0 +1,65 @@ +/* +Crafter® Build +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +LGPL-3.0-only. +*/ + +import std; +#include "TestUtil.h" +namespace fs = std::filesystem; +using namespace TestUtil; + +namespace { + bool Contains(std::string_view haystack, std::string_view needle) { + return haystack.find(needle) != std::string_view::npos; + } + + std::string PickQemu() { + if (const char* v = std::getenv("CRAFTER_TEST_QEMU"); v && *v) return v; + return "qemu-x86_64"; + } + + bool QemuPresent(const std::string& qemu) { + std::string cmd = std::format("which {} > /dev/null 2>&1", qemu); + return std::system(cmd.c_str()) == 0; + } +} + +int main() { + try { + std::string qemu = PickQemu(); + if (!QemuPresent(qemu)) { + std::println("(skipped: {} not on PATH)", qemu); + return 0; + } + + fs::path projectRoot = fs::current_path(); + fs::path src = projectRoot / "tests" / "fixtures" / "qemu-runner"; + fs::path crafterBuild = projectRoot / "bin" / "crafter-build"; + + fs::path work = CopyFixtureToTemp("QemuUser", src); + + // Tell the inner crafter-build to use qemu for the host triple via the + // FromEnv mechanism that the fixture's project.cpp opted into. + auto run = RunInDir(work, std::format( + "CRAFTER_BUILD_RUNNER_x86_64_pc_linux_gnu='qemu:{}' '{}' test", + qemu, crafterBuild.string())); + + if (run.exitCode != 0) { + std::println(std::cerr, "inner runner failed (rc={}):\n{}", run.exitCode, run.output); + return 1; + } + if (!Contains(run.output, std::format("\xE2\x9C\x85 Hello (qemu:{})", qemu))) { + std::println(std::cerr, + "expected '✅ Hello (qemu:{})' marker not found in inner output:\n{}", + qemu, run.output); + return 1; + } + return 0; + } catch (const std::exception& e) { + std::println(std::cerr, "test exception: {}", e.what()); + return 1; + } +} diff --git a/tests/RunnerClassification.cpp b/tests/RunnerClassification.cpp new file mode 100644 index 0000000..4cb169c --- /dev/null +++ b/tests/RunnerClassification.cpp @@ -0,0 +1,61 @@ +/* +Crafter® Build +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +LGPL-3.0-only. +*/ + +import std; +#include "TestUtil.h" +namespace fs = std::filesystem; +using namespace TestUtil; + +namespace { + bool Contains(std::string_view haystack, std::string_view needle) { + return haystack.find(needle) != std::string_view::npos; + } +} + +int main() { + try { + fs::path projectRoot = fs::current_path(); + fs::path src = projectRoot / "tests" / "fixtures" / "runner-classification"; + fs::path crafterBuild = projectRoot / "bin" / "crafter-build"; + + fs::path work = CopyFixtureToTemp("RunnerClassification", src); + + // Run the inner crafter-build test with a short timeout for the Hang test. + auto run = RunInDir(work, std::format("'{}' test --timeout=2", crafterBuild.string())); + + // Inner runner must report 1 passed + 1 failed + 1 crashed + 1 timed out. + // Therefore exit code must be nonzero. + if (run.exitCode == 0) { + std::println(std::cerr, "inner runner unexpectedly succeeded:\n{}", run.output); + return 1; + } + + struct Check { std::string_view name; std::string_view marker; }; + Check checks[] = { + {"Pass", "\xE2\x9C\x85 Pass"}, // ✅ Pass + {"Fail", "\xE2\x9D\x8C Fail"}, // ❌ Fail + {"Crash", "Crash"}, // any line mentioning Crash + {"crashed", "crashed:"}, // crash classifier line + {"Hang", "Hang"}, // any line mentioning Hang + {"timeout", "timeout"}, // timeout classifier word + {"summary", "1 passed, 1 failed, 1 crashed, 1 timed out"}, + }; + + for (auto& c : checks) { + if (!Contains(run.output, c.marker)) { + std::println(std::cerr, "expected marker {:?} ({}) not found in inner output:\n{}", + c.marker, c.name, run.output); + return 1; + } + } + return 0; + } catch (const std::exception& e) { + std::println(std::cerr, "test exception: {}", e.what()); + return 1; + } +} diff --git a/tests/ShouldCompile.cpp b/tests/ShouldCompile.cpp deleted file mode 100644 index 46daab0..0000000 --- a/tests/ShouldCompile.cpp +++ /dev/null @@ -1,29 +0,0 @@ -/* -Crafter® Build -Copyright (C) 2026 Catcrafts® -Catcrafts.net - -This library is free software; you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License version 3.0 as published by the Free Software Foundation; - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library; if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -*/ -import Crafter.Build; -import std; -using namespace Crafter; - -extern "C" { - std::string* RunTest() { - return nullptr; - } -} - - diff --git a/tests/SshRunner.cpp b/tests/SshRunner.cpp new file mode 100644 index 0000000..ce0f850 --- /dev/null +++ b/tests/SshRunner.cpp @@ -0,0 +1,63 @@ +/* +Crafter® Build +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +LGPL-3.0-only. +*/ + +import std; +#include "TestUtil.h" +namespace fs = std::filesystem; +using namespace TestUtil; + +namespace { + bool Contains(std::string_view haystack, std::string_view needle) { + return haystack.find(needle) != std::string_view::npos; + } +} + +int main() { + try { + const char* hostEnv = std::getenv("CRAFTER_TEST_SSH_HOST"); + if (!hostEnv || !*hostEnv) { + std::println("(skipped: set CRAFTER_TEST_SSH_HOST to enable)"); + return 0; + } + std::string host = hostEnv; + + // Confirm the host is actually reachable; otherwise skip rather than fail + // (tests should not depend on transient network state). + std::string probe = std::format("ssh -o BatchMode=yes -o ConnectTimeout=5 {} true > /dev/null 2>&1", host); + if (std::system(probe.c_str()) != 0) { + std::println("(skipped: ssh {} not reachable)", host); + return 0; + } + + fs::path projectRoot = fs::current_path(); + fs::path src = projectRoot / "tests" / "fixtures" / "ssh-runner"; + fs::path crafterBuild = projectRoot / "bin" / "crafter-build"; + + fs::path work = CopyFixtureToTemp("SshRunner", src); + + std::string remoteDir = "/tmp/crafter-test-ssh-runner"; + auto run = RunInDir(work, std::format( + "CRAFTER_BUILD_RUNNER_x86_64_pc_linux_gnu='ssh:{}:{}' '{}' test", + host, remoteDir, crafterBuild.string())); + + if (run.exitCode != 0) { + std::println(std::cerr, "inner runner failed (rc={}):\n{}", run.exitCode, run.output); + return 1; + } + if (!Contains(run.output, std::format("\xE2\x9C\x85 Hello (ssh:{})", host))) { + std::println(std::cerr, + "expected '✅ Hello (ssh:{})' marker not found in inner output:\n{}", + host, run.output); + return 1; + } + return 0; + } catch (const std::exception& e) { + std::println(std::cerr, "test exception: {}", e.what()); + return 1; + } +} diff --git a/tests/TestUtil.h b/tests/TestUtil.h new file mode 100644 index 0000000..106f5f5 --- /dev/null +++ b/tests/TestUtil.h @@ -0,0 +1,36 @@ +#pragma once + +namespace TestUtil { + inline std::string ReadFile(const std::filesystem::path& p) { + std::ifstream f(p); + std::stringstream ss; + ss << f.rdbuf(); + return ss.str(); + } + + inline std::filesystem::path CopyFixtureToTemp(std::string_view testName, const std::filesystem::path& source) { + namespace fs = std::filesystem; + fs::path tmp = fs::temp_directory_path() / std::format("crafter-test-{}", testName); + fs::remove_all(tmp); + fs::copy(source, tmp, fs::copy_options::recursive); + return tmp; + } + + struct CmdResult { + int exitCode; + std::string output; + }; + + inline CmdResult RunInDir(const std::filesystem::path& cwd, std::string_view command) { + namespace fs = std::filesystem; + // Log inside cwd so parallel test drivers don't trample each other. + fs::path log = cwd / ".crafter-cmd-output.log"; + std::string cmd = std::format("cd '{}' && {} > '{}' 2>&1", + cwd.string(), command, log.string()); + int rc = std::system(cmd.c_str()); + std::string out = ReadFile(log); + std::error_code ec; + fs::remove(log, ec); + return {rc, std::move(out)}; + } +} diff --git a/tests/WindowsViaSsh.cpp b/tests/WindowsViaSsh.cpp new file mode 100644 index 0000000..4ea10e7 --- /dev/null +++ b/tests/WindowsViaSsh.cpp @@ -0,0 +1,85 @@ +/* +Crafter® Build +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +LGPL-3.0-only. + +End-to-end Linux→Windows via SSH: +the fixture cross-compiles main.cpp for x86_64-w64-mingw32 (V2's existing MinGW +build path), the resulting .exe + runtime DLLs (which Build() already copies +into the output dir for the mingw target) get scp'd to a Windows host (winvm by +default), then ssh runs the .exe under cmd.exe and we capture stdout. Exercises +the same build-system features HelloWorld covers, but for the Windows target +path. Gated on: + - mingw cross-toolchain installed on the host (x86_64-w64-mingw32-g++) + - CRAFTER_TEST_WIN_SSH_HOST env var set (defaults to no-skip if "winvm" is + reachable, but explicit opt-in keeps CI green by default) +*/ + +import std; +#include "TestUtil.h" +namespace fs = std::filesystem; +using namespace TestUtil; + +namespace { + bool ToolPresent(std::string_view name) { + std::string cmd = std::format("which {} > /dev/null 2>&1", name); + return std::system(cmd.c_str()) == 0; + } + + bool Contains(std::string_view haystack, std::string_view needle) { + return haystack.find(needle) != std::string_view::npos; + } +} + +int main() { + try { + const char* hostEnv = std::getenv("CRAFTER_TEST_WIN_SSH_HOST"); + if (!hostEnv || !*hostEnv) { + std::println("(skipped: set CRAFTER_TEST_WIN_SSH_HOST to enable, e.g. winvm)"); + return 0; + } + std::string host = hostEnv; + + if (!ToolPresent("x86_64-w64-mingw32-g++")) { + std::println("(skipped: mingw cross-toolchain not on PATH)"); + return 0; + } + + // Probe the SSH host; skip on transient unreachability rather than fail. + std::string probe = std::format( + "ssh -o BatchMode=yes -o ConnectTimeout=5 {} \"ver\" > /dev/null 2>&1", host); + if (std::system(probe.c_str()) != 0) { + std::println("(skipped: ssh {} not reachable)", host); + return 0; + } + + fs::path projectRoot = fs::current_path(); + fs::path src = projectRoot / "tests" / "fixtures" / "windows-via-ssh"; + fs::path crafterBuild = projectRoot / "bin" / "crafter-build"; + + fs::path work = CopyFixtureToTemp("WindowsViaSsh", src); + + std::string remoteDir = "C:/temp/crafter-test-winhello"; + auto run = RunInDir(work, std::format( + "CRAFTER_BUILD_RUNNER_x86_64_w64_mingw32='sshwin:{}:{}' '{}' test", + host, remoteDir, crafterBuild.string())); + + if (run.exitCode != 0) { + std::println(std::cerr, "inner runner failed (rc={}):\n{}", run.exitCode, run.output); + return 1; + } + std::string marker = std::format("\xE2\x9C\x85 winhello (sshwin:{})", host); + if (!Contains(run.output, marker)) { + std::println(std::cerr, + "expected marker {:?} not found in inner output:\n{}", + marker, run.output); + return 1; + } + return 0; + } catch (const std::exception& e) { + std::println(std::cerr, "test exception: {}", e.what()); + return 1; + } +} diff --git a/tests/fixtures/build-error/main.cpp b/tests/fixtures/build-error/main.cpp new file mode 100644 index 0000000..a4e6476 --- /dev/null +++ b/tests/fixtures/build-error/main.cpp @@ -0,0 +1,3 @@ +int main() { + return undefined_symbol_xyzzy_oqv; +} diff --git a/tests/fixtures/build-error/project.cpp b/tests/fixtures/build-error/project.cpp new file mode 100644 index 0000000..42e4d6b --- /dev/null +++ b/tests/fixtures/build-error/project.cpp @@ -0,0 +1,22 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "broken"; + cfg.outputName = "broken"; + cfg.target = "x86_64-pc-linux-gnu"; + cfg.type = ConfigurationType::Executable; + + std::array ifaces = {}; + std::array impls = { "main" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + + cfg.linkFlags.push_back("-Wl,--export-dynamic"); + cfg.linkFlags.push_back("-ldl"); + + return cfg; +} diff --git a/tests/fixtures/cross-arch-aarch64/main.cpp b/tests/fixtures/cross-arch-aarch64/main.cpp new file mode 100644 index 0000000..9dee6f7 --- /dev/null +++ b/tests/fixtures/cross-arch-aarch64/main.cpp @@ -0,0 +1,6 @@ +import std; + +int main() { + std::println("hi from {}-bit aarch64", sizeof(void*) * 8); + return 0; +} diff --git a/tests/fixtures/cross-arch-aarch64/project.cpp b/tests/fixtures/cross-arch-aarch64/project.cpp new file mode 100644 index 0000000..5a751a8 --- /dev/null +++ b/tests/fixtures/cross-arch-aarch64/project.cpp @@ -0,0 +1,24 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "aarch-hello"; + cfg.outputName = "aarch-hello"; + cfg.target = "aarch64-linux-gnu"; + cfg.march = "armv8-a"; + cfg.mtune = "generic"; + cfg.sysroot = "/opt/aarch64-rootfs"; + cfg.type = ConfigurationType::Executable; + + std::array ifaces = {}; + std::array impls = { "main" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + + cfg.linkFlags.push_back("-fuse-ld=lld"); + + return cfg; +} diff --git a/tests/fixtures/cross-project/lib/Foo.cppm b/tests/fixtures/cross-project/lib/Foo.cppm new file mode 100644 index 0000000..4964ffc --- /dev/null +++ b/tests/fixtures/cross-project/lib/Foo.cppm @@ -0,0 +1,5 @@ +export module Foo; + +export int Compute() { + return 7 * 6; +} diff --git a/tests/fixtures/cross-project/main.cpp b/tests/fixtures/cross-project/main.cpp new file mode 100644 index 0000000..7635fd4 --- /dev/null +++ b/tests/fixtures/cross-project/main.cpp @@ -0,0 +1,7 @@ +import std; +import Foo; + +int main() { + if (Compute() != 42) return 1; + return 0; +} diff --git a/tests/fixtures/defines/main.cpp b/tests/fixtures/defines/main.cpp new file mode 100644 index 0000000..86a39b2 --- /dev/null +++ b/tests/fixtures/defines/main.cpp @@ -0,0 +1,8 @@ +import std; + +static_assert(CRAFTER_TEST_FOO == 42, "CRAFTER_TEST_FOO should be 42"); + +int main() { + if (CRAFTER_TEST_FOO != 42) return 1; + return 0; +} diff --git a/tests/fixtures/diamond/B/B.cppm b/tests/fixtures/diamond/B/B.cppm new file mode 100644 index 0000000..88351c2 --- /dev/null +++ b/tests/fixtures/diamond/B/B.cppm @@ -0,0 +1,6 @@ +export module B; +import X; + +export int BValue() { + return XValue() * 2; +} diff --git a/tests/fixtures/diamond/C/C.cppm b/tests/fixtures/diamond/C/C.cppm new file mode 100644 index 0000000..18f3020 --- /dev/null +++ b/tests/fixtures/diamond/C/C.cppm @@ -0,0 +1,6 @@ +export module C; +import X; + +export int CValue() { + return XValue() * 3; +} diff --git a/tests/fixtures/diamond/X/X.cppm b/tests/fixtures/diamond/X/X.cppm new file mode 100644 index 0000000..c059f0c --- /dev/null +++ b/tests/fixtures/diamond/X/X.cppm @@ -0,0 +1,5 @@ +export module X; + +export int XValue() { + return 7; +} diff --git a/tests/fixtures/diamond/main.cpp b/tests/fixtures/diamond/main.cpp new file mode 100644 index 0000000..7d687bb --- /dev/null +++ b/tests/fixtures/diamond/main.cpp @@ -0,0 +1,9 @@ +import std; +import B; +import C; + +int main() { + if (BValue() != 14) return 1; // X(7) * 2 + if (CValue() != 21) return 1; // X(7) * 3 + return 0; +} diff --git a/tests/fixtures/hello-world/main.cpp b/tests/fixtures/hello-world/main.cpp new file mode 100644 index 0000000..92cf953 --- /dev/null +++ b/tests/fixtures/hello-world/main.cpp @@ -0,0 +1,7 @@ +import std; + +int main() { + // hello-world is degenerate: the runner reports ✅ on exit 0, which is the + // signal that the build produced a runnable binary. + return 0; +} diff --git a/tests/fixtures/incremental/interfaces/Greeter.cppm b/tests/fixtures/incremental/interfaces/Greeter.cppm new file mode 100644 index 0000000..1294d98 --- /dev/null +++ b/tests/fixtures/incremental/interfaces/Greeter.cppm @@ -0,0 +1,6 @@ +export module Greeter; +import std; + +export std::string Greet(std::string_view name) { + return std::format("hello, {}!", name); +} diff --git a/tests/fixtures/incremental/main.cpp b/tests/fixtures/incremental/main.cpp new file mode 100644 index 0000000..b331987 --- /dev/null +++ b/tests/fixtures/incremental/main.cpp @@ -0,0 +1,7 @@ +import std; +import Greeter; + +int main() { + std::println("{}", Greet("crafter")); + return 0; +} diff --git a/tests/fixtures/incremental/project.cpp b/tests/fixtures/incremental/project.cpp new file mode 100644 index 0000000..548399d --- /dev/null +++ b/tests/fixtures/incremental/project.cpp @@ -0,0 +1,22 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "hello-mod"; + cfg.outputName = "hello-mod"; + cfg.target = "x86_64-pc-linux-gnu"; + cfg.type = ConfigurationType::Executable; + + std::array ifaces = { "interfaces/Greeter" }; + std::array impls = { "main" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + + cfg.linkFlags.push_back("-Wl,--export-dynamic"); + cfg.linkFlags.push_back("-ldl"); + + return cfg; +} diff --git a/tests/fixtures/libraries/greetlib/GreetLib.cppm b/tests/fixtures/libraries/greetlib/GreetLib.cppm new file mode 100644 index 0000000..ce2260f --- /dev/null +++ b/tests/fixtures/libraries/greetlib/GreetLib.cppm @@ -0,0 +1,6 @@ +export module GreetLib; +import std; + +export std::string Greet() { + return "hi"; +} diff --git a/tests/fixtures/libraries/main.cpp b/tests/fixtures/libraries/main.cpp new file mode 100644 index 0000000..4268458 --- /dev/null +++ b/tests/fixtures/libraries/main.cpp @@ -0,0 +1,8 @@ +import std; +import MathLib; +import GreetLib; + +int main() { + std::println("{}={}", Greet(), Add(40, 2)); + return 0; +} diff --git a/tests/fixtures/libraries/mathlib/MathLib.cppm b/tests/fixtures/libraries/mathlib/MathLib.cppm new file mode 100644 index 0000000..592c81a --- /dev/null +++ b/tests/fixtures/libraries/mathlib/MathLib.cppm @@ -0,0 +1,5 @@ +export module MathLib; + +export int Add(int a, int b) { + return a + b; +} diff --git a/tests/fixtures/libraries/project.cpp b/tests/fixtures/libraries/project.cpp new file mode 100644 index 0000000..3fe42e0 --- /dev/null +++ b/tests/fixtures/libraries/project.cpp @@ -0,0 +1,47 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + static auto MathStatic = std::make_unique(); + MathStatic->path = "./mathlib/"; + MathStatic->name = "MathLib"; + MathStatic->outputName = "MathLib"; + MathStatic->target = "x86_64-pc-linux-gnu"; + MathStatic->type = ConfigurationType::LibraryStatic; + { + std::array ifaces = { "MathLib" }; + std::array impls = {}; + MathStatic->GetInterfacesAndImplementations(ifaces, impls); + } + + static auto GreetDynamic = std::make_unique(); + GreetDynamic->path = "./greetlib/"; + GreetDynamic->name = "GreetLib"; + GreetDynamic->outputName = "GreetLib"; + GreetDynamic->target = "x86_64-pc-linux-gnu"; + GreetDynamic->type = ConfigurationType::LibraryDynamic; + { + std::array ifaces = { "GreetLib" }; + std::array impls = {}; + GreetDynamic->GetInterfacesAndImplementations(ifaces, impls); + } + + Configuration app; + app.path = "./"; + app.name = "libs-app"; + app.outputName = "libs-app"; + app.target = "x86_64-pc-linux-gnu"; + app.type = ConfigurationType::Executable; + app.dependencies = { MathStatic.get(), GreetDynamic.get() }; + { + std::array ifaces = {}; + std::array impls = { "main" }; + app.GetInterfacesAndImplementations(ifaces, impls); + } + app.linkFlags.push_back("-Wl,--export-dynamic"); + app.linkFlags.push_back("-ldl"); + + return app; +} diff --git a/tests/fixtures/qemu-runner/project.cpp b/tests/fixtures/qemu-runner/project.cpp new file mode 100644 index 0000000..63f60ad --- /dev/null +++ b/tests/fixtures/qemu-runner/project.cpp @@ -0,0 +1,27 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "qemu-meta"; + cfg.outputName = "qemu-meta"; + cfg.target = "x86_64-pc-linux-gnu"; + cfg.type = ConfigurationType::Executable; + + Test t; + t.config.path = "./"; + t.config.name = "Hello"; + t.config.outputName = "Hello"; + t.config.target = "x86_64-pc-linux-gnu"; + t.config.type = ConfigurationType::Executable; + std::array ifaces = {}; + std::array impls = { "tests/Hello" }; + t.config.GetInterfacesAndImplementations(ifaces, impls); + t.runner = TestRunner::FromEnv(t.config.target); + cfg.tests.push_back(std::move(t)); + + return cfg; +} diff --git a/tests/fixtures/qemu-runner/tests/Hello.cpp b/tests/fixtures/qemu-runner/tests/Hello.cpp new file mode 100644 index 0000000..3849425 --- /dev/null +++ b/tests/fixtures/qemu-runner/tests/Hello.cpp @@ -0,0 +1,6 @@ +import std; + +int main() { + std::println("hello-from-qemu"); + return 0; +} diff --git a/tests/fixtures/runner-classification/project.cpp b/tests/fixtures/runner-classification/project.cpp new file mode 100644 index 0000000..841872f --- /dev/null +++ b/tests/fixtures/runner-classification/project.cpp @@ -0,0 +1,33 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "rc-meta"; + cfg.outputName = "rc-meta"; + cfg.target = "x86_64-pc-linux-gnu"; + cfg.type = ConfigurationType::Executable; + + auto addTest = [&](std::string name) { + Test t; + t.config.path = "./"; + t.config.name = name; + t.config.outputName = name; + t.config.target = "x86_64-pc-linux-gnu"; + t.config.type = ConfigurationType::Executable; + std::array empty = {}; + std::array impls = { fs::path("tests") / name }; + t.config.GetInterfacesAndImplementations(empty, impls); + cfg.tests.push_back(std::move(t)); + }; + + addTest("Pass"); + addTest("Fail"); + addTest("Crash"); + addTest("Hang"); + + return cfg; +} diff --git a/tests/fixtures/runner-classification/tests/Crash.cpp b/tests/fixtures/runner-classification/tests/Crash.cpp new file mode 100644 index 0000000..2679e10 --- /dev/null +++ b/tests/fixtures/runner-classification/tests/Crash.cpp @@ -0,0 +1,4 @@ +int main() { + *(volatile int*)0 = 0; + return 0; +} diff --git a/tests/fixtures/runner-classification/tests/Fail.cpp b/tests/fixtures/runner-classification/tests/Fail.cpp new file mode 100644 index 0000000..40cbb54 --- /dev/null +++ b/tests/fixtures/runner-classification/tests/Fail.cpp @@ -0,0 +1 @@ +int main() { return 1; } diff --git a/tests/fixtures/runner-classification/tests/Hang.cpp b/tests/fixtures/runner-classification/tests/Hang.cpp new file mode 100644 index 0000000..22630b6 --- /dev/null +++ b/tests/fixtures/runner-classification/tests/Hang.cpp @@ -0,0 +1,3 @@ +int main() { + for (;;) {} +} diff --git a/tests/fixtures/runner-classification/tests/Pass.cpp b/tests/fixtures/runner-classification/tests/Pass.cpp new file mode 100644 index 0000000..76e8197 --- /dev/null +++ b/tests/fixtures/runner-classification/tests/Pass.cpp @@ -0,0 +1 @@ +int main() { return 0; } diff --git a/tests/fixtures/ssh-runner/project.cpp b/tests/fixtures/ssh-runner/project.cpp new file mode 100644 index 0000000..578e07a --- /dev/null +++ b/tests/fixtures/ssh-runner/project.cpp @@ -0,0 +1,27 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "ssh-meta"; + cfg.outputName = "ssh-meta"; + cfg.target = "x86_64-pc-linux-gnu"; + cfg.type = ConfigurationType::Executable; + + Test t; + t.config.path = "./"; + t.config.name = "Hello"; + t.config.outputName = "Hello"; + t.config.target = "x86_64-pc-linux-gnu"; + t.config.type = ConfigurationType::Executable; + std::array ifaces = {}; + std::array impls = { "tests/Hello" }; + t.config.GetInterfacesAndImplementations(ifaces, impls); + t.runner = TestRunner::FromEnv(t.config.target); + cfg.tests.push_back(std::move(t)); + + return cfg; +} diff --git a/tests/fixtures/ssh-runner/tests/Hello.cpp b/tests/fixtures/ssh-runner/tests/Hello.cpp new file mode 100644 index 0000000..f275c9d --- /dev/null +++ b/tests/fixtures/ssh-runner/tests/Hello.cpp @@ -0,0 +1,6 @@ +import std; + +int main() { + std::println("hello-from-ssh"); + return 0; +} diff --git a/tests/fixtures/windows-via-ssh/main.cpp b/tests/fixtures/windows-via-ssh/main.cpp new file mode 100644 index 0000000..44e22e0 --- /dev/null +++ b/tests/fixtures/windows-via-ssh/main.cpp @@ -0,0 +1,6 @@ +import std; + +int main() { + std::println("hi from windows"); + return 0; +} diff --git a/tests/fixtures/windows-via-ssh/project.cpp b/tests/fixtures/windows-via-ssh/project.cpp new file mode 100644 index 0000000..d23958f --- /dev/null +++ b/tests/fixtures/windows-via-ssh/project.cpp @@ -0,0 +1,30 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "winhello-meta"; + cfg.outputName = "winhello-meta"; + cfg.target = "x86_64-pc-linux-gnu"; + cfg.type = ConfigurationType::Executable; + + Test t; + t.config.path = "./"; + t.config.name = "winhello"; + t.config.outputName = "winhello"; + t.config.target = "x86_64-w64-mingw32"; + t.config.type = ConfigurationType::Executable; + std::array ifaces = {}; + std::array impls = { "main" }; + t.config.GetInterfacesAndImplementations(ifaces, impls); + t.config.linkFlags.push_back("-fuse-ld=lld"); + t.config.linkFlags.push_back("-lstdc++exp"); + + t.runner = TestRunner::FromEnv(t.config.target); + + cfg.tests.push_back(std::move(t)); + return cfg; +} diff --git a/tests/fixtures/with-module/interfaces/Greeter.cppm b/tests/fixtures/with-module/interfaces/Greeter.cppm new file mode 100644 index 0000000..1294d98 --- /dev/null +++ b/tests/fixtures/with-module/interfaces/Greeter.cppm @@ -0,0 +1,6 @@ +export module Greeter; +import std; + +export std::string Greet(std::string_view name) { + return std::format("hello, {}!", name); +} diff --git a/tests/fixtures/with-module/main.cpp b/tests/fixtures/with-module/main.cpp new file mode 100644 index 0000000..e48a400 --- /dev/null +++ b/tests/fixtures/with-module/main.cpp @@ -0,0 +1,7 @@ +import std; +import Greeter; + +int main() { + if (Greet("crafter") != "hello, crafter!") return 1; + return 0; +}