V2: WASI, -r flag, CI pipeline, examples & tests cleanup
Some checks failed
CI / build-test-release (pull_request) Failing after 44s

WASI / wasm32 target support
- Auto-detect /usr/share/wasi-sysroot on Linux when target starts_with("wasm32")
- Skip -march/-mtune for wasm (clang rejects them)
- Apply -fno-exceptions -fno-c++-static-destructors -mllvm -wasm-enable-sjlj
  -D_WASI_EMULATED_SIGNAL to wasm builds (compile + std PCM, kept in sync)
- .wasm output extension in expectedOutputFor and link command
- EnableWasiBrowserRuntime(cfg): opt-in helper that drops index.html +
  runtime.js next to the .wasm; runtime.js reads window.CRAFTER_WASM_URL
  set in the templated index.html so a single shim handles any output name

-r run flag in the CLI: build then exec the artifact (host targets only;
  rejects libraries; auto .exe/.wasm extension handling)

CI pipeline (.forgejo/workflows/ci.yaml)
- Triggers: PR/push to master + manual dispatch
- Single arch-latest container job: install deps, bootstrap, self-rebuild,
  run tests, cross-compile mingw, package both archives, upload artifacts
- Rolling 'latest' release published only on push/dispatch to master

mingw cross-compile from Linux now works end-to-end:
- ExternalDependency cache key includes target so per-target glslang builds
  don't collide; CMAKE_BUILD_TYPE=Release pinned (otherwise glslang appends
  'd' to lib names and breaks linking); cross-compile cmake flags
  (CMAKE_SYSTEM_NAME=Windows, CMAKE_*_COMPILER_TARGET=...)
- project.cpp accepts --target=<triple>; Linux-only -Wl,--export-dynamic
  and -ldl are gated; mingw glslang skips the standalone exe (its libgcc_eh
  link pulls pthread which mingw doesn't link by default)
- mingw compile uses -femulated-tls so std::__once_callable etc reference
  the same emutls symbols libstdc++ provides
- mingw link auto-adds -lstdc++exp -lpthread

GetCrafterBuildHome() exposed from the Platform module; LoadProject (Linux
+ Windows) now both use it instead of duplicating the resolution.

Examples reorg: hello-world, library, with-module, wasi, tests — each with
its own README. Tests reorg: per-test directory with inner/ fixture, no
shared tests/fixtures/ tree. New Wasi test verifies .wasm magic bytes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-04-28 23:24:46 +02:00
commit eaee502e8c
102 changed files with 2211 additions and 686 deletions

View file

@ -1,46 +0,0 @@
/*
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;
}
}

View file

@ -0,0 +1,41 @@
/*
Crafter® Build
Copyright (C) 2026 Catcrafts®
Catcrafts.net
LGPL-3.0-only.
*/
import std;
import Crafter.Build;
#include "../_shared/TestUtil.h"
namespace fs = std::filesystem;
using namespace TestUtil;
using namespace Crafter;
int main() {
try {
fs::path src = fs::current_path() / "tests" / "BuildError" / "inner";
Configuration cfg = LoadFixture("BuildError", src);
fs::path work = fs::current_path();
auto br = BuildOnce(cfg);
if (br.result.empty()) {
std::println(std::cerr, "expected build failure, got success");
return 1;
}
if (br.result.find("undefined_symbol_xyzzy_oqv") == std::string::npos) {
std::println(std::cerr, "diagnostic missing unresolved-name reference:\n{}", br.result);
return 1;
}
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;
}
}

View file

@ -0,0 +1,20 @@
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 = "tests/BuildError/";
cfg.name = "BuildError";
cfg.outputName = "BuildError";
cfg.target = "x86_64-pc-linux-gnu";
cfg.type = ConfigurationType::Executable;
cfg.dependencies = { ParentLib("crafter.build-lib") };
cfg.linkFlags.push_back("-Wl,--export-dynamic");
cfg.linkFlags.push_back("-ldl");
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "BuildError" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
return cfg;
}

View file

@ -6,22 +6,23 @@ 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.
the inner project.cpp targets aarch64-linux-gnu with cfg.sysroot pointing at
the Arch Linux ARM rootfs at /opt/aarch64-rootfs. crafter.build-lib
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"
import Crafter.Build;
#include "../_shared/TestUtil.h"
namespace fs = std::filesystem;
using namespace TestUtil;
using namespace Crafter;
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;
return std::system(std::format("which {} > /dev/null 2>&1", name).c_str()) == 0;
}
}
@ -29,32 +30,23 @@ 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;
Skip(std::format("aarch64 sysroot missing at {} — see README", sysroot.string()));
}
if (!ToolPresent("qemu-aarch64")) Skip("qemu-aarch64 not on PATH");
fs::path projectRoot = fs::current_path();
fs::path src = projectRoot / "tests" / "fixtures" / "cross-arch-aarch64";
fs::path crafterBuild = projectRoot / "bin" / "crafter-build";
fs::path src = fs::current_path() / "tests" / "CrossArchAarch64" / "inner";
Configuration cfg = LoadFixture("CrossArchAarch64", src);
fs::path work = fs::current_path();
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);
auto br = BuildOnce(cfg);
if (!br.result.empty()) {
std::println(std::cerr, "build failed:\n{}", br.result);
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);
std::println(std::cerr, "expected artifact missing at {}", artifact.string());
return 1;
}

View file

@ -18,7 +18,6 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>)
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,20 @@
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 = "tests/CrossArchAarch64/";
cfg.name = "CrossArchAarch64";
cfg.outputName = "CrossArchAarch64";
cfg.target = "x86_64-pc-linux-gnu";
cfg.type = ConfigurationType::Executable;
cfg.dependencies = { ParentLib("crafter.build-lib") };
cfg.linkFlags.push_back("-Wl,--export-dynamic");
cfg.linkFlags.push_back("-ldl");
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "CrossArchAarch64" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
return cfg;
}

View file

@ -0,0 +1,36 @@
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
std::string target = "x86_64-pc-linux-gnu";
for (auto a : args) {
if (a.starts_with("--target=")) target = std::string(a.substr(9));
}
static auto fooLib = std::make_unique<Configuration>();
fooLib->path = "tests/CrossProjectModule/lib/";
fooLib->name = std::format("Foo-{}", target);
fooLib->outputName = "Foo";
fooLib->target = target;
fooLib->type = ConfigurationType::LibraryStatic;
{
std::array<fs::path, 1> ifaces = { "Foo" };
std::array<fs::path, 0> impls = {};
fooLib->GetInterfacesAndImplementations(ifaces, impls);
}
Configuration cfg;
cfg.path = "tests/CrossProjectModule/";
cfg.name = "CrossProjectModule";
cfg.outputName = "cross-app";
cfg.target = target;
cfg.type = ConfigurationType::Executable;
cfg.dependencies = { fooLib.get() };
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "main" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
return cfg;
}

24
tests/Defines/project.cpp Normal file
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> args) {
std::string target = "x86_64-pc-linux-gnu";
for (auto a : args) {
if (a.starts_with("--target=")) target = std::string(a.substr(9));
}
Configuration cfg;
cfg.path = "tests/Defines/";
cfg.name = "Defines";
cfg.outputName = "defines-app";
cfg.target = target;
cfg.type = ConfigurationType::Executable;
cfg.defines.push_back({"CRAFTER_TEST_FOO", "42"});
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "main" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
return cfg;
}

49
tests/Diamond/project.cpp Normal file
View file

@ -0,0 +1,49 @@
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
namespace {
std::unique_ptr<Configuration> MakeLib(std::string_view dir, std::string_view modName,
std::string_view target,
std::span<Configuration*> deps) {
auto lib = std::make_unique<Configuration>();
lib->path = std::format("tests/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<fs::path, 1> ifaces = { fs::path(modName) };
std::array<fs::path, 0> impls = {};
lib->GetInterfacesAndImplementations(ifaces, impls);
return lib;
}
}
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
std::string target = "x86_64-pc-linux-gnu";
for (auto a : args) {
if (a.starts_with("--target=")) target = std::string(a.substr(9));
}
static std::unique_ptr<Configuration> X, B, C;
X = MakeLib("X", "X", target, {});
Configuration* xDeps[] = { X.get() };
B = MakeLib("B", "B", target, xDeps);
C = MakeLib("C", "C", target, xDeps);
Configuration* mainDeps[] = { B.get(), C.get() };
Configuration cfg;
cfg.path = "tests/Diamond/";
cfg.name = "Diamond";
cfg.outputName = "diamond-app";
cfg.target = target;
cfg.type = ConfigurationType::Executable;
cfg.dependencies.assign(mainDeps, mainDeps + 2);
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "main" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
return cfg;
}

