V2: WASI, -r flag, CI pipeline, examples & tests cleanup
Some checks failed
CI / build-test-release (pull_request) Failing after 44s
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:
parent
cdfdb976c8
commit
eaee502e8c
102 changed files with 2211 additions and 686 deletions
|
|
@ -197,6 +197,40 @@ void Configuration::GetInterfacesAndImplementations(std::span<fs::path> interfac
|
|||
}
|
||||
|
||||
BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, std::shared_future<BuildResult>>& depResults, std::mutex& depMutex) {
|
||||
// Reset per-build cached state on every Module/ModulePartition so that
|
||||
// successive Build() calls on the same Configuration re-evaluate mtimes
|
||||
// (incremental-rebuild test scenarios). Walks cfg.dependencies recursively
|
||||
// with a seen-set so diamond deps don't loop.
|
||||
{
|
||||
std::unordered_set<Configuration*> resetSeen;
|
||||
std::function<void(Configuration*)> reset = [&](Configuration* c) {
|
||||
if (!resetSeen.insert(c).second) return;
|
||||
for (auto& iface : c->interfaces) {
|
||||
iface->checked = false;
|
||||
iface->compiled.store(false);
|
||||
for (auto& part : iface->partitions) {
|
||||
part->checked = false;
|
||||
part->compiled.store(false);
|
||||
}
|
||||
}
|
||||
for (Configuration* dep : c->dependencies) {
|
||||
reset(dep);
|
||||
}
|
||||
};
|
||||
reset(&config);
|
||||
}
|
||||
|
||||
// Auto-detect the WASI sysroot before any compile step runs so BuildStdPcm
|
||||
// and the main compile command see the same value. Linux-only — Windows
|
||||
// users supply cfg.sysroot pointing at their wasi-sdk install. Covers all
|
||||
// wasm32-* triples (wasi, wasip1, wasip2, ...); the sysroot's per-triple
|
||||
// subdirs handle the differences.
|
||||
#ifdef CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_linux_gnu
|
||||
if (config.sysroot.empty() && config.target.starts_with("wasm32")) {
|
||||
config.sysroot = "/usr/share/wasi-sysroot";
|
||||
}
|
||||
#endif
|
||||
|
||||
fs::path buildDir = config.path/"build"/std::format("{}-{}-{}", config.name, config.target, config.march);
|
||||
fs::path outputDir = config.path/"bin"/std::format("{}-{}-{}", config.name, config.target, config.march);
|
||||
|
||||
|
|
@ -287,7 +321,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
|
|||
for (std::size_t i = 0; i < config.externalDependencies.size(); ++i) {
|
||||
externalThreads.emplace_back([&, i]() {
|
||||
if (buildCancelled.load(std::memory_order_relaxed)) return;
|
||||
externalResults[i] = BuildExternal(config.externalDependencies[i], buildCancelled);
|
||||
externalResults[i] = BuildExternal(config.externalDependencies[i], config.target, buildCancelled);
|
||||
if (!externalResults[i].error.empty()) {
|
||||
bool expected = false;
|
||||
if (buildCancelled.compare_exchange_strong(expected, true)) {
|
||||
|
|
@ -324,11 +358,31 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
|
|||
std::string editedTarget = config.target;
|
||||
std::replace(editedTarget.begin(), editedTarget.end(), '-', '_');
|
||||
|
||||
std::string command = std::format("{} --target={} -march={} -mtune={} -std=c++26 -D CRAFTER_BUILD_CONFIGURATION_TARGET=\\\"{}\\\" -D CRAFTER_BUILD_CONFIGURATION_TARGET_{} -fprebuilt-module-path={} -fprebuilt-module-path={}", GetBaseCommand(config), config.target, config.march, config.mtune, editedTarget, editedTarget, stdPcmDir.string(), pcmDir.string());
|
||||
// wasm32 targets reject -march and silently ignore -mtune (clang errors on
|
||||
// the former). Skip both for any wasm32-* triple.
|
||||
bool isWasm = config.target.starts_with("wasm32");
|
||||
std::string archFlags = isWasm
|
||||
? std::string()
|
||||
: std::format(" -march={} -mtune={}", config.march, config.mtune);
|
||||
std::string command = std::format("{} --target={}{} -std=c++26 -D CRAFTER_BUILD_CONFIGURATION_TARGET=\\\"{}\\\" -D CRAFTER_BUILD_CONFIGURATION_TARGET_{} -fprebuilt-module-path={} -fprebuilt-module-path={}", GetBaseCommand(config), config.target, archFlags, editedTarget, editedTarget, stdPcmDir.string(), pcmDir.string());
|
||||
|
||||
if (!config.sysroot.empty()) {
|
||||
command += std::format(" --sysroot={}", config.sysroot);
|
||||
}
|
||||
if (config.target.starts_with("wasm32")) {
|
||||
// -mllvm is consumed by codegen but not the link driver, which is the
|
||||
// same command line; quiet the unused-flag warning rather than split
|
||||
// compile and link commands.
|
||||
command += " -fno-exceptions -fno-c++-static-destructors -mllvm -wasm-enable-sjlj -D_WASI_EMULATED_SIGNAL -Wno-unused-command-line-argument";
|
||||
}
|
||||
if (config.target == "x86_64-w64-mingw32") {
|
||||
// mingw libstdc++ defines TLS via __emutls_v.* (emulated TLS); without
|
||||
// -femulated-tls clang generates native-TLS references that don't
|
||||
// match. Symptom: undefined std::__once_callable / __once_call at
|
||||
// link time. Also -Wno-unused… because -femulated-tls is a codegen
|
||||
// flag the link driver doesn't consume.
|
||||
command += " -femulated-tls -Wno-unused-command-line-argument";
|
||||
}
|
||||
|
||||
if(config.type == ConfigurationType::LibraryDynamic) {
|
||||
#ifdef CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_linux_gnu
|
||||
|
|
@ -565,6 +619,61 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
|
|||
for(const std::string& flag : buildResult.libs) {
|
||||
linkExtras += " " + flag;
|
||||
}
|
||||
// mingw uses libstdc++; C++26 std::print/format extras live in libstdc++exp.
|
||||
// libstdc++ on mingw uses winpthreads for std::atomic_wait /
|
||||
// counting_semaphore / stop_token, so -lpthread is required as soon as
|
||||
// those primitives appear (they do, transitively, in any non-trivial std
|
||||
// import). -static-libstdc++ bundles libstdc++ into the exe so we don't
|
||||
// chase libstdc++-6.dll TLS symbol mismatches across mingw versions and
|
||||
// the resulting binary stands alone. Auto-link so user projects don't
|
||||
// carry boilerplate.
|
||||
if (config.target == "x86_64-w64-mingw32") {
|
||||
// mingw runtime DLLs (libstdc++-6.dll, libgcc_s_seh-1.dll,
|
||||
// libwinpthread-1.dll) get auto-copied next to the .exe by the
|
||||
// post-build step below, so dynamic linkage is fine. -lstdc++exp =
|
||||
// C++26 std::print/format extras; -lpthread = winpthreads symbols
|
||||
// that libstdc++ uses for std::atomic_wait, counting_semaphore,
|
||||
// stop_token. clang++ adds the main -lstdc++ implicitly.
|
||||
linkExtras += " -lstdc++exp -lpthread";
|
||||
}
|
||||
|
||||
// Force a relink if the expected output is missing or older than any dep
|
||||
// artifact. Missing covers: previous build produced a different outputName,
|
||||
// or the binary was deleted by hand. Older-than-dep covers: dep's library
|
||||
// was rebuilt by an earlier run (so dep.repack is false this time around)
|
||||
// but the consumer was never relinked against the new dep.
|
||||
{
|
||||
auto expectedOutputFor = [](const Configuration& c) -> fs::path {
|
||||
fs::path dir = c.path/"bin"/std::format("{}-{}-{}", c.name, c.target, c.march);
|
||||
if (c.type == ConfigurationType::Executable) {
|
||||
if (c.target.starts_with("wasm32"))
|
||||
return dir / (c.outputName + ".wasm");
|
||||
return c.target == "x86_64-w64-mingw32"
|
||||
? dir / (c.outputName + ".exe")
|
||||
: dir / c.outputName;
|
||||
}
|
||||
if (c.type == ConfigurationType::LibraryStatic) {
|
||||
return c.target == "x86_64-w64-mingw32" || c.target == "x86_64-pc-windows-msvc"
|
||||
? dir / std::format("{}.lib", c.outputName)
|
||||
: dir / std::format("lib{}.a", c.outputName);
|
||||
}
|
||||
return dir / std::format("lib{}.so", c.outputName);
|
||||
};
|
||||
|
||||
fs::path expected = expectedOutputFor(config);
|
||||
if (!fs::exists(expected)) {
|
||||
buildResult.repack = true;
|
||||
} else {
|
||||
auto consumerMtime = fs::last_write_time(expected);
|
||||
for (Configuration* dep : config.dependencies) {
|
||||
fs::path depArtifact = expectedOutputFor(*dep);
|
||||
if (fs::exists(depArtifact) && fs::last_write_time(depArtifact) > consumerMtime) {
|
||||
buildResult.repack = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(buildResult.repack) {
|
||||
if(config.type == ConfigurationType::Executable) {
|
||||
|
|
@ -589,7 +698,11 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
|
|||
return {e.what(), false, {}};
|
||||
}
|
||||
}
|
||||
buildResult.result = RunCommand(std::format("{}{} -o {} -fuse-ld=lld{}", command, files, (outputDir/config.outputName).string(), linkExtras));
|
||||
if (config.target.starts_with("wasm32")) {
|
||||
buildResult.result = RunCommand(std::format("{}{} -o {}.wasm -fuse-ld=lld{}", command, files, (outputDir/config.outputName).string(), linkExtras));
|
||||
} else {
|
||||
buildResult.result = RunCommand(std::format("{}{} -o {} -fuse-ld=lld{}", command, files, (outputDir/config.outputName).string(), linkExtras));
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
|
||||
|
|
@ -616,6 +729,33 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
|
|||
|
||||
return buildResult;
|
||||
}
|
||||
|
||||
void Crafter::EnableWasiBrowserRuntime(Configuration& cfg) {
|
||||
fs::path runtimeDir = GetCrafterBuildHome() / "wasi-runtime";
|
||||
fs::path runtimeJs = runtimeDir / "runtime.js";
|
||||
fs::path htmlTemplate = runtimeDir / "index.html.in";
|
||||
if (!fs::exists(runtimeJs) || !fs::exists(htmlTemplate)) {
|
||||
throw std::runtime_error(std::format(
|
||||
"wasi-runtime assets missing under {} (set CRAFTER_BUILD_HOME or reinstall)",
|
||||
runtimeDir.string()));
|
||||
}
|
||||
|
||||
fs::path htmlOutDir = cfg.path / "build" / "wasi-runtime" / cfg.name;
|
||||
fs::create_directories(htmlOutDir);
|
||||
fs::path htmlPath = htmlOutDir / "index.html";
|
||||
|
||||
std::ifstream in(htmlTemplate);
|
||||
std::stringstream buf;
|
||||
buf << in.rdbuf();
|
||||
std::string html = std::regex_replace(buf.str(), std::regex(R"(\{\{WASM\}\})"), cfg.outputName + ".wasm");
|
||||
std::ofstream out(htmlPath);
|
||||
out << html;
|
||||
out.close();
|
||||
|
||||
cfg.files.push_back(runtimeJs);
|
||||
cfg.files.push_back(htmlPath);
|
||||
}
|
||||
|
||||
int Crafter::Run(int argc, char** argv) {
|
||||
try {
|
||||
fs::path projectFile = "./project.cpp";
|
||||
|
|
@ -623,12 +763,15 @@ int Crafter::Run(int argc, char** argv) {
|
|||
projectArgs.reserve(argc);
|
||||
|
||||
bool runTests = false;
|
||||
bool runAfterBuild = false;
|
||||
RunTestsOptions testOpts;
|
||||
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
std::string_view arg = argv[i];
|
||||
if (arg == "test") {
|
||||
runTests = true;
|
||||
} else if (arg == "-r") {
|
||||
runAfterBuild = true;
|
||||
} else if (arg.starts_with("--project=")) {
|
||||
projectFile = arg.substr(std::string_view("--project=").size());
|
||||
} else if (runTests && arg.starts_with("--jobs=")) {
|
||||
|
|
@ -637,6 +780,8 @@ int Crafter::Run(int argc, char** argv) {
|
|||
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("--runner=")) {
|
||||
testOpts.runnerOverride = std::string(arg.substr(std::string_view("--runner=").size()));
|
||||
} else if (runTests && !arg.starts_with("-")) {
|
||||
testOpts.globs.emplace_back(arg);
|
||||
} else {
|
||||
|
|
@ -644,15 +789,25 @@ int Crafter::Run(int argc, char** argv) {
|
|||
}
|
||||
}
|
||||
|
||||
// The test run is target-scoped: only tests whose cfg.target equals
|
||||
// testOpts.targetFilter are included. Default = host triple, so a
|
||||
// bare `crafter-build test` runs everything that targets host.
|
||||
for (auto& a : projectArgs) {
|
||||
if (a.starts_with("--target=")) {
|
||||
testOpts.targetFilter = std::string(a.substr(std::string_view("--target=").size()));
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs::exists(projectFile)) {
|
||||
std::println(std::cerr, "No project file at {}", projectFile.string());
|
||||
return 1;
|
||||
}
|
||||
|
||||
Configuration config = LoadProject(projectFile, projectArgs);
|
||||
SetParentProject(&config);
|
||||
|
||||
if (runTests) {
|
||||
TestSummary summary = RunTests(config, testOpts);
|
||||
TestSummary summary = RunTests(config, testOpts, projectArgs);
|
||||
return summary.AllPassed() ? 0 : 1;
|
||||
}
|
||||
|
||||
|
|
@ -665,6 +820,21 @@ int Crafter::Run(int argc, char** argv) {
|
|||
return 1;
|
||||
}
|
||||
|
||||
if (runAfterBuild) {
|
||||
if (config.type != ConfigurationType::Executable) {
|
||||
std::println(std::cerr, "-r: cannot run a library");
|
||||
return 1;
|
||||
}
|
||||
fs::path dir = config.path / "bin" / std::format("{}-{}-{}", config.name, config.target, config.march);
|
||||
fs::path artifact = dir / config.outputName;
|
||||
if (config.target.starts_with("wasm32")) {
|
||||
artifact += ".wasm";
|
||||
} else if (config.target == "x86_64-w64-mingw32" || config.target == "x86_64-pc-windows-msvc") {
|
||||
artifact += ".exe";
|
||||
}
|
||||
return std::system(artifact.string().c_str()) == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
} catch (const std::exception& e) {
|
||||
std::println(std::cerr, "{}", e.what());
|
||||
|
|
|
|||
|
|
@ -110,22 +110,37 @@ std::string FetchGit(const GitSource& source, const fs::path& cloneDir) {
|
|||
return "";
|
||||
}
|
||||
|
||||
std::string BuildInjectedCMakeFlags(const fs::path& cmakeBuildDir) {
|
||||
std::string BuildInjectedCMakeFlags(const fs::path& cmakeBuildDir, std::string_view target) {
|
||||
std::string out;
|
||||
// Without an explicit build type, some packages (glslang) append a 'd'
|
||||
// suffix to library names assuming a debug build, breaking -l<name>
|
||||
// linking. Pin to Release.
|
||||
out += " -DCMAKE_BUILD_TYPE=Release";
|
||||
out += " -DCMAKE_C_COMPILER=clang";
|
||||
out += " -DCMAKE_CXX_COMPILER=clang++";
|
||||
// Stdlib choice: libc++ for the Linux host build (matches how crafter-build
|
||||
// itself is built with -stdlib=libc++); mingw cross-compile uses libstdc++
|
||||
// shipped by the mingw-w64 toolchain so we leave the default; Windows
|
||||
// native (msvc target) similarly uses libc++ via LIBCXX_DIR.
|
||||
#ifdef CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_linux_gnu
|
||||
out += " -DCMAKE_CXX_FLAGS=-stdlib=libc++";
|
||||
out += " -DCMAKE_EXE_LINKER_FLAGS=-stdlib=libc++";
|
||||
out += " -DCMAKE_SHARED_LINKER_FLAGS=-stdlib=libc++";
|
||||
if (target == "x86_64-pc-linux-gnu") {
|
||||
out += " -DCMAKE_CXX_FLAGS=-stdlib=libc++";
|
||||
out += " -DCMAKE_EXE_LINKER_FLAGS=-stdlib=libc++";
|
||||
out += " -DCMAKE_SHARED_LINKER_FLAGS=-stdlib=libc++";
|
||||
}
|
||||
#endif
|
||||
if (target == "x86_64-w64-mingw32") {
|
||||
out += " -DCMAKE_SYSTEM_NAME=Windows";
|
||||
out += std::format(" -DCMAKE_C_COMPILER_TARGET={}", target);
|
||||
out += std::format(" -DCMAKE_CXX_COMPILER_TARGET={}", target);
|
||||
}
|
||||
fs::path absBuildDir = fs::absolute(cmakeBuildDir);
|
||||
out += std::format(" -DCMAKE_ARCHIVE_OUTPUT_DIRECTORY={}", ShellQuote(absBuildDir.string()));
|
||||
out += std::format(" -DCMAKE_LIBRARY_OUTPUT_DIRECTORY={}", ShellQuote(absBuildDir.string()));
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string ConfigureCMake(const fs::path& cloneDir, const fs::path& cmakeBuildDir, std::span<const std::string> options) {
|
||||
std::string ConfigureCMake(const fs::path& cloneDir, const fs::path& cmakeBuildDir, std::span<const std::string> options, std::string_view target) {
|
||||
fs::path optionsFile = cmakeBuildDir / ".crafter-options";
|
||||
std::string optionsKey = JoinOptions(options);
|
||||
|
||||
|
|
@ -148,7 +163,7 @@ std::string ConfigureCMake(const fs::path& cloneDir, const fs::path& cmakeBuildD
|
|||
std::string cmd = std::format("cmake -S {} -B {}{}",
|
||||
ShellQuote(fs::absolute(cloneDir).string()),
|
||||
ShellQuote(fs::absolute(cmakeBuildDir).string()),
|
||||
BuildInjectedCMakeFlags(cmakeBuildDir));
|
||||
BuildInjectedCMakeFlags(cmakeBuildDir, target));
|
||||
if (!options.empty()) {
|
||||
cmd += " ";
|
||||
cmd += optionsKey;
|
||||
|
|
@ -176,6 +191,7 @@ std::string BuildCMake(const fs::path& cmakeBuildDir) {
|
|||
|
||||
ExternalBuildResult Crafter::BuildExternal(
|
||||
const ExternalDependency& dep,
|
||||
std::string_view target,
|
||||
std::atomic<bool>& cancelled) {
|
||||
ExternalBuildResult result;
|
||||
|
||||
|
|
@ -197,8 +213,8 @@ ExternalBuildResult Crafter::BuildExternal(
|
|||
}
|
||||
}
|
||||
|
||||
std::string keyMaterial = std::format("{}|{}|{}|{}",
|
||||
dep.source.url, dep.source.branch, dep.source.commit, JoinOptions(dep.options));
|
||||
std::string keyMaterial = std::format("{}|{}|{}|{}|{}",
|
||||
dep.source.url, dep.source.branch, dep.source.commit, JoinOptions(dep.options), target);
|
||||
std::size_t key = std::hash<std::string>{}(keyMaterial);
|
||||
fs::path cloneDir = externalRoot / std::format("{}-{:016x}", name, key);
|
||||
|
||||
|
|
@ -213,7 +229,7 @@ ExternalBuildResult Crafter::BuildExternal(
|
|||
fs::path cmakeBuildDir;
|
||||
if (dep.builder == ExternalBuilder::CMake) {
|
||||
cmakeBuildDir = cloneDir / "build";
|
||||
if (std::string err = ConfigureCMake(cloneDir, cmakeBuildDir, dep.options); !err.empty()) {
|
||||
if (std::string err = ConfigureCMake(cloneDir, cmakeBuildDir, dep.options, target); !err.empty()) {
|
||||
result.error = std::format("cmake configure for '{}': {}", name, err);
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,22 @@ import :Clang;
|
|||
namespace fs = std::filesystem;
|
||||
using namespace Crafter;
|
||||
|
||||
fs::path Crafter::GetCrafterBuildHome() {
|
||||
if (const char* envHome = std::getenv("CRAFTER_BUILD_HOME")) {
|
||||
return fs::path(envHome);
|
||||
}
|
||||
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
|
||||
char buf[MAX_PATH];
|
||||
DWORD len = GetModuleFileNameA(nullptr, buf, MAX_PATH);
|
||||
if (len == 0 || len == MAX_PATH) {
|
||||
throw std::runtime_error("GetModuleFileName failed");
|
||||
}
|
||||
fs::path hostExe(std::string(buf, len));
|
||||
#else
|
||||
fs::path hostExe = fs::read_symlink("/proc/self/exe");
|
||||
#endif
|
||||
return hostExe.parent_path().parent_path() / "share" / "crafter-build";
|
||||
}
|
||||
|
||||
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
|
||||
std::string Crafter::RunCommand(const std::string_view cmd) {
|
||||
|
|
@ -76,8 +92,103 @@ 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");
|
||||
CommandResult Crafter::RunCommandWithTimeout(std::string_view cmd, std::chrono::seconds timeout) {
|
||||
CommandResult result{};
|
||||
|
||||
SECURITY_ATTRIBUTES sa{ sizeof(sa), nullptr, TRUE };
|
||||
HANDLE readEnd = nullptr, writeEnd = nullptr;
|
||||
if (!CreatePipe(&readEnd, &writeEnd, &sa, 0)) {
|
||||
throw std::runtime_error("CreatePipe failed");
|
||||
}
|
||||
SetHandleInformation(readEnd, HANDLE_FLAG_INHERIT, 0);
|
||||
|
||||
// KILL_ON_JOB_CLOSE so the cmd.exe wrapper plus whatever it spawned
|
||||
// (the test binary, ssh, etc.) all die when the job handle goes away —
|
||||
// that's how we enforce the timeout reliably across the whole tree.
|
||||
HANDLE job = CreateJobObjectA(nullptr, nullptr);
|
||||
if (!job) {
|
||||
CloseHandle(readEnd);
|
||||
CloseHandle(writeEnd);
|
||||
throw std::runtime_error("CreateJobObject failed");
|
||||
}
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION jeli{};
|
||||
jeli.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
SetInformationJobObject(job, JobObjectExtendedLimitInformation, &jeli, sizeof(jeli));
|
||||
|
||||
STARTUPINFOA si{};
|
||||
si.cb = sizeof(si);
|
||||
si.dwFlags = STARTF_USESTDHANDLES;
|
||||
si.hStdOutput = writeEnd;
|
||||
si.hStdError = writeEnd;
|
||||
si.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
|
||||
|
||||
PROCESS_INFORMATION pi{};
|
||||
// cmd /C "...": gives users the same shell-syntax surface (redirects,
|
||||
// &&, ||) as popen("/bin/sh -c …") does on Linux. cmd's special-case
|
||||
// /C parsing strips the outer quote pair when the inner text contains
|
||||
// additional quotes, which is the common case here.
|
||||
std::string wrapped = "cmd /C \"" + std::string(cmd) + "\"";
|
||||
std::vector<char> cmdBuf(wrapped.begin(), wrapped.end());
|
||||
cmdBuf.push_back('\0');
|
||||
|
||||
BOOL ok = CreateProcessA(
|
||||
nullptr, cmdBuf.data(), nullptr, nullptr, TRUE,
|
||||
CREATE_NO_WINDOW | CREATE_SUSPENDED,
|
||||
nullptr, nullptr, &si, &pi);
|
||||
if (!ok) {
|
||||
CloseHandle(readEnd);
|
||||
CloseHandle(writeEnd);
|
||||
CloseHandle(job);
|
||||
throw std::runtime_error("CreateProcess failed");
|
||||
}
|
||||
|
||||
AssignProcessToJobObject(job, pi.hProcess);
|
||||
ResumeThread(pi.hThread);
|
||||
// Drop our writer ref so the read end sees EOF once the child (its
|
||||
// own dup of writeEnd) exits.
|
||||
CloseHandle(writeEnd);
|
||||
|
||||
std::string output;
|
||||
std::jthread reader([&output, readEnd]() {
|
||||
char buf[4096];
|
||||
DWORD n = 0;
|
||||
while (ReadFile(readEnd, buf, sizeof(buf), &n, nullptr) && n > 0) {
|
||||
output.append(buf, n);
|
||||
}
|
||||
});
|
||||
|
||||
DWORD waitMs = static_cast<DWORD>(std::min<long long>(
|
||||
static_cast<long long>(timeout.count()) * 1000LL,
|
||||
static_cast<long long>(INFINITE) - 1));
|
||||
DWORD waitResult = WaitForSingleObject(pi.hProcess, waitMs);
|
||||
|
||||
if (waitResult == WAIT_TIMEOUT) {
|
||||
TerminateJobObject(job, 1);
|
||||
WaitForSingleObject(pi.hProcess, INFINITE);
|
||||
result.timedOut = true;
|
||||
result.exitCode = 124;
|
||||
} else {
|
||||
DWORD exit = 0;
|
||||
GetExitCodeProcess(pi.hProcess, &exit);
|
||||
// NTSTATUS error severity (top two bits set) means the process
|
||||
// terminated by exception — STATUS_ACCESS_VIOLATION 0xC0000005,
|
||||
// STATUS_STACK_OVERFLOW 0xC00000FD, etc. Surface those as crashes
|
||||
// so the runner shows 💥 instead of a numeric exit code.
|
||||
if ((exit & 0xC0000000) == 0xC0000000) {
|
||||
result.crashed = true;
|
||||
result.signal = static_cast<int>(exit);
|
||||
}
|
||||
result.exitCode = static_cast<int>(exit);
|
||||
}
|
||||
|
||||
reader.join();
|
||||
CloseHandle(readEnd);
|
||||
CloseHandle(pi.hThread);
|
||||
CloseHandle(pi.hProcess);
|
||||
CloseHandle(job);
|
||||
|
||||
result.output = std::move(output);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
|
||||
|
|
@ -155,10 +266,7 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
|
|||
}
|
||||
fs::path hostExe(std::string(hostExeBuf, hostExeLen));
|
||||
|
||||
const char* envHome = std::getenv("CRAFTER_BUILD_HOME");
|
||||
fs::path sourceDir = envHome
|
||||
? fs::path(envHome)
|
||||
: hostExe.parent_path().parent_path() / "share" / "crafter-build";
|
||||
fs::path sourceDir = Crafter::GetCrafterBuildHome();
|
||||
|
||||
Configuration hostConfig;
|
||||
hostConfig.target = "x86_64-pc-windows-msvc";
|
||||
|
|
@ -318,19 +426,37 @@ std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
|
|||
fs::path stdCc = fs::path(std::format("/usr/x86_64-w64-mingw32/include/c++/{}/bits/std.cc", mingWversion));
|
||||
|
||||
if(!fs::exists(stdPcm) || fs::last_write_time(stdPcm) < fs::last_write_time(stdCc)) {
|
||||
return RunCommand(std::format("cp {} {}/std.cppm\nclang++ --target={} -march={} -mtune={} -O3 -std=c++26 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile {}/std.cppm -o {}", stdCc.string(), stdPcm.parent_path().string(), config.target, config.march, config.mtune, stdPcm.parent_path().string(), stdPcm.string()));
|
||||
// -femulated-tls keeps PCM TLS access matching libstdc++'s emutls
|
||||
// definitions; mismatch surfaces as undefined std::__once_callable.
|
||||
return RunCommand(std::format("cp {} {}/std.cppm\nclang++ --target={} -march={} -mtune={} -femulated-tls -O3 -std=c++26 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile {}/std.cppm -o {}", stdCc.string(), stdPcm.parent_path().string(), config.target, config.march, config.mtune, stdPcm.parent_path().string(), stdPcm.string()));
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
} else {
|
||||
std::string stdCppm = config.sysroot.empty()
|
||||
? std::string("/usr/share/libc++/v1/std.cppm")
|
||||
: std::format("{}/usr/share/libc++/v1/std.cppm", config.sysroot);
|
||||
bool isWasm = config.target.starts_with("wasm32");
|
||||
// wasi-sdk drops std.cppm at <sysroot>/share/libc++/v1/, the rest of
|
||||
// the libc++ ecosystem (e.g. /opt/aarch64-rootfs) follows FHS at
|
||||
// <sysroot>/usr/share/libc++/v1/.
|
||||
std::string stdCppm;
|
||||
if (isWasm) {
|
||||
stdCppm = std::format("{}/share/libc++/v1/std.cppm", config.sysroot);
|
||||
} else if (config.sysroot.empty()) {
|
||||
stdCppm = "/usr/share/libc++/v1/std.cppm";
|
||||
} else {
|
||||
stdCppm = std::format("{}/usr/share/libc++/v1/std.cppm", config.sysroot);
|
||||
}
|
||||
std::string sysrootFlag = config.sysroot.empty()
|
||||
? std::string()
|
||||
: std::format(" --sysroot={}", config.sysroot);
|
||||
// wasm32 rejects -march. wasi-libc++ headers require these baselines
|
||||
// to compile: setjmp.h needs -mllvm -wasm-enable-sjlj (lowered to wasm
|
||||
// EH); signal.h requires the emulation define; and EH itself isn't
|
||||
// wired up so -fno-exceptions stays.
|
||||
std::string archFlags = isWasm
|
||||
? std::string(" -fno-exceptions -fno-c++-static-destructors -mllvm -wasm-enable-sjlj -D_WASI_EMULATED_SIGNAL")
|
||||
: std::format(" -march={} -mtune={}", config.march, config.mtune);
|
||||
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()));
|
||||
return RunCommand(std::format("clang++ --target={} -std=c++26 -stdlib=libc++{}{} -O3 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile {} -o {}", config.target, sysrootFlag, archFlags, stdCppm, stdPcm.string()));
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
|
|
@ -406,10 +532,7 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
|
|||
|
||||
fs::path hostExe = fs::read_symlink("/proc/self/exe");
|
||||
|
||||
const char* envHome = std::getenv("CRAFTER_BUILD_HOME");
|
||||
fs::path sourceDir = envHome
|
||||
? fs::path(envHome)
|
||||
: hostExe.parent_path().parent_path() / "share" / "crafter-build";
|
||||
fs::path sourceDir = Crafter::GetCrafterBuildHome();
|
||||
|
||||
Configuration hostConfig;
|
||||
hostConfig.target = "x86_64-pc-linux-gnu";
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ namespace {
|
|||
return false;
|
||||
}
|
||||
|
||||
std::string ShellQuote(std::string_view s) {
|
||||
std::string ShellQuoteSh(std::string_view s) {
|
||||
std::string out;
|
||||
out.reserve(s.size() + 2);
|
||||
out.push_back('\'');
|
||||
|
|
@ -75,11 +75,42 @@ namespace {
|
|||
return out;
|
||||
}
|
||||
|
||||
std::string JoinAndQuoteArgs(std::span<const std::string> args) {
|
||||
// cmd.exe doesn't recognize '...' as quoting (it would pass the single
|
||||
// quotes through to the executable). Wrap in "..." for cmd; embedded "
|
||||
// is rare in paths but escape it to be safe. Backslash sequences before
|
||||
// " don't need the MS-CRT doubling rules because we go through
|
||||
// `cmd /C "..."`, which uses cmd's parser, not the CRT's argv splitter.
|
||||
std::string ShellQuoteCmd(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;
|
||||
}
|
||||
|
||||
// Host-shell quoting: sh on Linux, cmd on Windows. For args/paths that
|
||||
// hit the local shell (Local runner exec, Cmd-prefix runner exec).
|
||||
std::string ShellQuoteHost(std::string_view s) {
|
||||
#ifdef _WIN32
|
||||
return ShellQuoteCmd(s);
|
||||
#else
|
||||
return ShellQuoteSh(s);
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string JoinAndQuoteArgs(std::span<const std::string> args, TestRunner::Shell shell) {
|
||||
std::string out;
|
||||
for (const auto& a : args) {
|
||||
if (!out.empty()) out.push_back(' ');
|
||||
out += ShellQuote(a);
|
||||
switch (shell) {
|
||||
case TestRunner::Shell::Sh: out += ShellQuoteSh(a); break;
|
||||
case TestRunner::Shell::Cmd: out += ShellQuoteCmd(a); break;
|
||||
case TestRunner::Shell::Host: out += ShellQuoteHost(a); break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -167,9 +198,42 @@ namespace {
|
|||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
std::atomic<Configuration*> g_parentProject{nullptr};
|
||||
|
||||
Configuration* FindLibInTree(Configuration* root,
|
||||
std::string_view name,
|
||||
std::unordered_set<Configuration*>& seen) {
|
||||
if (!seen.insert(root).second) return nullptr;
|
||||
if (root->name == name) return root;
|
||||
for (Configuration* dep : root->dependencies) {
|
||||
if (auto found = FindLibInTree(dep, name, seen)) return found;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void Crafter::SetParentProject(Configuration* parent) {
|
||||
g_parentProject.store(parent);
|
||||
}
|
||||
|
||||
Configuration* Crafter::ParentLib(std::string_view name) {
|
||||
Configuration* root = g_parentProject.load();
|
||||
if (!root) {
|
||||
throw std::runtime_error(std::format(
|
||||
"Crafter::ParentLib('{}'): no parent project set", name));
|
||||
}
|
||||
std::unordered_set<Configuration*> seen;
|
||||
if (auto found = FindLibInTree(root, name, seen)) return found;
|
||||
throw std::runtime_error(std::format(
|
||||
"Crafter::ParentLib('{}'): not found in parent project '{}'",
|
||||
name, root->name));
|
||||
}
|
||||
|
||||
TestRunner TestRunner::Local() {
|
||||
TestRunner r;
|
||||
r.name = "local";
|
||||
r.argsShell = Shell::Host;
|
||||
return r;
|
||||
}
|
||||
|
||||
|
|
@ -177,10 +241,18 @@ 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);
|
||||
r.argsShell = Shell::Sh;
|
||||
// Outer "..." (not '...') so the wrapper survives both sh (Linux host)
|
||||
// and cmd (Windows host). cmd doesn't honor single quotes, which would
|
||||
// cause it to split on the inner '&&'. Args inside use POSIX quoting
|
||||
// because they reach a remote bash regardless of which host issued them.
|
||||
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}}\"", host);
|
||||
// RunCommandChecked captures stdout+stderr internally — no shell redirect
|
||||
// needed in the probe spec, which means it works the same way under cmd
|
||||
// (Windows host) and sh (Linux host) since `> /dev/null` doesn't translate.
|
||||
r.probe = std::format("ssh -q -o BatchMode=yes -o ConnectTimeout=5 {} true", host);
|
||||
return r;
|
||||
}
|
||||
|
||||
|
|
@ -188,6 +260,7 @@ TestRunner TestRunner::SshWin(std::string host, std::string remoteDir) {
|
|||
TestRunner r;
|
||||
r.name = std::format("sshwin:{}", host);
|
||||
r.remoteDir = std::move(remoteDir);
|
||||
r.argsShell = Shell::Cmd;
|
||||
// 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
|
||||
|
|
@ -196,15 +269,39 @@ TestRunner TestRunner::SshWin(std::string host, std::string remoteDir) {
|
|||
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);
|
||||
// No shell redirect in the probe — see TestRunner::Ssh for rationale.
|
||||
r.probe = std::format("ssh -q -o BatchMode=yes -o ConnectTimeout=5 {} \"ver\"", host);
|
||||
return r;
|
||||
}
|
||||
|
||||
TestRunner TestRunner::QemuUser(std::string qemuBin) {
|
||||
TestRunner TestRunner::Wsl(std::string remoteDir) {
|
||||
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);
|
||||
r.name = "wsl";
|
||||
r.argsShell = Shell::Sh;
|
||||
r.remoteDir = std::move(remoteDir);
|
||||
// Transport runner: stage the test bundle into WSL's native filesystem
|
||||
// (faster than executing in-place from /mnt/c) then run via bash. {bundle_wsl}
|
||||
// is the local Windows path translated to /mnt/<drive>/... form so wsl cp
|
||||
// can read it. Args are POSIX-quoted because they reach a Linux shell.
|
||||
r.copy = "wsl mkdir -p {remote_bundle} && wsl cp -r {bundle_wsl}/. {remote_bundle}/";
|
||||
r.exec = "wsl bash -c \"cd {remote_bundle} && ./{bin_name} {args}\"";
|
||||
r.cleanup = "wsl rm -rf {remote_bundle}";
|
||||
// `where wsl` matches the host-shell convention; on a non-Windows host
|
||||
// it fails (no `where` command), which correctly skips this runner.
|
||||
r.probe = "where wsl";
|
||||
return r;
|
||||
}
|
||||
|
||||
TestRunner TestRunner::Cmd(std::string command) {
|
||||
TestRunner r;
|
||||
r.name = std::format("cmd:{}", command);
|
||||
r.argsShell = Shell::Host;
|
||||
r.exec = std::format("{} {{bin}} {{args}}", command);
|
||||
#ifdef _WIN32
|
||||
r.probe = std::format("where {}", command);
|
||||
#else
|
||||
r.probe = std::format("which {}", command);
|
||||
#endif
|
||||
return r;
|
||||
}
|
||||
|
||||
|
|
@ -225,7 +322,7 @@ namespace {
|
|||
}
|
||||
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] == "cmd" && parts.size() == 2) return TestRunner::Cmd(parts[1]);
|
||||
if (parts[0] == "ssh" && parts.size() == 2) return TestRunner::Ssh(parts[1]);
|
||||
if (parts[0] == "ssh" && parts.size() >= 3) {
|
||||
std::string remote;
|
||||
|
|
@ -244,9 +341,34 @@ namespace {
|
|||
}
|
||||
return TestRunner::SshWin(parts[1], remote);
|
||||
}
|
||||
if (parts[0] == "wsl" && parts.size() == 1) return TestRunner::Wsl();
|
||||
if (parts[0] == "wsl" && parts.size() >= 2) {
|
||||
std::string remote;
|
||||
for (std::size_t i = 1; i < parts.size(); ++i) {
|
||||
if (i > 1) remote.push_back(':');
|
||||
remote += parts[i];
|
||||
}
|
||||
return TestRunner::Wsl(remote);
|
||||
}
|
||||
throw std::runtime_error(std::format(
|
||||
"TestRunner::FromEnv: unrecognized runner spec '{}'", spec));
|
||||
"TestRunner::FromSpec: unrecognized runner spec '{}'", spec));
|
||||
}
|
||||
|
||||
// C:\Users\jorij -> /mnt/c/Users/jorij. Idempotent on paths that don't
|
||||
// start with a drive letter, so callers can compute it unconditionally.
|
||||
std::string WindowsPathToWsl(std::string_view p) {
|
||||
if (p.size() < 2 || p[1] != ':') return std::string(p);
|
||||
char drive = static_cast<char>(std::tolower(static_cast<unsigned char>(p[0])));
|
||||
std::string out = std::format("/mnt/{}", drive);
|
||||
for (std::size_t i = 2; i < p.size(); ++i) {
|
||||
out.push_back(p[i] == '\\' ? '/' : p[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<TestRunner> TestRunner::FromSpec(std::string_view spec) {
|
||||
return ParseRunnerSpec(spec);
|
||||
}
|
||||
|
||||
TestRunner TestRunner::FromEnv(std::string_view target, TestRunner fallback) {
|
||||
|
|
@ -263,7 +385,7 @@ TestResult Crafter::RunSingleTest(const Test& test, const fs::path& binary, std:
|
|||
result.name = test.config.name;
|
||||
|
||||
std::map<std::string, std::string> ph;
|
||||
ph["{args}"] = JoinAndQuoteArgs(test.args);
|
||||
ph["{args}"] = JoinAndQuoteArgs(test.args, test.runner.argsShell);
|
||||
ph["{bin_name}"] = binary.filename().string();
|
||||
ph["{bundle}"] = binary.parent_path().string();
|
||||
|
||||
|
|
@ -271,8 +393,8 @@ TestResult Crafter::RunSingleTest(const Test& test, const fs::path& binary, std:
|
|||
CommandResult r;
|
||||
|
||||
if (test.runner.exec.empty()) {
|
||||
// Pure-local runner: spawn the binary directly.
|
||||
std::string cmd = std::format("{} {}", ShellQuote(binary.string()), ph["{args}"]);
|
||||
// Pure-local runner: spawn the binary directly through the host shell.
|
||||
std::string cmd = std::format("{} {}", ShellQuoteHost(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.
|
||||
|
|
@ -292,6 +414,10 @@ TestResult Crafter::RunSingleTest(const Test& test, const fs::path& binary, std:
|
|||
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(), '/', '\\');
|
||||
// WSL form of the local bundle path: C:\foo -> /mnt/c/foo. Used by
|
||||
// the Wsl runner's `wsl cp -r` step to read the test bundle out of
|
||||
// the Windows host's filesystem.
|
||||
ph["{bundle_wsl}"] = WindowsPathToWsl(ph["{bundle}"]);
|
||||
|
||||
CommandResult cp = RunCommandWithTimeout(Substitute(test.runner.copy, ph), 5min);
|
||||
if (cp.exitCode != 0) {
|
||||
|
|
@ -318,14 +444,133 @@ TestResult Crafter::RunSingleTest(const Test& test, const fs::path& binary, std:
|
|||
|
||||
if (r.timedOut) result.outcome = TestOutcome::Timeout;
|
||||
else if (r.crashed) result.outcome = TestOutcome::Crash;
|
||||
else if (r.exitCode == 77) result.outcome = TestOutcome::Skipped;
|
||||
else if (r.exitCode != 0) result.outcome = TestOutcome::Fail;
|
||||
else result.outcome = TestOutcome::Pass;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
TestSummary Crafter::RunTests(Configuration& projectCfg, const RunTestsOptions& opts) {
|
||||
namespace {
|
||||
// Synthesize a Configuration for tests/<Name>/ folders that don't contain
|
||||
// a project.cpp. Convention: cfg.path = the folder, cfg.name/outputName =
|
||||
// folder basename, cfg.target = the run's targetFilter, cfg.type = exe.
|
||||
// Sources: top-level *.cpp (excluding project.cpp) become implementations,
|
||||
// interfaces/*.cppm become module interfaces (matching the layout used
|
||||
// elsewhere in this codebase). Tests with deeper layouts, defines, or
|
||||
// dependencies still need an explicit project.cpp.
|
||||
Configuration SynthesizeTest(const fs::path& dir, std::string_view target) {
|
||||
Configuration cfg;
|
||||
cfg.path = dir;
|
||||
cfg.name = dir.filename().string();
|
||||
cfg.outputName = cfg.name;
|
||||
cfg.target = std::string(target);
|
||||
cfg.type = ConfigurationType::Executable;
|
||||
|
||||
std::vector<fs::path> impls;
|
||||
for (auto& e : fs::directory_iterator(dir)) {
|
||||
if (!e.is_regular_file()) continue;
|
||||
auto p = e.path();
|
||||
if (p.extension() != ".cpp") continue;
|
||||
if (p.filename() == "project.cpp") continue;
|
||||
impls.push_back(p.stem());
|
||||
}
|
||||
std::ranges::sort(impls);
|
||||
|
||||
std::vector<fs::path> ifaces;
|
||||
fs::path interfacesDir = dir / "interfaces";
|
||||
if (fs::exists(interfacesDir) && fs::is_directory(interfacesDir)) {
|
||||
for (auto& e : fs::directory_iterator(interfacesDir)) {
|
||||
if (!e.is_regular_file()) continue;
|
||||
auto p = e.path();
|
||||
if (p.extension() != ".cppm") continue;
|
||||
ifaces.push_back(fs::path("interfaces") / p.stem());
|
||||
}
|
||||
std::ranges::sort(ifaces);
|
||||
}
|
||||
|
||||
if (impls.empty() && ifaces.empty()) {
|
||||
throw std::runtime_error(std::format(
|
||||
"no .cpp or interfaces/*.cppm files found in {}", dir.string()));
|
||||
}
|
||||
|
||||
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||
return cfg;
|
||||
}
|
||||
}
|
||||
|
||||
TestSummary Crafter::RunTests(Configuration& projectCfg, const RunTestsOptions& opts, std::span<const std::string_view> projectArgs) {
|
||||
TestSummary summary;
|
||||
std::vector<TestResult> discoveryFailures;
|
||||
|
||||
// Auto-discover tests one layer deep: each <project>/tests/<Name>/ folder
|
||||
// is a test. If it contains project.cpp, that's loaded for full control;
|
||||
// otherwise the Configuration is synthesized from the folder contents.
|
||||
// Folders whose name starts with '_' or '.' are skipped (so tests/_shared/
|
||||
// holds cross-test code without becoming a test). Each project.cpp receives
|
||||
// the same args the root project did, so --target=... propagates through.
|
||||
//
|
||||
// Discovery is keyed off cwd (= the project root, since crafter-build loads
|
||||
// ./project.cpp), not projectCfg.path: tests live at the project root even
|
||||
// when projectCfg.path points at a subdirectory like "./src/" or "./lib/".
|
||||
fs::path testsDir = fs::current_path() / "tests";
|
||||
if (fs::exists(testsDir) && fs::is_directory(testsDir)) {
|
||||
struct TestEntry { fs::path dir; fs::path pcpp; }; // pcpp empty = synth
|
||||
std::vector<TestEntry> entries;
|
||||
for (auto& entry : fs::directory_iterator(testsDir)) {
|
||||
if (!entry.is_directory()) continue;
|
||||
auto stem = entry.path().filename().string();
|
||||
if (stem.empty() || stem[0] == '_' || stem[0] == '.') continue;
|
||||
TestEntry te;
|
||||
te.dir = entry.path();
|
||||
auto pcpp = te.dir / "project.cpp";
|
||||
if (fs::exists(pcpp)) te.pcpp = pcpp;
|
||||
entries.push_back(std::move(te));
|
||||
}
|
||||
std::ranges::sort(entries, [](auto& a, auto& b) { return a.dir < b.dir; });
|
||||
|
||||
// Inject --target=<filter> into the args we hand each fixture so its
|
||||
// CrafterBuildProject can default to the run's target. The CLI's own
|
||||
// --target=... propagates through projectArgs already; this only
|
||||
// appends when missing so an explicit user choice wins.
|
||||
std::string targetArg = std::format("--target={}", opts.targetFilter);
|
||||
std::vector<std::string_view> fixtureArgs(projectArgs.begin(), projectArgs.end());
|
||||
bool hasTarget = std::ranges::any_of(fixtureArgs, [](std::string_view a) {
|
||||
return a.starts_with("--target=");
|
||||
});
|
||||
if (!hasTarget) fixtureArgs.push_back(targetArg);
|
||||
|
||||
for (auto& te : entries) {
|
||||
Test t;
|
||||
try {
|
||||
if (!te.pcpp.empty()) {
|
||||
t.config = LoadProject(te.pcpp, fixtureArgs);
|
||||
} else {
|
||||
t.config = SynthesizeTest(te.dir, opts.targetFilter);
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
// A broken fixture shouldn't kill the whole run. Surface as a
|
||||
// Fail and let other tests proceed.
|
||||
TestResult r;
|
||||
r.name = te.dir.filename().string();
|
||||
r.outcome = TestOutcome::Fail;
|
||||
r.exitCode = -1;
|
||||
r.output = !te.pcpp.empty()
|
||||
? std::format("project.cpp failed to load: {}", e.what())
|
||||
: std::format("test discovery failed: {}", e.what());
|
||||
discoveryFailures.push_back(std::move(r));
|
||||
continue;
|
||||
}
|
||||
if (t.config.target != opts.targetFilter) continue;
|
||||
t.runner = TestRunner::FromEnv(t.config.target, TestRunner::Local());
|
||||
if (opts.runnerOverride) {
|
||||
if (auto r = TestRunner::FromSpec(*opts.runnerOverride)) {
|
||||
t.runner = std::move(*r);
|
||||
}
|
||||
}
|
||||
projectCfg.tests.push_back(std::move(t));
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<Test*> filtered;
|
||||
filtered.reserve(projectCfg.tests.size());
|
||||
|
|
@ -336,17 +581,28 @@ TestSummary Crafter::RunTests(Configuration& projectCfg, const RunTestsOptions&
|
|||
}
|
||||
|
||||
if (opts.listOnly) {
|
||||
for (auto& r : discoveryFailures) {
|
||||
std::println("{} (project.cpp broken)", r.name);
|
||||
}
|
||||
for (auto* t : filtered) {
|
||||
std::println("{}", t->config.name);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
if (filtered.empty()) {
|
||||
if (filtered.empty() && discoveryFailures.empty()) {
|
||||
std::println("No tests matched.");
|
||||
return summary;
|
||||
}
|
||||
|
||||
// Render discovery failures upfront so they appear before parallel test
|
||||
// results. They're already-determined Fails — no need to put them through
|
||||
// the worker pool.
|
||||
for (auto& r : discoveryFailures) {
|
||||
PrintResult(r, "");
|
||||
WriteLog(projectCfg.path, r.name, r.output);
|
||||
}
|
||||
|
||||
int jobs = opts.jobs > 0
|
||||
? opts.jobs
|
||||
: std::max(1u, std::thread::hardware_concurrency());
|
||||
|
|
@ -366,7 +622,9 @@ TestSummary Crafter::RunTests(Configuration& projectCfg, const RunTestsOptions&
|
|||
if (auto it = probeCache.find(runner.name); it != probeCache.end()) {
|
||||
return it->second;
|
||||
}
|
||||
bool ok = (std::system(runner.probe.c_str()) == 0);
|
||||
// RunCommandChecked captures and discards output internally, so probe
|
||||
// specs don't need `> /dev/null 2>&1` (which doesn't translate to cmd).
|
||||
bool ok = (RunCommandChecked(runner.probe).exitCode == 0);
|
||||
probeCache[runner.name] = ok;
|
||||
return ok;
|
||||
};
|
||||
|
|
@ -446,6 +704,10 @@ TestSummary Crafter::RunTests(Configuration& projectCfg, const RunTestsOptions&
|
|||
}
|
||||
threads.clear(); // joins all jthreads
|
||||
|
||||
for (auto& r : discoveryFailures) {
|
||||
results.push_back(std::move(r));
|
||||
}
|
||||
|
||||
for (auto& r : results) {
|
||||
switch (r.outcome) {
|
||||
case TestOutcome::Pass: summary.passed++; break;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue