test runner, cross-target runners, lib/exe split

- 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 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-04-27 22:32:19 +02:00
commit cdfdb976c8
60 changed files with 2029 additions and 104 deletions

3
tests/fixtures/build-error/main.cpp vendored Normal file
View file

@ -0,0 +1,3 @@
int main() {
return undefined_symbol_xyzzy_oqv;
}

22
tests/fixtures/build-error/project.cpp vendored Normal file
View file

@ -0,0 +1,22 @@
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
Configuration cfg;
cfg.path = "./";
cfg.name = "broken";
cfg.outputName = "broken";
cfg.target = "x86_64-pc-linux-gnu";
cfg.type = ConfigurationType::Executable;
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "main" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
cfg.linkFlags.push_back("-Wl,--export-dynamic");
cfg.linkFlags.push_back("-ldl");
return cfg;
}

View file

@ -0,0 +1,6 @@
import std;
int main() {
std::println("hi from {}-bit aarch64", sizeof(void*) * 8);
return 0;
}

View file

@ -0,0 +1,24 @@
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
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<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "main" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
cfg.linkFlags.push_back("-fuse-ld=lld");
return cfg;
}

View file

@ -0,0 +1,5 @@
export module Foo;
export int Compute() {
return 7 * 6;
}

7
tests/fixtures/cross-project/main.cpp vendored Normal file
View file

@ -0,0 +1,7 @@
import std;
import Foo;
int main() {
if (Compute() != 42) return 1;
return 0;
}

8
tests/fixtures/defines/main.cpp vendored Normal file
View file

@ -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;
}

6
tests/fixtures/diamond/B/B.cppm vendored Normal file
View file

@ -0,0 +1,6 @@
export module B;
import X;
export int BValue() {
return XValue() * 2;
}

6
tests/fixtures/diamond/C/C.cppm vendored Normal file
View file

@ -0,0 +1,6 @@
export module C;
import X;
export int CValue() {
return XValue() * 3;
}

5
tests/fixtures/diamond/X/X.cppm vendored Normal file
View file

@ -0,0 +1,5 @@
export module X;
export int XValue() {
return 7;
}

9
tests/fixtures/diamond/main.cpp vendored Normal file
View file

@ -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;
}

7
tests/fixtures/hello-world/main.cpp vendored Normal file
View file

@ -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;
}

View file

@ -0,0 +1,6 @@
export module Greeter;
import std;
export std::string Greet(std::string_view name) {
return std::format("hello, {}!", name);
}

7
tests/fixtures/incremental/main.cpp vendored Normal file
View file

@ -0,0 +1,7 @@
import std;
import Greeter;
int main() {
std::println("{}", Greet("crafter"));
return 0;
}

22
tests/fixtures/incremental/project.cpp vendored Normal file
View file

@ -0,0 +1,22 @@
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
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<fs::path, 1> ifaces = { "interfaces/Greeter" };
std::array<fs::path, 1> impls = { "main" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
cfg.linkFlags.push_back("-Wl,--export-dynamic");
cfg.linkFlags.push_back("-ldl");
return cfg;
}

View file

@ -0,0 +1,6 @@
export module GreetLib;
import std;
export std::string Greet() {
return "hi";
}

8
tests/fixtures/libraries/main.cpp vendored Normal file
View file

@ -0,0 +1,8 @@
import std;
import MathLib;
import GreetLib;
int main() {
std::println("{}={}", Greet(), Add(40, 2));
return 0;
}

View file

@ -0,0 +1,5 @@
export module MathLib;
export int Add(int a, int b) {
return a + b;
}

47
tests/fixtures/libraries/project.cpp vendored Normal file
View file

@ -0,0 +1,47 @@
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
static auto MathStatic = std::make_unique<Configuration>();
MathStatic->path = "./mathlib/";
MathStatic->name = "MathLib";
MathStatic->outputName = "MathLib";
MathStatic->target = "x86_64-pc-linux-gnu";
MathStatic->type = ConfigurationType::LibraryStatic;
{
std::array<fs::path, 1> ifaces = { "MathLib" };
std::array<fs::path, 0> impls = {};
MathStatic->GetInterfacesAndImplementations(ifaces, impls);
}
static auto GreetDynamic = std::make_unique<Configuration>();
GreetDynamic->path = "./greetlib/";
GreetDynamic->name = "GreetLib";
GreetDynamic->outputName = "GreetLib";
GreetDynamic->target = "x86_64-pc-linux-gnu";
GreetDynamic->type = ConfigurationType::LibraryDynamic;
{
std::array<fs::path, 1> ifaces = { "GreetLib" };
std::array<fs::path, 0> 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<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "main" };
app.GetInterfacesAndImplementations(ifaces, impls);
}
app.linkFlags.push_back("-Wl,--export-dynamic");
app.linkFlags.push_back("-ldl");
return app;
}

27
tests/fixtures/qemu-runner/project.cpp vendored Normal file
View file

@ -0,0 +1,27 @@
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
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<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "tests/Hello" };
t.config.GetInterfacesAndImplementations(ifaces, impls);
t.runner = TestRunner::FromEnv(t.config.target);
cfg.tests.push_back(std::move(t));
return cfg;
}

View file

@ -0,0 +1,6 @@
import std;
int main() {
std::println("hello-from-qemu");
return 0;
}

View file

@ -0,0 +1,33 @@
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
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<fs::path, 0> empty = {};
std::array<fs::path, 1> 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;
}

View file

@ -0,0 +1,4 @@
int main() {
*(volatile int*)0 = 0;
return 0;
}

View file

@ -0,0 +1 @@
int main() { return 1; }

View file

@ -0,0 +1,3 @@
int main() {
for (;;) {}
}

View file

@ -0,0 +1 @@
int main() { return 0; }

27
tests/fixtures/ssh-runner/project.cpp vendored Normal file
View file

@ -0,0 +1,27 @@
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
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<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "tests/Hello" };
t.config.GetInterfacesAndImplementations(ifaces, impls);
t.runner = TestRunner::FromEnv(t.config.target);
cfg.tests.push_back(std::move(t));
return cfg;
}

View file

@ -0,0 +1,6 @@
import std;
int main() {
std::println("hello-from-ssh");
return 0;
}

View file

@ -0,0 +1,6 @@
import std;
int main() {
std::println("hi from windows");
return 0;
}

View file

@ -0,0 +1,30 @@
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
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<fs::path, 0> ifaces = {};
std::array<fs::path, 1> 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;
}

View file

@ -0,0 +1,6 @@
export module Greeter;
import std;
export std::string Greet(std::string_view name) {
return std::format("hello, {}!", name);
}

7
tests/fixtures/with-module/main.cpp vendored Normal file
View file

@ -0,0 +1,7 @@
import std;
import Greeter;
int main() {
if (Greet("crafter") != "hello, crafter!") return 1;
return 0;
}