View file

@ -7,28 +7,28 @@ LGPL-3.0-only.
*/
import std;
#include "TestUtil.h"
import Crafter.Build;
#include "../_shared/TestUtil.h"
namespace fs = std::filesystem;
using namespace std::chrono_literals;
using namespace TestUtil;
using namespace Crafter;
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 src = fs::current_path() / "tests" / "Incremental" / "inner";
Configuration cfg = LoadFixture("Incremental", src);
fs::path work = fs::current_path();
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);
auto cold = BuildOnce(cfg);
if (!cold.result.empty()) {
std::println(std::cerr, "cold build failed:\n{}", cold.result);
return 1;
}
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";
if (!fs::exists(greeterObj) || !fs::exists(mainObj)) {
std::println(std::cerr, "expected .o files missing after cold build");
return 1;
@ -36,35 +36,32 @@ int main() {
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);
auto noop = BuildOnce(cfg);
if (!noop.result.empty()) {
std::println(std::cerr, "no-op rebuild failed:\n{}", noop.result);
return 1;
}
auto greeter_t2 = fs::last_write_time(greeterObj);
auto main_t2 = fs::last_write_time(mainObj);
if (greeter_t2 != greeter_t1) {
if (fs::last_write_time(greeterObj) != greeter_t1) {
std::println(std::cerr, "no-op rebuild regenerated Greeter.o");
return 1;
}
if (main_t2 != main_t1) {
if (fs::last_write_time(mainObj) != 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
// 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);
auto touched = BuildOnce(cfg);
if (!touched.result.empty()) {
std::println(std::cerr, "rebuild after touch failed:\n{}", touched.result);
return 1;
}
auto greeter_t3 = fs::last_write_time(greeterObj);
auto main_t3 = fs::last_write_time(mainObj);
if (greeter_t3 != greeter_t1) {
if (fs::last_write_time(greeterObj) != greeter_t1) {
std::println(std::cerr, "touching main.cpp unnecessarily rebuilt Greeter.o");
return 1;
}
if (main_t3 <= main_t1) {
if (fs::last_write_time(mainObj) <= main_t1) {
std::println(std::cerr, "touching main.cpp did NOT rebuild main_impl.o");
return 1;
}

View file

@ -0,0 +1,20 @@
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 = "tests/Incremental/";
cfg.name = "Incremental";
cfg.outputName = "Incremental";
cfg.target = "x86_64-pc-linux-gnu";
cfg.type = ConfigurationType::Executable;
cfg.dependencies = { ParentLib("crafter.build-lib") };
cfg.linkFlags.push_back("-Wl,--export-dynamic");
cfg.linkFlags.push_back("-ldl");
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "Incremental" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
return cfg;
}

View file

@ -7,21 +7,21 @@ LGPL-3.0-only.
*/
import std;
#include "TestUtil.h"
import Crafter.Build;
#include "../_shared/TestUtil.h"
namespace fs = std::filesystem;
using namespace TestUtil;
using namespace Crafter;
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 src = fs::current_path() / "tests" / "Libraries" / "inner";
Configuration cfg = LoadFixture("Libraries", src);
fs::path work = fs::current_path();
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);
auto br = BuildOnce(cfg);
if (!br.result.empty()) {
std::println(std::cerr, "build failed:\n{}", br.result);
return 1;
}
@ -38,14 +38,12 @@ int main() {
return 1;
}
if (!fs::exists(artifact)) {
std::println(std::cerr, "exe missing at {}\nbuild log:\n{}", artifact.string(), build.output);
std::println(std::cerr, "exe missing at {}", artifact.string());
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.
// Linked against the dynamic .so which lives in greetlib/bin/...; set
// LD_LIBRARY_PATH explicitly for the artifact run.
auto run = RunInDir(work, std::format(
"LD_LIBRARY_PATH='{}' '{}'",
dynamicSO.parent_path().string(), artifact.string()));
@ -53,7 +51,6 @@ int main() {
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;

View file

@ -0,0 +1,20 @@
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 = "tests/Libraries/";
cfg.name = "Libraries";
cfg.outputName = "Libraries";
cfg.target = "x86_64-pc-linux-gnu";
cfg.type = ConfigurationType::Executable;
cfg.dependencies = { ParentLib("crafter.build-lib") };
cfg.linkFlags.push_back("-Wl,--export-dynamic");
cfg.linkFlags.push_back("-ldl");
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "Libraries" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
return cfg;
}

View file

@ -1,65 +0,0 @@
/*
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;
}
}

View file

@ -0,0 +1,58 @@
/*
Crafter® Build
Copyright (C) 2026 Catcrafts®
Catcrafts.net
LGPL-3.0-only.
*/
import std;
import Crafter.Build;
#include "../_shared/TestUtil.h"
namespace fs = std::filesystem;
using namespace TestUtil;
using namespace Crafter;
namespace {
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) {
return std::system(std::format("which {} > /dev/null 2>&1", qemu).c_str()) == 0;
}
}
int main() {
try {
std::string qemu = PickQemu();
if (!QemuPresent(qemu)) Skip(std::format("{} not on PATH", qemu));
std::string spec = std::format("cmd:{}", qemu);
::setenv("CRAFTER_BUILD_RUNNER_x86_64_pc_linux_gnu", spec.c_str(), 1);
// Verify env-var translation independently of RunTests.
auto runner = TestRunner::FromEnv("x86_64-pc-linux-gnu", TestRunner::Local());
if (runner.name != spec) {
std::println(std::cerr, "FromEnv produced '{}', expected '{}'", runner.name, spec);
return 1;
}
fs::path src = fs::current_path() / "tests" / "QemuUser" / "inner";
Configuration cfg = LoadFixture("QemuUser", src);
RunTestsOptions opts;
TestSummary summary = RunTests(cfg, opts);
if (summary.passed != 1 || summary.failed != 0 || summary.crashed != 0 ||
summary.timedOut != 0 || summary.skipped != 0) {
std::println(std::cerr,
"outcome counts mismatch: passed={} failed={} crashed={} timedOut={} skipped={}",
summary.passed, summary.failed, summary.crashed, summary.timedOut, summary.skipped);
return 1;
}
return 0;
} catch (const std::exception& e) {
std::println(std::cerr, "test exception: {}", e.what());
return 1;
}
}

View file

@ -0,0 +1,20 @@
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 = "tests/QemuUser/";
cfg.name = "QemuUser";
cfg.outputName = "QemuUser";
cfg.target = "x86_64-pc-linux-gnu";
cfg.type = ConfigurationType::Executable;
cfg.dependencies = { ParentLib("crafter.build-lib") };
cfg.linkFlags.push_back("-Wl,--export-dynamic");
cfg.linkFlags.push_back("-ldl");
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "QemuUser" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
return cfg;
}

View file

@ -1,61 +0,0 @@
/*
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;
}
}

View file

@ -0,0 +1,37 @@
/*
Crafter® Build
Copyright (C) 2026 Catcrafts®
Catcrafts.net
LGPL-3.0-only.
*/
import std;
import Crafter.Build;
#include "../_shared/TestUtil.h"
namespace fs = std::filesystem;
using namespace TestUtil;
using namespace Crafter;
int main() {
try {
fs::path src = fs::current_path() / "tests" / "RunnerClassification" / "inner";
Configuration cfg = LoadFixture("RunnerClassification", src);
RunTestsOptions opts;
opts.timeoutOverride = std::chrono::seconds(2); // Hang test bounded
TestSummary summary = RunTests(cfg, opts);
if (summary.passed != 1 || summary.failed != 1 ||
summary.crashed != 1 || summary.timedOut != 1 || summary.skipped != 0) {
std::println(std::cerr,
"outcome counts mismatch: passed={} failed={} crashed={} timedOut={} skipped={}",
summary.passed, summary.failed, summary.crashed, summary.timedOut, summary.skipped);
return 1;
}
return 0;
} catch (const std::exception& e) {
std::println(std::cerr, "test exception: {}", e.what());
return 1;
}
}

View file

@ -0,0 +1,20 @@
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 = "tests/RunnerClassification/";
cfg.name = "RunnerClassification";
cfg.outputName = "RunnerClassification";
cfg.target = "x86_64-pc-linux-gnu";
cfg.type = ConfigurationType::Executable;
cfg.dependencies = { ParentLib("crafter.build-lib") };
cfg.linkFlags.push_back("-Wl,--export-dynamic");
cfg.linkFlags.push_back("-ldl");
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "RunnerClassification" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
return cfg;
}

View file

@ -1,63 +0,0 @@
/*
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;
}
}

View file

@ -0,0 +1,52 @@
/*
Crafter® Build
Copyright (C) 2026 Catcrafts®
Catcrafts.net
LGPL-3.0-only.
*/
import std;
import Crafter.Build;
#include "../_shared/TestUtil.h"
namespace fs = std::filesystem;
using namespace TestUtil;
using namespace Crafter;
int main() {
try {
const char* hostEnv = std::getenv("CRAFTER_TEST_SSH_HOST");
if (!hostEnv || !*hostEnv) Skip("set CRAFTER_TEST_SSH_HOST to enable");
std::string host = hostEnv;
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) Skip(std::format("ssh {} not reachable", host));
std::string remoteDir = "/tmp/crafter-test-ssh-runner";
std::string spec = std::format("ssh:{}:{}", host, remoteDir);
::setenv("CRAFTER_BUILD_RUNNER_x86_64_pc_linux_gnu", spec.c_str(), 1);
auto runner = TestRunner::FromEnv("x86_64-pc-linux-gnu", TestRunner::Local());
if (runner.name != std::format("ssh:{}", host)) {
std::println(std::cerr, "FromEnv produced '{}', expected 'ssh:{}'", runner.name, host);
return 1;
}
fs::path src = fs::current_path() / "tests" / "SshRunner" / "inner";
Configuration cfg = LoadFixture("SshRunner", src);
RunTestsOptions opts;
TestSummary summary = RunTests(cfg, opts);
if (summary.passed != 1 || summary.failed != 0 || summary.crashed != 0 ||
summary.timedOut != 0 || summary.skipped != 0) {
std::println(std::cerr,
"outcome counts mismatch: passed={} failed={} crashed={} timedOut={} skipped={}",
summary.passed, summary.failed, summary.crashed, summary.timedOut, summary.skipped);
return 1;
}
return 0;
} catch (const std::exception& e) {
std::println(std::cerr, "test exception: {}", e.what());
return 1;
}
}

View file

@ -0,0 +1,20 @@
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 = "tests/SshRunner/";
cfg.name = "SshRunner";
cfg.outputName = "SshRunner";
cfg.target = "x86_64-pc-linux-gnu";
cfg.type = ConfigurationType::Executable;
cfg.dependencies = { ParentLib("crafter.build-lib") };
cfg.linkFlags.push_back("-Wl,--export-dynamic");
cfg.linkFlags.push_back("-ldl");
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "SshRunner" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
return cfg;
}

