diff --git a/project.cpp b/project.cpp index a1e9fd1..89ab636 100644 --- a/project.cpp +++ b/project.cpp @@ -98,6 +98,15 @@ extern "C" Configuration CrafterBuildProject(std::span a // Mirrors how downstream consumers link their own libraries into tests. if (cfg.target == "x86_64-pc-linux-gnu") { cfg.AddTest("HelloWorld").Dependencies({ crafterBuildLib.get() }); + cfg.AddTest("StaticLib").Dependencies({ crafterBuildLib.get() }); + cfg.AddTest("ModuleInterface").Dependencies({ crafterBuildLib.get() }); + cfg.AddTest("DependencyLink").Dependencies({ crafterBuildLib.get() }); + cfg.AddTest("ShaderCompile").Dependencies({ crafterBuildLib.get() }); + cfg.AddTest("StandardArgs").Dependencies({ crafterBuildLib.get() }); + cfg.AddTest("TestRunnerSpec").Dependencies({ crafterBuildLib.get() }); + cfg.AddTest("VariantId").Dependencies({ crafterBuildLib.get() }); + cfg.AddTest("WasiBrowserRuntime").Dependencies({ crafterBuildLib.get() }); + cfg.AddTest("RunSingleTestExit").Dependencies({ crafterBuildLib.get() }); } return cfg; diff --git a/tests/DependencyLink/fixture/main.cpp b/tests/DependencyLink/fixture/main.cpp new file mode 100644 index 0000000..93a058f --- /dev/null +++ b/tests/DependencyLink/fixture/main.cpp @@ -0,0 +1,7 @@ +import std; +import Calc; + +int main() { + std::print("{}", Add(3, 4)); + return 0; +} diff --git a/tests/DependencyLink/fixture/mathlib/Calc.cppm b/tests/DependencyLink/fixture/mathlib/Calc.cppm new file mode 100644 index 0000000..994e745 --- /dev/null +++ b/tests/DependencyLink/fixture/mathlib/Calc.cppm @@ -0,0 +1,4 @@ +export module Calc; +import std; + +export int Add(int a, int b) { return a + b; } diff --git a/tests/DependencyLink/main.cpp b/tests/DependencyLink/main.cpp new file mode 100644 index 0000000..445f56e --- /dev/null +++ b/tests/DependencyLink/main.cpp @@ -0,0 +1,90 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +// Two-config build: an exe linked against a sibling static library via +// cfg.dependencies. Exercises cross-config module import resolution (the +// exe imports Calc, which is defined in the lib's interface set) and the +// dep-archive linking step. +int main() { + fs::path fixtureRoot = fs::current_path() / "tests" / "DependencyLink" / "fixture"; + + auto lib = std::make_unique(); + lib->path = fixtureRoot / "mathlib"; + lib->name = "calc"; + lib->outputName = "calc"; + lib->target = HostTarget(); + lib->type = ConfigurationType::LibraryStatic; + { + std::array ifaces = { "Calc" }; + std::array impls = {}; + lib->GetInterfacesAndImplementations(ifaces, impls); + } + + Configuration app; + app.path = fixtureRoot; + app.name = "calc-app"; + app.outputName = "calc-app"; + app.target = HostTarget(); + app.type = ConfigurationType::Executable; + app.dependencies = { lib.get() }; + { + std::array ifaces = {}; + std::array impls = { "main" }; + app.GetInterfacesAndImplementations(ifaces, impls); + } + + if (app.implementations.size() != 1) { + std::println(std::cerr, "expected 1 implementation, got {}", app.implementations.size()); + return 1; + } + // The import resolves to the dependency's interface, not a local one. + if (!app.implementations[0].moduleDependencies.empty()) { + std::println(std::cerr, "expected no local module deps, got {}", + app.implementations[0].moduleDependencies.size()); + return 1; + } + if (app.implementations[0].externalModuleDependencies.size() != 1) { + std::println(std::cerr, "expected 1 external module dep, got {}", + app.implementations[0].externalModuleDependencies.size()); + return 1; + } + if (app.implementations[0].externalModuleDependencies[0].first->name != "Calc") { + std::println(std::cerr, "expected external dep 'Calc', got '{}'", + app.implementations[0].externalModuleDependencies[0].first->name); + return 1; + } + + std::unordered_map> depResults; + std::mutex depMutex; + BuildResult r = Build(app, depResults, depMutex); + if (!r.result.empty()) { + std::println(std::cerr, "build failed: {}", r.result); + return 1; + } + + fs::path libArchive = lib->BinDir() / "libcalc.a"; + if (!fs::exists(libArchive)) { + std::println(std::cerr, "dep archive not produced at {}", libArchive.string()); + return 1; + } + + fs::path bin = app.BinDir() / "calc-app"; + if (!fs::exists(bin)) { + std::println(std::cerr, "binary not produced at {}", bin.string()); + return 1; + } + + auto run = RunCommandWithTimeout(bin.string(), std::chrono::seconds(10)); + if (run.exitCode != 0 || run.timedOut || run.crashed) { + std::println(std::cerr, "exe did not exit cleanly: exit={} output={}", + run.exitCode, run.output); + return 1; + } + if (run.output != "7") { + std::println(std::cerr, "expected '7', got '{}'", run.output); + return 1; + } + return 0; +} diff --git a/tests/ModuleInterface/fixture/Greeter.cppm b/tests/ModuleInterface/fixture/Greeter.cppm new file mode 100644 index 0000000..97ebc78 --- /dev/null +++ b/tests/ModuleInterface/fixture/Greeter.cppm @@ -0,0 +1,4 @@ +export module Greeter; +import std; + +export std::string Greet() { return "ok-from-module"; } diff --git a/tests/ModuleInterface/fixture/main.cpp b/tests/ModuleInterface/fixture/main.cpp new file mode 100644 index 0000000..e4fc1b5 --- /dev/null +++ b/tests/ModuleInterface/fixture/main.cpp @@ -0,0 +1,7 @@ +import std; +import Greeter; + +int main() { + std::print("{}", Greet()); + return 0; +} diff --git a/tests/ModuleInterface/main.cpp b/tests/ModuleInterface/main.cpp new file mode 100644 index 0000000..9173dc1 --- /dev/null +++ b/tests/ModuleInterface/main.cpp @@ -0,0 +1,65 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +// Build an executable that imports a project-local .cppm module, then run +// it and check the produced output. Exercises the within-project module +// dependency path (main.cpp imports Greeter, resolved through cfg.interfaces). +int main() { + Configuration cfg; + cfg.path = fs::current_path() / "tests" / "ModuleInterface" / "fixture"; + cfg.name = "greeter-app"; + cfg.outputName = "greeter-app"; + cfg.target = HostTarget(); + cfg.type = ConfigurationType::Executable; + + std::array ifaces = { "Greeter" }; + std::array impls = { "main" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + + // Implementation should have picked up the import Greeter; through the + // interface list resolution in GetInterfacesAndImplementations. + if (cfg.implementations.size() != 1) { + std::println(std::cerr, "expected 1 implementation, got {}", cfg.implementations.size()); + return 1; + } + if (cfg.implementations[0].moduleDependencies.size() != 1) { + std::println(std::cerr, + "expected main.cpp to depend on 1 module, got {}", + cfg.implementations[0].moduleDependencies.size()); + return 1; + } + if (cfg.implementations[0].moduleDependencies[0]->name != "Greeter") { + std::println(std::cerr, + "expected dep 'Greeter', got '{}'", + cfg.implementations[0].moduleDependencies[0]->name); + return 1; + } + + std::unordered_map> depResults; + std::mutex depMutex; + BuildResult r = Build(cfg, depResults, depMutex); + if (!r.result.empty()) { + std::println(std::cerr, "build failed: {}", r.result); + return 1; + } + + fs::path bin = cfg.BinDir() / "greeter-app"; + if (!fs::exists(bin)) { + std::println(std::cerr, "binary not produced at {}", bin.string()); + return 1; + } + + auto run = RunCommandWithTimeout(bin.string(), std::chrono::seconds(10)); + if (run.exitCode != 0 || run.timedOut || run.crashed) { + std::println(std::cerr, "binary did not exit cleanly: exit={} output={}", + run.exitCode, run.output); + return 1; + } + if (run.output != "ok-from-module") { + std::println(std::cerr, "unexpected output: '{}'", run.output); + return 1; + } + return 0; +} diff --git a/tests/RunSingleTestExit/main.cpp b/tests/RunSingleTestExit/main.cpp new file mode 100644 index 0000000..a76a0ab --- /dev/null +++ b/tests/RunSingleTestExit/main.cpp @@ -0,0 +1,87 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +namespace { + int failures = 0; + + void Check(bool cond, std::string_view msg) { + if (!cond) { + std::println(std::cerr, "FAIL: {}", msg); + ++failures; + } + } + + // Write a tiny POSIX shell script that exits with `code` and return its + // path. The harness invokes ` ` through the host shell, so + // a #!/bin/sh script works as a stand-in for a real test binary. + fs::path WriteExitScript(const fs::path& dir, std::string_view stem, int code) { + fs::path script = dir / stem; + std::ofstream out(script); + out << "#!/bin/sh\nexit " << code << "\n"; + out.close(); + fs::permissions(script, + fs::perms::owner_read | fs::perms::owner_write | fs::perms::owner_exec + | fs::perms::group_read | fs::perms::group_exec + | fs::perms::others_read | fs::perms::others_exec, + fs::perm_options::replace); + return script; + } +} + +// Drives RunSingleTest against shell-script fixtures and asserts the +// exit-code → TestOutcome mapping documented in the README: +// 0 -> Pass +// 77 -> Skipped (autoconf convention) +// anything else -> Fail +int main() { + fs::path dir = fs::temp_directory_path() / "crafter-build-run-single-test"; + std::error_code ec; + fs::remove_all(dir, ec); + fs::create_directories(dir); + + Test t; + t.runner = TestRunner::Local(); + t.config.outputName = "run-single"; + + { + fs::path script = WriteExitScript(dir, "pass.sh", 0); + TestResult r = RunSingleTest(t, script, std::chrono::seconds(5)); + Check(r.outcome == TestOutcome::Pass, "exit 0 -> Pass"); + Check(r.exitCode == 0, "Pass carries exitCode 0"); + } + { + fs::path script = WriteExitScript(dir, "skip.sh", 77); + TestResult r = RunSingleTest(t, script, std::chrono::seconds(5)); + Check(r.outcome == TestOutcome::Skipped, "exit 77 -> Skipped"); + Check(r.exitCode == 77, "Skipped carries exitCode 77"); + } + { + fs::path script = WriteExitScript(dir, "fail.sh", 3); + TestResult r = RunSingleTest(t, script, std::chrono::seconds(5)); + Check(r.outcome == TestOutcome::Fail, "non-zero/non-77 exit -> Fail"); + Check(r.exitCode == 3, "Fail carries the original exitCode"); + } + + // Cmd-prefix runner: wrap the script in `sh {bin} {args}` so we exercise + // the templated-exec path through RunSingleTest. Probes don't run here + // (RunSingleTest doesn't probe — RunTests does), so any prefix that + // resolves on PATH is fine. + { + Test ct; + ct.runner = TestRunner::Cmd("sh"); + ct.config.outputName = "run-single-prefix"; + fs::path script = WriteExitScript(dir, "prefixed.sh", 0); + TestResult r = RunSingleTest(ct, script, std::chrono::seconds(5)); + Check(r.outcome == TestOutcome::Pass, "Cmd-prefixed exit 0 -> Pass"); + } + + fs::remove_all(dir, ec); + + if (failures > 0) { + std::println(std::cerr, "{} assertions failed", failures); + return 1; + } + return 0; +} diff --git a/tests/ShaderCompile/fixture/triangle.frag b/tests/ShaderCompile/fixture/triangle.frag new file mode 100644 index 0000000..7acc257 --- /dev/null +++ b/tests/ShaderCompile/fixture/triangle.frag @@ -0,0 +1,8 @@ +#version 450 + +layout(location = 0) in vec2 inUV; +layout(location = 0) out vec4 outColor; + +void main() { + outColor = vec4(inUV, 0.0, 1.0); +} diff --git a/tests/ShaderCompile/main.cpp b/tests/ShaderCompile/main.cpp new file mode 100644 index 0000000..6fac150 --- /dev/null +++ b/tests/ShaderCompile/main.cpp @@ -0,0 +1,53 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +// Compile a tiny GLSL fragment shader directly via the Shader API. Verifies +// the SPIR-V output lands in the target dir and starts with the SPIR-V magic +// number, without dragging in the full Build() pipeline. +int main() { + fs::path src = fs::current_path() / "tests" / "ShaderCompile" / "fixture" / "triangle.frag"; + if (!fs::exists(src)) { + std::println(std::cerr, "fixture missing: {}", src.string()); + return 1; + } + + fs::path outDir = fs::temp_directory_path() / "crafter-build-shader-test"; + std::error_code ec; + fs::remove_all(outDir, ec); + fs::create_directories(outDir); + + Shader shader(fs::path(src), "main", ShaderType::Fragment); + + std::array includeDirs = {}; + std::string err = shader.Compile(outDir, includeDirs); + if (!err.empty()) { + std::println(std::cerr, "shader compile failed: {}", err); + return 1; + } + + fs::path spv = outDir / "triangle.spv"; + if (!fs::exists(spv)) { + std::println(std::cerr, "spv not produced at {}", spv.string()); + return 1; + } + + // SPIR-V binaries start with the magic number 0x07230203 (little-endian + // when written by glslang on x86). Read the first four bytes to confirm + // the produced file is real SPIR-V, not a fluke empty file. + std::ifstream in(spv, std::ios::binary); + std::uint32_t magic = 0; + in.read(reinterpret_cast(&magic), sizeof(magic)); + if (!in || magic != 0x07230203u) { + std::println(std::cerr, "spv has wrong magic: 0x{:08x}", magic); + return 1; + } + + // Compiling again should be a no-op since the source isn't newer. + if (!shader.Check(outDir)) { + std::println(std::cerr, "Shader::Check returned false on an up-to-date spv"); + return 1; + } + return 0; +} diff --git a/tests/StandardArgs/main.cpp b/tests/StandardArgs/main.cpp new file mode 100644 index 0000000..200c196 --- /dev/null +++ b/tests/StandardArgs/main.cpp @@ -0,0 +1,75 @@ +import std; +import Crafter.Build; +using namespace Crafter; + +namespace { + int failures = 0; + + void Check(bool cond, std::string_view msg) { + if (!cond) { + std::println(std::cerr, "FAIL: {}", msg); + ++failures; + } + } +} + +// Pure-data tests for ApplyStandardArgs and ArgQuery — no build/run, just +// confirms the documented arg parsing produces the documented mutations. +int main() { + // --debug + --target= + --march= + --mtune= + { + Configuration cfg; + cfg.target = "ignored"; + std::array raw = { + "--debug", "--target=aarch64-linux-gnu", "--march=armv8-a", "--mtune=cortex-a72", + }; + ApplyStandardArgs(cfg, raw); + Check(cfg.debug, "--debug should set cfg.debug"); + Check(cfg.target == "aarch64-linux-gnu", "--target= should overwrite cfg.target"); + Check(cfg.march == "armv8-a", "--march= should overwrite cfg.march"); + Check(cfg.mtune == "cortex-a72", "--mtune= should overwrite cfg.mtune"); + } + + // --lib promotes Executable -> LibraryStatic; --shared then promotes to Dynamic. + { + Configuration cfg; + cfg.type = ConfigurationType::Executable; + std::array raw = { "--lib" }; + ApplyStandardArgs(cfg, raw); + Check(cfg.type == ConfigurationType::LibraryStatic, "--lib promotes Exe -> Static"); + } + { + Configuration cfg; + cfg.type = ConfigurationType::Executable; + // Order shouldn't matter — promotions chain in priority order. + std::array raw = { "--shared", "--lib" }; + ApplyStandardArgs(cfg, raw); + Check(cfg.type == ConfigurationType::LibraryDynamic, "--lib + --shared lands on Dynamic regardless of order"); + } + { + Configuration cfg; + cfg.type = ConfigurationType::Executable; + std::array raw = { "--shared" }; + ApplyStandardArgs(cfg, raw); + Check(cfg.type == ConfigurationType::Executable, "--shared alone on Executable is a no-op"); + } + + // ArgQuery is returned over the same args span and exposes Has/Get. + { + Configuration cfg; + std::array raw = { "--timing", "--prefix=/opt/x", "extra" }; + ArgQuery q = ApplyStandardArgs(cfg, raw); + Check(q.Has("--timing"), "ArgQuery::Has true for present flag"); + Check(!q.Has("--missing"), "ArgQuery::Has false for absent flag"); + auto prefix = q.Get("--prefix="); + Check(prefix.has_value(), "ArgQuery::Get returns a value for known prefix"); + Check(prefix.value_or("") == "/opt/x", "ArgQuery::Get returns the substring after ="); + Check(!q.Get("--other=").has_value(), "ArgQuery::Get returns nullopt for absent prefix"); + } + + if (failures > 0) { + std::println(std::cerr, "{} assertions failed", failures); + return 1; + } + return 0; +} diff --git a/tests/StaticLib/fixture/Greet.cppm b/tests/StaticLib/fixture/Greet.cppm new file mode 100644 index 0000000..b54de20 --- /dev/null +++ b/tests/StaticLib/fixture/Greet.cppm @@ -0,0 +1,4 @@ +export module Greet; +import std; + +export std::string Hello() { return "hi"; } diff --git a/tests/StaticLib/main.cpp b/tests/StaticLib/main.cpp new file mode 100644 index 0000000..10dc5d4 --- /dev/null +++ b/tests/StaticLib/main.cpp @@ -0,0 +1,49 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +// Build a tiny module-only static library and confirm libgreet.a lands in +// BinDir(). Exercises ConfigurationType::LibraryStatic, interface module +// parsing via GetInterfacesAndImplementations, and the ar/llvm-lib archive +// step in Crafter.Build-Clang.cpp. +int main() { + Configuration cfg; + cfg.path = fs::current_path() / "tests" / "StaticLib" / "fixture"; + cfg.name = "greet"; + cfg.outputName = "greet"; + cfg.target = HostTarget(); + cfg.type = ConfigurationType::LibraryStatic; + + std::array ifaces = { "Greet" }; + std::array impls = {}; + cfg.GetInterfacesAndImplementations(ifaces, impls); + + if (cfg.interfaces.size() != 1) { + std::println(std::cerr, "expected 1 interface, got {}", cfg.interfaces.size()); + return 1; + } + if (cfg.interfaces[0]->name != "Greet") { + std::println(std::cerr, "expected interface 'Greet', got '{}'", cfg.interfaces[0]->name); + return 1; + } + + std::unordered_map> depResults; + std::mutex depMutex; + BuildResult r = Build(cfg, depResults, depMutex); + if (!r.result.empty()) { + std::println(std::cerr, "build failed: {}", r.result); + return 1; + } + + fs::path archive = cfg.BinDir() / "libgreet.a"; + if (!fs::exists(archive)) { + std::println(std::cerr, "archive not produced at {}", archive.string()); + return 1; + } + if (fs::file_size(archive) == 0) { + std::println(std::cerr, "archive {} is empty", archive.string()); + return 1; + } + return 0; +} diff --git a/tests/TestRunnerSpec/main.cpp b/tests/TestRunnerSpec/main.cpp new file mode 100644 index 0000000..0b94adc --- /dev/null +++ b/tests/TestRunnerSpec/main.cpp @@ -0,0 +1,115 @@ +import std; +import Crafter.Build; +using namespace Crafter; + +// POSIX env helpers — libc++ exports only std::getenv via `import std`, so +// forward-declare the setters we need against the C library directly. The +// test only runs on POSIX hosts (the project.cpp gates new tests by +// x86_64-pc-linux-gnu) so we don't need a Windows alternative here. +extern "C" int setenv(const char* name, const char* value, int overwrite); +extern "C" int unsetenv(const char* name); + +namespace { + int failures = 0; + + void Check(bool cond, std::string_view msg) { + if (!cond) { + std::println(std::cerr, "FAIL: {}", msg); + ++failures; + } + } +} + +// Pure-data tests for TestRunner factories. No tests are actually executed; +// we just confirm the routing tables in ForTarget / FromSpec / FromEnv. +int main() { + // FromSpec: empty -> nullopt; recognized -> populated; garbage -> throws. + { + auto empty = TestRunner::FromSpec(""); + Check(!empty.has_value(), "FromSpec(\"\") returns nullopt"); + } + { + auto local = TestRunner::FromSpec("local"); + Check(local.has_value() && local->IsLocal(), "FromSpec(\"local\") returns Local"); + Check(local.has_value() && local->name == "local", "Local name is 'local'"); + } + { + auto cmd = TestRunner::FromSpec("cmd:wasmtime"); + Check(cmd.has_value(), "FromSpec(\"cmd:wasmtime\") returns a runner"); + Check(cmd.has_value() && cmd->name == "cmd:wasmtime", "Cmd name carries the binary"); + Check(cmd.has_value() && cmd->exec.find("wasmtime") != std::string::npos, "Cmd exec carries the binary"); + Check(cmd.has_value() && !cmd->probe.empty(), "Cmd probe is set so harness can detect missing tools"); + } + { + bool threw = false; + try { + (void)TestRunner::FromSpec("garbage"); + } catch (const std::exception&) { + threw = true; + } + Check(threw, "FromSpec rejects unrecognized non-empty specs"); + } + + // ForTarget routing. + { + Configuration cfg; + cfg.target = HostTarget(); + TestRunner r = TestRunner::ForTarget(cfg); + Check(r.IsLocal(), "ForTarget(host) is Local"); + } + { + Configuration cfg; + cfg.target = "wasm32-wasip1"; + TestRunner r = TestRunner::ForTarget(cfg); + Check(r.name == "cmd:wasmtime", "ForTarget(wasm32-wasip1) routes through wasmtime"); + } + { + Configuration cfg; + cfg.target = "aarch64-linux-gnu"; + TestRunner r = TestRunner::ForTarget(cfg); + Check(r.name == "cmd:qemu-aarch64", "ForTarget(aarch64-linux-gnu) routes through qemu-aarch64"); + } + { + Configuration cfg; + cfg.target = "i686-linux-gnu"; + TestRunner r = TestRunner::ForTarget(cfg); + Check(r.name == "cmd:qemu-i386", "ForTarget(i686-linux-gnu) rewrites arch to i386"); + } + { + // Sysroot propagates to QEMU_LD_PREFIX so the dynamic linker is reachable. + Configuration cfg; + cfg.target = "aarch64-linux-gnu"; + cfg.sysroot = "/opt/alarm-sysroot"; + TestRunner r = TestRunner::ForTarget(cfg); + Check(r.exec.find("QEMU_LD_PREFIX=/opt/alarm-sysroot") != std::string::npos, + "ForTarget propagates sysroot to QEMU_LD_PREFIX"); + } + { + // From a Linux host the only sensible way to run x86_64-w64-mingw32 is wine. + Configuration cfg; + cfg.target = "x86_64-w64-mingw32"; + TestRunner r = TestRunner::ForTarget(cfg); + if (HostTarget().find("linux") != std::string::npos) { + Check(r.name == "wine", "ForTarget(mingw) on Linux host uses wine"); + } + } + + // FromEnv: when the env var is set, it wins over the fallback. + { + // Use a unique fake triple so we don't stomp on a real env var the + // CI/dev shell may have set. + const char* name = "CRAFTER_BUILD_RUNNER_fake_target_for_unit_test"; + setenv(name, "cmd:fake-tool", 1); + TestRunner r = TestRunner::FromEnv("fake-target-for-unit-test", TestRunner::Local()); + Check(r.name == "cmd:fake-tool", "FromEnv reads CRAFTER_BUILD_RUNNER_"); + unsetenv(name); + TestRunner fallback = TestRunner::FromEnv("fake-target-for-unit-test", TestRunner::Local()); + Check(fallback.IsLocal(), "FromEnv falls back when env var is unset"); + } + + if (failures > 0) { + std::println(std::cerr, "{} assertions failed", failures); + return 1; + } + return 0; +} diff --git a/tests/VariantId/main.cpp b/tests/VariantId/main.cpp new file mode 100644 index 0000000..13331ce --- /dev/null +++ b/tests/VariantId/main.cpp @@ -0,0 +1,120 @@ +import std; +import Crafter.Build; +using namespace Crafter; + +namespace { + int failures = 0; + + void Check(bool cond, std::string_view msg) { + if (!cond) { + std::println(std::cerr, "FAIL: {}", msg); + ++failures; + } + } + + Configuration MakeBase() { + Configuration cfg; + cfg.path = "/tmp/variant-id-test"; + cfg.name = "vt"; + cfg.outputName = "vt"; + cfg.target = "x86_64-pc-linux-gnu"; + cfg.march = "native"; + cfg.mtune = "native"; + cfg.type = ConfigurationType::Executable; + return cfg; + } +} + +// VariantId is the cache key for build outputs. Two Configurations that +// differ only in something that affects codegen must produce different +// VariantId / BuildDir / BinDir, otherwise their .o files collide. +int main() { + { + // Baseline against itself: VariantId is deterministic. + Configuration a = MakeBase(); + Configuration b = MakeBase(); + Check(a.VariantId() == b.VariantId(), "identical configs have identical VariantId"); + Check(a.BuildDir() == b.BuildDir(), "identical configs have identical BuildDir"); + } + { + // type differs -> VariantId differs. + Configuration a = MakeBase(); + Configuration b = MakeBase(); + b.type = ConfigurationType::LibraryStatic; + Check(a.VariantId() != b.VariantId(), "type change perturbs VariantId"); + } + { + // debug differs -> VariantId differs. + Configuration a = MakeBase(); + Configuration b = MakeBase(); + b.debug = true; + Check(a.VariantId() != b.VariantId(), "debug change perturbs VariantId"); + } + { + // sysroot differs -> VariantId differs. + Configuration a = MakeBase(); + Configuration b = MakeBase(); + b.sysroot = "/opt/sysroot"; + Check(a.VariantId() != b.VariantId(), "sysroot change perturbs VariantId"); + } + { + // defines differ -> VariantId differs. + Configuration a = MakeBase(); + Configuration b = MakeBase(); + b.defines.push_back({"FOO", "1"}); + Check(a.VariantId() != b.VariantId(), "define added perturbs VariantId"); + } + { + // Same define name, different value -> VariantId differs. + Configuration a = MakeBase(); + a.defines.push_back({"FOO", "1"}); + Configuration b = MakeBase(); + b.defines.push_back({"FOO", "2"}); + Check(a.VariantId() != b.VariantId(), "define value change perturbs VariantId"); + } + { + // compileFlags differ -> VariantId differs. + Configuration a = MakeBase(); + Configuration b = MakeBase(); + b.compileFlags.push_back("-fno-omit-frame-pointer"); + Check(a.VariantId() != b.VariantId(), "compile flag added perturbs VariantId"); + } + { + // target differs -> VariantId differs. + Configuration a = MakeBase(); + Configuration b = MakeBase(); + b.target = "aarch64-linux-gnu"; + Check(a.VariantId() != b.VariantId(), "target change perturbs VariantId"); + } + { + // march differs -> VariantId differs. + Configuration a = MakeBase(); + Configuration b = MakeBase(); + b.march = "x86-64-v3"; + Check(a.VariantId() != b.VariantId(), "march change perturbs VariantId"); + } + { + // PcmDir() differs between Executable (in BuildDir) and Library + // (in BinDir) — Library PCMs land in the installable bin dir so + // downstream consumers can find them. + Configuration exe = MakeBase(); + Configuration lib = MakeBase(); + lib.type = ConfigurationType::LibraryStatic; + Check(exe.PcmDir() == exe.BuildDir(), "Executable PcmDir == BuildDir"); + Check(lib.PcmDir() == lib.BinDir(), "Library PcmDir == BinDir"); + } + { + // VariantId is embedded in the path so distinct ids produce distinct dirs. + Configuration a = MakeBase(); + Configuration b = MakeBase(); + b.debug = true; + Check(a.BuildDir() != b.BuildDir(), "different VariantId yields different BuildDir"); + Check(a.BinDir() != b.BinDir(), "different VariantId yields different BinDir"); + } + + if (failures > 0) { + std::println(std::cerr, "{} assertions failed", failures); + return 1; + } + return 0; +} diff --git a/tests/WasiBrowserRuntime/main.cpp b/tests/WasiBrowserRuntime/main.cpp new file mode 100644 index 0000000..ab61d0e --- /dev/null +++ b/tests/WasiBrowserRuntime/main.cpp @@ -0,0 +1,97 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +// POSIX env helper — std::setenv is non-standard. Linux-only is fine here; +// project.cpp gates this test on x86_64-pc-linux-gnu. +extern "C" int setenv(const char* name, const char* value, int overwrite); + +// EnableWasiBrowserRuntime stamps runtime.js, a generated index.html, and a +// files.json manifest onto cfg.files so the build step copies them next to +// the .wasm. We don't run the wasm build here (avoids requiring wasi-sdk + +// the wasi-libc++ PCM rebuild on top of every test); we just verify the +// helper produced the right artifacts and registered them on the config. +int main() { + Configuration cfg; + cfg.path = fs::temp_directory_path() / "crafter-build-wasi-runtime-test"; + cfg.name = "wasi-helper"; + cfg.outputName = "wasi-helper"; + cfg.target = "wasm32-wasip1"; + cfg.type = ConfigurationType::Executable; + + std::error_code ec; + fs::remove_all(cfg.path, ec); + fs::create_directories(cfg.path); + + // EnableWasiBrowserRuntime looks up runtime.js + index.html.in under + // GetCrafterBuildHome()/wasi-runtime. The test binary lives in + // bin//, so the auto-detect walks parents and won't find a + // share/crafter-build/Crafter.Build.cppm anchor — point CRAFTER_BUILD_HOME + // at the repo root (cwd when crafter-build test runs) so it picks up + // ./wasi-runtime/. Probe both repo root and the system-installed share + // dir to also work from a packaged checkout. + fs::path repoWasi = fs::current_path() / "wasi-runtime"; + fs::path systemHome = "/usr/local/share/crafter-build"; + if (fs::exists(repoWasi / "runtime.js")) { + setenv("CRAFTER_BUILD_HOME", fs::current_path().c_str(), 1); + } else if (fs::exists(systemHome / "wasi-runtime" / "runtime.js")) { + setenv("CRAFTER_BUILD_HOME", systemHome.c_str(), 1); + } else { + std::println(std::cerr, + "wasi-runtime assets not found near {} or {}", + repoWasi.string(), systemHome.string()); + return 77; // skipped — environmental, not a code defect + } + + EnableWasiBrowserRuntime(cfg); + + // Three files registered: runtime.js, generated index.html, files.json. + if (cfg.files.size() != 3) { + std::println(std::cerr, "expected 3 cfg.files entries, got {}", cfg.files.size()); + for (const auto& f : cfg.files) std::println(std::cerr, " - {}", f.string()); + return 1; + } + + auto findByName = [&](std::string_view name) -> const fs::path* { + for (const fs::path& f : cfg.files) { + if (f.filename() == name) return &f; + } + return nullptr; + }; + + const fs::path* runtimeJs = findByName("runtime.js"); + const fs::path* indexHtml = findByName("index.html"); + const fs::path* manifest = findByName("files.json"); + + if (!runtimeJs) { std::println(std::cerr, "runtime.js not registered"); return 1; } + if (!indexHtml) { std::println(std::cerr, "index.html not registered"); return 1; } + if (!manifest) { std::println(std::cerr, "files.json not registered"); return 1; } + + // The generated index.html and manifest live under the project's build/ + // wasi-runtime// directory and must actually exist on disk. + if (!fs::exists(*indexHtml)) { + std::println(std::cerr, "index.html not produced at {}", indexHtml->string()); + return 1; + } + if (!fs::exists(*manifest)) { + std::println(std::cerr, "files.json not produced at {}", manifest->string()); + return 1; + } + + // The template's {{WASM}} placeholder should have been replaced with + // the configured outputName. + std::ifstream in(*indexHtml); + std::string html{std::istreambuf_iterator(in), std::istreambuf_iterator()}; + if (html.find("wasi-helper.wasm") == std::string::npos) { + std::println(std::cerr, "index.html missing wasi-helper.wasm reference"); + return 1; + } + if (html.find("{{WASM}}") != std::string::npos) { + std::println(std::cerr, "index.html still contains unreplaced {{WASM}} placeholder"); + return 1; + } + + fs::remove_all(cfg.path, ec); + return 0; +}