View file

@ -1,36 +0,0 @@
#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)};
}
}

33
tests/UnitLib/main.cpp Normal file
View file

@ -0,0 +1,33 @@
import std;
import Crafter.Build;
using namespace Crafter;
int main() {
// Local() is the no-op runner — empty exec template, name "local".
if (TestRunner::Local().name != "local") return 1;
// FromSpec parses each known kind and labels it consistently.
auto cmd = TestRunner::FromSpec("cmd:wine");
if (!cmd || cmd->name != "cmd:wine") return 1;
auto local = TestRunner::FromSpec("local");
if (!local || local->name != "local") return 1;
auto ssh = TestRunner::FromSpec("ssh:somehost");
if (!ssh || ssh->name != "ssh:somehost") return 1;
auto sshWithDir = TestRunner::FromSpec("ssh:somehost:/var/tmp/x");
if (!sshWithDir || sshWithDir->remoteDir != "/var/tmp/x") return 1;
auto sshWin = TestRunner::FromSpec("sshwin:winhost");
if (!sshWin || sshWin->name != "sshwin:winhost") return 1;
// Empty input returns nullopt; bogus prefix throws.
if (TestRunner::FromSpec("")) return 1;
try {
TestRunner::FromSpec("nonsense:thing");
return 1;
} catch (const std::exception&) {}
return 0;
}

21
tests/UnitLib/project.cpp Normal file
View file

@ -0,0 +1,21 @@
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 = "tests/UnitLib/";
cfg.name = "UnitLib";
cfg.outputName = "UnitLib";
cfg.target = "x86_64-pc-linux-gnu";
cfg.type = ConfigurationType::Executable;
cfg.dependencies = { ParentLib("crafter.build-lib") };
cfg.linkFlags.push_back("-Wl,--export-dynamic");
cfg.linkFlags.push_back("-ldl");
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "main" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
return cfg;
}

44
tests/Wasi/Wasi.cpp Normal file
View file

@ -0,0 +1,44 @@
import std;
import Crafter.Build;
#include "../_shared/TestUtil.h"
namespace fs = std::filesystem;
using namespace TestUtil;
using namespace Crafter;
int main() {
try {
if (!fs::exists("/usr/share/wasi-sysroot/share/libc++/v1/std.cppm")) {
Skip("WASI sysroot/libc++ missing — install wasi-libc, wasi-libc++, wasi-libc++abi");
}
fs::path src = fs::current_path() / "tests" / "Wasi" / "inner";
Configuration cfg = LoadFixture("Wasi", src);
fs::path work = fs::current_path();
auto br = BuildOnce(cfg);
if (!br.result.empty()) {
std::println(std::cerr, "build failed:\n{}", br.result);
return 1;
}
fs::path artifact = work / "bin" / "wasi-hello-wasm32-wasip1-native" / "wasi-hello.wasm";
if (!fs::exists(artifact)) {
std::println(std::cerr, "expected artifact missing at {}", artifact.string());
return 1;
}
// Verify WASM magic bytes: \0asm
std::ifstream f(artifact, std::ios::binary);
char magic[4] = {};
f.read(magic, 4);
if (magic[0] != '\0' || magic[1] != 'a' || magic[2] != 's' || magic[3] != 'm') {
std::println(std::cerr, "artifact is not a valid WASM file (bad magic bytes)");
return 1;
}
return 0;
} catch (const std::exception& e) {
std::println(std::cerr, "test exception: {}", e.what());
return 1;
}
}

View file

@ -0,0 +1,6 @@
import std;
int main() {
std::println("Hello, WASI!");
return 0;
}

View file

@ -0,0 +1,19 @@
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 = "wasi-hello";
cfg.outputName = "wasi-hello";
cfg.target = "wasm32-wasip1";
cfg.type = ConfigurationType::Executable;
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "main" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
return cfg;
}

20
tests/Wasi/project.cpp Normal file
View file

@ -0,0 +1,20 @@
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 = "tests/Wasi/";
cfg.name = "Wasi";
cfg.outputName = "Wasi";
cfg.target = "x86_64-pc-linux-gnu";
cfg.type = ConfigurationType::Executable;
cfg.dependencies = { ParentLib("crafter.build-lib") };
cfg.linkFlags.push_back("-Wl,--export-dynamic");
cfg.linkFlags.push_back("-ldl");
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "Wasi" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
return cfg;
}

View file

@ -1,85 +0,0 @@
/*
Crafter® Build
Copyright (C) 2026 Catcrafts®
Catcrafts.net
LGPL-3.0-only.
End-to-end LinuxWindows 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;
}
}

View file

@ -0,0 +1,68 @@
/*
Crafter® Build
Copyright (C) 2026 Catcrafts®
Catcrafts.net
LGPL-3.0-only.
End-to-end LinuxWindows via SSH:
the inner fixture cross-compiles main.cpp for x86_64-w64-mingw32, the runner
specified via CRAFTER_BUILD_RUNNER_x86_64_w64_mingw32 scp's it to a Windows
host (winvm by default) and runs the .exe under cmd.exe via ssh. Gated on:
- mingw cross-toolchain installed (x86_64-w64-mingw32-g++)
- CRAFTER_TEST_WIN_SSH_HOST env var set
- the host reachable via ssh
*/
import std;
import Crafter.Build;
#include "../_shared/TestUtil.h"
namespace fs = std::filesystem;
using namespace TestUtil;
using namespace Crafter;
namespace {
bool ToolPresent(std::string_view name) {
return std::system(std::format("which {} > /dev/null 2>&1", name).c_str()) == 0;
}
}
int main() {
try {
const char* hostEnv = std::getenv("CRAFTER_TEST_WIN_SSH_HOST");
if (!hostEnv || !*hostEnv) Skip("set CRAFTER_TEST_WIN_SSH_HOST to enable, e.g. winvm");
std::string host = hostEnv;
if (!ToolPresent("x86_64-w64-mingw32-g++")) Skip("mingw cross-toolchain not on PATH");
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) Skip(std::format("ssh {} not reachable", host));
std::string remoteDir = "C:/temp/crafter-test-winhello";
std::string spec = std::format("sshwin:{}:{}", host, remoteDir);
::setenv("CRAFTER_BUILD_RUNNER_x86_64_w64_mingw32", spec.c_str(), 1);
auto runner = TestRunner::FromEnv("x86_64-w64-mingw32", TestRunner::Local());
if (runner.name != std::format("sshwin:{}", host)) {
std::println(std::cerr, "FromEnv produced '{}', expected 'sshwin:{}'", runner.name, host);
return 1;
}
fs::path src = fs::current_path() / "tests" / "WindowsViaSsh" / "inner";
Configuration cfg = LoadFixture("WindowsViaSsh", src);
RunTestsOptions opts;
TestSummary summary = RunTests(cfg, opts);
if (summary.passed != 1 || summary.failed != 0 || summary.crashed != 0 ||
summary.timedOut != 0 || summary.skipped != 0) {
std::println(std::cerr,
"outcome counts mismatch: passed={} failed={} crashed={} timedOut={} skipped={}",
summary.passed, summary.failed, summary.crashed, summary.timedOut, summary.skipped);
return 1;
}
return 0;
} catch (const std::exception& e) {
std::println(std::cerr, "test exception: {}", e.what());
return 1;
}
}

View file

@ -20,8 +20,6 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>)
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);

View file

@ -0,0 +1,20 @@
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 = "tests/WindowsViaSsh/";
cfg.name = "WindowsViaSsh";
cfg.outputName = "WindowsViaSsh";
cfg.target = "x86_64-pc-linux-gnu";
cfg.type = ConfigurationType::Executable;
cfg.dependencies = { ParentLib("crafter.build-lib") };
cfg.linkFlags.push_back("-Wl,--export-dynamic");
cfg.linkFlags.push_back("-ldl");
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "WindowsViaSsh" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
return cfg;
}

76
tests/_shared/TestUtil.h Normal file
View file

@ -0,0 +1,76 @@
#pragma once
#include <stdlib.h> // setenv (POSIX, not in `import std`)
namespace TestUtil {
// Exit code 77 follows the autoconf convention. crafter-build's test
// runner maps it to TestOutcome::Skipped and renders the test's stdout
// as the reason. Use this when the test discovers at runtime that its
// preconditions aren't met (tool missing, env unset, etc).
[[noreturn]] inline void Skip(std::string_view reason) {
std::print("{}", reason);
std::exit(77);
}
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)};
}
// Build cfg with a fresh dep cache. Convenient for outer-driver tests that
// exercise the build API in-process and don't need to share the dep cache
// across multiple Build() calls.
inline Crafter::BuildResult BuildOnce(Crafter::Configuration& cfg) {
std::unordered_map<std::filesystem::path, std::shared_future<Crafter::BuildResult>> depResults;
std::mutex depMutex;
return Crafter::Build(cfg, depResults, depMutex);
}
// Copy a fixture into a fresh temp dir, chdir there (so cfg.path = "./"
// inside the inner project.cpp resolves to the temp dir), and load its
// project.cpp. Common prep for in-process build/test API tests.
//
// Also sets CRAFTER_BUILD_HOME to <projectRoot>/share/crafter-build before
// loading. The lib's default sourceDir is derived from /proc/self/exe, but
// test exes live in tests/<Name>/bin/... rather than next to the project's
// share/, so the default lookup misses. The test runner launches tests
// with cwd = project root, so we capture that here before the chdir below.
inline Crafter::Configuration LoadFixture(std::string_view testName,
const std::filesystem::path& source,
std::span<const std::string_view> args = {}) {
auto projectRoot = std::filesystem::current_path();
auto sharePath = projectRoot / "share" / "crafter-build";
::setenv("CRAFTER_BUILD_HOME", sharePath.string().c_str(), 1);
auto work = CopyFixtureToTemp(testName, source);
std::filesystem::current_path(work);
return Crafter::LoadProject("project.cpp", args);
}
}