From eaee502e8c9f28eff213843051e1116ae3447a92 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Tue, 28 Apr 2026 23:24:46 +0200 Subject: [PATCH] V2: WASI, -r flag, CI pipeline, examples & tests cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=; 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 --- .forgejo/workflows/ci.yaml | 102 ++++++ PKGBUILD | 6 + README.md | 165 +++++----- build.cmd | 4 + build.sh | 4 + examples/README.md | 12 + examples/hello-world/README.md | 11 + examples/hello-world/main.cpp | 6 + examples/hello-world/project.cpp | 18 ++ examples/library/README.md | 21 ++ examples/library/main.cpp | 8 + examples/library/mylib/MyMath.cppm | 5 + examples/library/project.cpp | 37 +++ examples/tests/README.md | 54 ++++ examples/tests/mylib/MyMath.cppm | 5 + examples/tests/project.cpp | 20 ++ examples/tests/tests/Smoke/main.cpp | 8 + examples/tests/tests/UnitMyMath/main.cpp | 8 + examples/tests/tests/UnitMyMath/project.cpp | 22 ++ examples/wasi/README.md | 40 +++ examples/wasi/main.cpp | 6 + examples/wasi/project.cpp | 24 ++ examples/wasi/serve.sh | 2 + examples/with-module/README.md | 11 + .../with-module}/interfaces/Greeter.cppm | 0 examples/with-module/main.cpp | 7 + examples/with-module/project.cpp | 18 ++ implementations/Crafter.Build-Clang.cpp | 178 ++++++++++- implementations/Crafter.Build-External.cpp | 34 +- implementations/Crafter.Build-Platform.cpp | 153 ++++++++- implementations/Crafter.Build-Test.cpp | 302 ++++++++++++++++-- interfaces/Crafter.Build-Clang.cppm | 19 +- interfaces/Crafter.Build-External.cppm | 1 + interfaces/Crafter.Build-Platform.cppm | 4 + interfaces/Crafter.Build-Test.cppm | 24 +- project.cpp | 152 +-------- tests/BuildError.cpp | 46 --- tests/BuildError/BuildError.cpp | 41 +++ .../build-error => BuildError/inner}/main.cpp | 0 .../inner}/project.cpp | 0 tests/BuildError/project.cpp | 20 ++ .../CrossArchAarch64.cpp | 44 ++- .../inner}/main.cpp | 0 .../inner}/project.cpp | 1 - tests/CrossArchAarch64/project.cpp | 20 ++ .../lib/Foo.cppm | 0 .../main.cpp | 0 tests/CrossProjectModule/project.cpp | 36 +++ tests/{fixtures/defines => Defines}/main.cpp | 0 tests/Defines/project.cpp | 24 ++ tests/{fixtures/diamond => Diamond}/B/B.cppm | 0 tests/{fixtures/diamond => Diamond}/C/C.cppm | 0 tests/{fixtures/diamond => Diamond}/X/X.cppm | 0 tests/{fixtures/diamond => Diamond}/main.cpp | 0 tests/Diamond/project.cpp | 49 +++ .../hello-world => HelloWorld}/main.cpp | 0 tests/{ => Incremental}/Incremental.cpp | 51 ++- .../inner}/interfaces/Greeter.cppm | 0 .../inner}/main.cpp | 0 .../inner}/project.cpp | 0 tests/Incremental/project.cpp | 20 ++ tests/{ => Libraries}/Libraries.cpp | 27 +- .../inner}/greetlib/GreetLib.cppm | 0 .../libraries => Libraries/inner}/main.cpp | 0 .../inner}/mathlib/MathLib.cppm | 0 .../libraries => Libraries/inner}/project.cpp | 0 tests/Libraries/project.cpp | 20 ++ tests/QemuUser.cpp | 65 ---- tests/QemuUser/QemuUser.cpp | 58 ++++ .../inner}/project.cpp | 0 .../inner}/tests/Hello.cpp | 0 tests/QemuUser/project.cpp | 20 ++ tests/RunnerClassification.cpp | 61 ---- .../RunnerClassification.cpp | 37 +++ .../inner}/project.cpp | 0 .../inner}/tests/Crash.cpp | 0 .../inner}/tests/Fail.cpp | 0 .../inner}/tests/Hang.cpp | 0 .../inner}/tests/Pass.cpp | 0 tests/RunnerClassification/project.cpp | 20 ++ tests/SshRunner.cpp | 63 ---- tests/SshRunner/SshRunner.cpp | 52 +++ .../inner}/project.cpp | 0 .../inner}/tests/Hello.cpp | 0 tests/SshRunner/project.cpp | 20 ++ tests/TestUtil.h | 36 --- tests/UnitLib/main.cpp | 33 ++ tests/UnitLib/project.cpp | 21 ++ tests/Wasi/Wasi.cpp | 44 +++ tests/Wasi/inner/main.cpp | 6 + tests/Wasi/inner/project.cpp | 19 ++ tests/Wasi/project.cpp | 20 ++ tests/WindowsViaSsh.cpp | 85 ----- tests/WindowsViaSsh/WindowsViaSsh.cpp | 68 ++++ .../inner}/main.cpp | 0 .../inner}/project.cpp | 2 - tests/WindowsViaSsh/project.cpp | 20 ++ tests/WithModule/interfaces/Greeter.cppm | 6 + .../with-module => WithModule}/main.cpp | 0 tests/_shared/TestUtil.h | 76 +++++ wasi-runtime/index.html.in | 10 + wasi-runtime/runtime.js | 167 ++++++++++ 102 files changed, 2212 insertions(+), 687 deletions(-) create mode 100644 .forgejo/workflows/ci.yaml create mode 100644 examples/README.md create mode 100644 examples/hello-world/README.md create mode 100644 examples/hello-world/main.cpp create mode 100644 examples/hello-world/project.cpp create mode 100644 examples/library/README.md create mode 100644 examples/library/main.cpp create mode 100644 examples/library/mylib/MyMath.cppm create mode 100644 examples/library/project.cpp create mode 100644 examples/tests/README.md create mode 100644 examples/tests/mylib/MyMath.cppm create mode 100644 examples/tests/project.cpp create mode 100644 examples/tests/tests/Smoke/main.cpp create mode 100644 examples/tests/tests/UnitMyMath/main.cpp create mode 100644 examples/tests/tests/UnitMyMath/project.cpp create mode 100644 examples/wasi/README.md create mode 100644 examples/wasi/main.cpp create mode 100644 examples/wasi/project.cpp create mode 100755 examples/wasi/serve.sh create mode 100644 examples/with-module/README.md rename {tests/fixtures/incremental => examples/with-module}/interfaces/Greeter.cppm (100%) create mode 100644 examples/with-module/main.cpp create mode 100644 examples/with-module/project.cpp delete mode 100644 tests/BuildError.cpp create mode 100644 tests/BuildError/BuildError.cpp rename tests/{fixtures/build-error => BuildError/inner}/main.cpp (100%) rename tests/{fixtures/build-error => BuildError/inner}/project.cpp (100%) create mode 100644 tests/BuildError/project.cpp rename tests/{ => CrossArchAarch64}/CrossArchAarch64.cpp (55%) rename tests/{fixtures/cross-arch-aarch64 => CrossArchAarch64/inner}/main.cpp (100%) rename tests/{fixtures/cross-arch-aarch64 => CrossArchAarch64/inner}/project.cpp (93%) create mode 100644 tests/CrossArchAarch64/project.cpp rename tests/{fixtures/cross-project => CrossProjectModule}/lib/Foo.cppm (100%) rename tests/{fixtures/cross-project => CrossProjectModule}/main.cpp (100%) create mode 100644 tests/CrossProjectModule/project.cpp rename tests/{fixtures/defines => Defines}/main.cpp (100%) create mode 100644 tests/Defines/project.cpp rename tests/{fixtures/diamond => Diamond}/B/B.cppm (100%) rename tests/{fixtures/diamond => Diamond}/C/C.cppm (100%) rename tests/{fixtures/diamond => Diamond}/X/X.cppm (100%) rename tests/{fixtures/diamond => Diamond}/main.cpp (100%) create mode 100644 tests/Diamond/project.cpp rename tests/{fixtures/hello-world => HelloWorld}/main.cpp (100%) rename tests/{ => Incremental}/Incremental.cpp (50%) rename tests/{fixtures/with-module => Incremental/inner}/interfaces/Greeter.cppm (100%) rename tests/{fixtures/incremental => Incremental/inner}/main.cpp (100%) rename tests/{fixtures/incremental => Incremental/inner}/project.cpp (100%) create mode 100644 tests/Incremental/project.cpp rename tests/{ => Libraries}/Libraries.cpp (63%) rename tests/{fixtures/libraries => Libraries/inner}/greetlib/GreetLib.cppm (100%) rename tests/{fixtures/libraries => Libraries/inner}/main.cpp (100%) rename tests/{fixtures/libraries => Libraries/inner}/mathlib/MathLib.cppm (100%) rename tests/{fixtures/libraries => Libraries/inner}/project.cpp (100%) create mode 100644 tests/Libraries/project.cpp delete mode 100644 tests/QemuUser.cpp create mode 100644 tests/QemuUser/QemuUser.cpp rename tests/{fixtures/qemu-runner => QemuUser/inner}/project.cpp (100%) rename tests/{fixtures/qemu-runner => QemuUser/inner}/tests/Hello.cpp (100%) create mode 100644 tests/QemuUser/project.cpp delete mode 100644 tests/RunnerClassification.cpp create mode 100644 tests/RunnerClassification/RunnerClassification.cpp rename tests/{fixtures/runner-classification => RunnerClassification/inner}/project.cpp (100%) rename tests/{fixtures/runner-classification => RunnerClassification/inner}/tests/Crash.cpp (100%) rename tests/{fixtures/runner-classification => RunnerClassification/inner}/tests/Fail.cpp (100%) rename tests/{fixtures/runner-classification => RunnerClassification/inner}/tests/Hang.cpp (100%) rename tests/{fixtures/runner-classification => RunnerClassification/inner}/tests/Pass.cpp (100%) create mode 100644 tests/RunnerClassification/project.cpp delete mode 100644 tests/SshRunner.cpp create mode 100644 tests/SshRunner/SshRunner.cpp rename tests/{fixtures/ssh-runner => SshRunner/inner}/project.cpp (100%) rename tests/{fixtures/ssh-runner => SshRunner/inner}/tests/Hello.cpp (100%) create mode 100644 tests/SshRunner/project.cpp delete mode 100644 tests/TestUtil.h create mode 100644 tests/UnitLib/main.cpp create mode 100644 tests/UnitLib/project.cpp create mode 100644 tests/Wasi/Wasi.cpp create mode 100644 tests/Wasi/inner/main.cpp create mode 100644 tests/Wasi/inner/project.cpp create mode 100644 tests/Wasi/project.cpp delete mode 100644 tests/WindowsViaSsh.cpp create mode 100644 tests/WindowsViaSsh/WindowsViaSsh.cpp rename tests/{fixtures/windows-via-ssh => WindowsViaSsh/inner}/main.cpp (100%) rename tests/{fixtures/windows-via-ssh => WindowsViaSsh/inner}/project.cpp (89%) create mode 100644 tests/WindowsViaSsh/project.cpp create mode 100644 tests/WithModule/interfaces/Greeter.cppm rename tests/{fixtures/with-module => WithModule}/main.cpp (100%) create mode 100644 tests/_shared/TestUtil.h create mode 100644 wasi-runtime/index.html.in create mode 100644 wasi-runtime/runtime.js diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml new file mode 100644 index 0000000..4bb4297 --- /dev/null +++ b/.forgejo/workflows/ci.yaml @@ -0,0 +1,102 @@ +name: CI + +on: + pull_request: + branches: [master] + push: + branches: [master] + workflow_dispatch: + +jobs: + build-test-release: + runs-on: arch-latest + steps: + - name: Install build dependencies + run: | + pacman -Sy --noconfirm + pacman -S --noconfirm --needed \ + base-devel git zip tar \ + clang lld libc++ cmake \ + mingw-w64-gcc \ + wasi-libc wasi-libc++ wasi-libc++abi wasi-compiler-rt \ + nodejs + # The container runs as root and the workspace may be owned by a + # different uid; tell git not to refuse operations on it. + git config --global --add safe.directory '*' + + - name: Checkout + uses: actions/checkout@v4 + with: + # Persist the auth token so the 'Update rolling latest tag' step + # below can push the tag back via the implicit GITHUB_TOKEN. + persist-credentials: true + + - name: Cache glslang clone+build + uses: actions/cache@v4 + with: + path: | + ~/.cache/crafter.build/external + key: glslang-${{ runner.os }}-v1 + + - name: Bootstrap (build.sh) + run: ./build.sh + + - name: Self-rebuild via crafter-build (Linux) + run: CRAFTER_BUILD_HOME=$PWD/share/crafter-build ./bin/crafter-build + + - name: Run tests + run: CRAFTER_BUILD_HOME=$PWD/share/crafter-build ./bin/crafter-build test + + - name: Cross-compile for Windows (mingw32) + run: CRAFTER_BUILD_HOME=$PWD/share/crafter-build ./bin/crafter-build --target=x86_64-w64-mingw32 + + - name: Package artifacts + run: | + set -eux + mkdir -p dist + + # Linux: bin/, lib/, share/ at archive root + stage_lin=$(mktemp -d) + mkdir -p "$stage_lin/bin" "$stage_lin/lib" + cp bin/crafter-build "$stage_lin/bin/" + cp lib/libcrafter-build.a "$stage_lin/lib/" + cp -r share "$stage_lin/" + tar czf dist/crafter-build-linux-x86_64.tar.gz -C "$stage_lin" . + + # Windows: bin/ contents (exe + auto-bundled mingw DLLs) and share/ + stage_win=$(mktemp -d) + mkdir -p "$stage_win/bin" + cp bin/crafter.build-exe-x86_64-w64-mingw32-native/* "$stage_win/bin/" + cp -r share "$stage_win/" + (cd "$stage_win" && zip -r "$GITHUB_WORKSPACE/dist/crafter-build-windows-x86_64.zip" .) + + ls -la dist/ + + - name: Upload workflow artifacts + uses: actions/upload-artifact@v4 + with: + name: crafter-build + path: dist/ + if-no-files-found: error + + - name: Update rolling 'latest' tag + if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/master' + run: | + git config user.email "ci@catcrafts.net" + git config user.name "Crafter Build CI" + git tag -f latest + git push origin latest --force + + - name: Publish rolling 'latest' release + if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/master' + uses: https://code.forgejo.org/actions/forgejo-release@v2 + with: + direction: upload + url: ${{ github.server_url }} + repo: ${{ github.repository }} + tag: latest + title: Latest main build + prerelease: true + override: true + release-dir: dist + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/PKGBUILD b/PKGBUILD index 326ac2b..974e690 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -6,6 +6,9 @@ arch=('x86_64') url='https://forgejo.catcrafts.net/Catcrafts/Crafter.Build' license=('LGPL-3.0-only') depends=('clang' 'libc++' 'lld') +optdepends=('wasi-libc: WASI/WebAssembly target support' + 'wasi-libc++: C++ standard library for WASI' + 'wasi-libc++abi: C++ ABI support for WASI') makedepends=('cmake' 'git') source=() sha256sums=() @@ -24,4 +27,7 @@ package() { install -dm755 "$pkgdir/usr/share/crafter-build" install -m644 share/crafter-build/*.cppm "$pkgdir/usr/share/crafter-build/" install -m644 share/crafter-build/*.h "$pkgdir/usr/share/crafter-build/" + install -dm755 "$pkgdir/usr/share/crafter-build/wasi-runtime" + install -m644 share/crafter-build/wasi-runtime/runtime.js "$pkgdir/usr/share/crafter-build/wasi-runtime/" + install -m644 share/crafter-build/wasi-runtime/index.html.in "$pkgdir/usr/share/crafter-build/wasi-runtime/" } diff --git a/README.md b/README.md index 9082b48..77c5fab 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A C++26 modules build system. Project descriptions are written in C++ — no JSO ## Status - **Linux**: working end-to-end, packageable as a distro package. Verified on Arch Linux via [PKGBUILD](PKGBUILD) in a fresh container. -- **Windows**: working end-to-end. Bootstrap (`build.cmd`) produces `crafter-build.exe` + `crafter-build.dll` + `crafter-build.lib`; user `project.cpp` links against the import lib via the host-controlled DLL boundary. +- **Windows**: working end-to-end, including the test runner. Bootstrap (`build.cmd`) produces `crafter-build.exe` + `crafter-build.dll` + `crafter-build.lib`; user `project.cpp` links against the import lib via the host-controlled DLL boundary. `crafter-build test` runs natively on Windows hosts; cross-OS runs back to Linux via WSL2 (`--runner=wsl`). ## Quick start (Linux) @@ -50,7 +50,7 @@ extern "C" Configuration CrafterBuildProject(std::span a } ``` -Run `crafter-build` in that directory; outputs land at `bin/myapp--/myapp` and intermediates at `build/myapp--/`. +Run `crafter-build` in that directory; outputs land at `bin/myapp--/myapp` and intermediates at `build/myapp--/`. Add `-r` to run the produced executable straight after a successful build (host targets only — for cross targets use `crafter-build test --runner=...`). ## Dependencies @@ -100,115 +100,136 @@ Diamond deps (`A → {B, C}; B → X; C → X`) build `X` exactly once via a `st ## Tests -Tests are normal executables — any `int main()`, exit code `0` = pass, nonzero = fail, captured stdout+stderr is the failure message. There's no Crafter-specific entry-point convention, so a GoogleTest/Catch2/doctest binary works as-is. +Tests live under `tests//`. The simplest case is a single C++ file: -Declare tests by pushing onto `cfg.tests` in your `project.cpp`: - -```cpp -Test t; -t.config.path = "./"; -t.config.name = "VectorMath"; -t.config.outputName = "VectorMath"; -t.config.target = "x86_64-pc-linux-gnu"; -t.config.type = ConfigurationType::Executable; -std::array ifaces = {}; -std::array impls = { "tests/VectorMath" }; -t.config.GetInterfacesAndImplementations(ifaces, impls); -cfg.tests.push_back(std::move(t)); +``` +tests/Smoke/main.cpp # int main() — exit 0 = pass, nonzero = fail, 77 = skipped ``` -Each test runs in its own subprocess so a segfault doesn't take the runner down. Default timeout is 60 s per test (`--timeout=N` overrides). Tests run in parallel up to `hardware_concurrency()`; pass `--jobs=N` to override. Per-test stdout+stderr is buffered and printed atomically on completion so parallel output stays readable, and is also written to `build/test-logs/.log` when a test fails, crashes, or times out. +Run them all: ```bash -crafter-build test # build + run all tests -crafter-build test 'Vector*' # glob-filter test names -crafter-build test --list # enumerate without running -crafter-build test --jobs=1 # serial mode -crafter-build test --timeout=5 # override per-test timeout +crafter-build test ``` -Outcomes are reported as ✅ pass, ❌ fail, 💥 crash (with the signal name), or ⏱ timeout. The runner exits 0 only if every test passed. +Auto-discovery walks `tests/` one level deep. Three escalation layers: + +1. **`tests//main.cpp`** with no `project.cpp` — a Configuration is synthesized: top-level `*.cpp` files become implementations, `interfaces/*.cppm` become module interfaces, target = the run's `--target=` (host triple by default). Most tests need nothing more. +2. **`tests//project.cpp`** — full control. Use this for tests with defines, dependencies, or a non-default target. +3. Folders starting with `_` or `.` are skipped, so `tests/_shared/` can hold cross-test code without becoming a test itself. + +Test conventions: + +- **Exit codes**: `0` = ✅ pass, nonzero = ❌ fail, **`77`** = ⏭ skipped (autoconf convention; use it for runtime opt-outs like "tool not on PATH"). +- Each test runs in its own subprocess — a segfault doesn't take the runner down. +- Default timeout is 60 s per test; runs in parallel up to `hardware_concurrency()`. +- Stdout+stderr is captured and printed atomically on completion. Failed/crashed/timed-out tests also write to `build/test-logs/.log`. +- A broken fixture `project.cpp` is reported as a single failed test, not a fatal error — other tests still run. + +CLI: + +```bash +crafter-build test # run all +crafter-build test 'Unit*' # glob-filter test names +crafter-build test --list # enumerate without running +crafter-build test --target=x86_64-w64-mingw32 # only tests for this target +crafter-build test --target=x86_64-w64-mingw32 --runner=cmd:wine +crafter-build test --jobs=1 # serial mode +crafter-build test --timeout=5 # override per-test timeout +``` + +Outcomes: ✅ pass, ❌ fail, 💥 crash (with signal name), ⏱ timeout, ⏭ skipped. The runner exits 0 only if every non-skipped test passed. + +`--target=` filters the run to tests whose `cfg.target` matches; default is the host triple. Per-target tests parse `--target=...` from their own args span and build for it; tests with hardcoded targets (e.g. host-only meta-tests) are filtered out under non-matching `--target=`. ### Cross-target test runners -`Test::runner` runs the test binary somewhere other than this machine. Three factories ship today (Linux host only): +`Test::runner` runs the test binary somewhere other than this machine. Four factories: ```cpp -t.runner = TestRunner::Local(); // default -t.runner = TestRunner::QemuUser("qemu-aarch64"); // cross-arch via qemu user-mode -t.runner = TestRunner::Ssh("winvm", "/tmp/crafter-test"); // cross-OS via scp + ssh +TestRunner::Local() // run on this machine (default) +TestRunner::Cmd("wine") // local exec via prefix command +TestRunner::Ssh("hostname") // remote linux via scp + ssh +TestRunner::SshWin("winhost") // remote windows via scp + ssh-cmd.exe +TestRunner::Wsl() // Linux binaries via WSL2 (Windows host) ``` -`TestRunner::Ssh(host, remoteDir)` ships the test's whole `bin/--/` output dir via `scp -r`, runs the binary over `ssh`, then cleans up the remote temp dir. Each test invocation gets a unique remote subdir to keep parallel runs isolated. Assumes Unix-like shell on the remote (`mkdir -p`, `rm -rf`); for Windows remotes (e.g. `cmd.exe`) construct `TestRunner` directly with your own command templates — `copy`, `exec`, `cleanup` are public string fields with `{bundle}`, `{remote_bundle}`, `{bin}`, `{bin_name}`, `{args}` placeholders. +`Cmd(cmd)` is a generic ` ` prefix runner — covers wine, qemu-user, valgrind, gdbserver, anything with that shape. Probes via `which ` (or `where` on Windows hosts). The `Ssh` variants `scp -r` the test's whole bin dir, run the binary remotely, then clean up; each invocation uses a unique remote subdir so parallel runs don't collide. `Wsl()` mirrors the SSH model but copies the bundle into WSL's native filesystem (faster than executing in-place from `/mnt/c`) — useful when a Windows host wants to exercise Linux test binaries. -`TestRunner::QemuUser(qemuBin)` simply wraps ` {bin} {args}` — same machine, just a command prefix. Pair it with `t.config.target = "aarch64-linux-gnu"` (plus an aarch64 sysroot installed on the host) for cross-arch Linux testing. +For elaborate setups (rsync, ssh ControlMaster, env vars on the spawn, `QEMU_LD_PREFIX` for sysroots), construct `TestRunner` directly — `copy`/`exec`/`cleanup`/`probe` are public string fields with `{bundle}`, `{remote_bundle}`, `{bin}`, `{bin_name}`, `{args}` placeholders. -For cross-arch builds, set `cfg.sysroot` on the `Configuration` to point at a sysroot for the target. `BuildStdPcm` and the compile/link commands both consume it: `--sysroot=` is passed to clang, and `/usr/share/libc++/v1/std.cppm` is precompiled instead of the host's. +For cross-arch builds, set `cfg.sysroot` on the `Configuration` to point at a sysroot for the target. `BuildStdPcm` and the compile/link commands both consume it: `--sysroot=` is passed to clang, and `/usr/share/libc++/v1/std.cppm` is precompiled instead of the host's. The cheapest aarch64 sysroot is an extracted [Arch Linux ARM rootfs](http://os.archlinuxarm.org/) plus `libc++` + `libc++abi` + `gcc` installed into it via `pacman --root --config --dbpath /var/lib/pacman -Sy …` (point pacman at ALARM's mirrors via explicit config to avoid pulling host-arch packages). See `tests/CrossArchAarch64/inner/` for a working sysroot+qemu example. -For aarch64 specifically, the cheapest sysroot is an extracted [Arch Linux ARM rootfs](http://os.archlinuxarm.org/) plus `libc++` + `libc++abi` + `gcc` installed into it via `pacman --root --config --dbpath /var/lib/pacman -Sy …` (point pacman at ALARM's mirrors with an explicit config to avoid pulling host-arch packages). Then `cfg.sysroot = "";` plus a manual `TestRunner` whose exec template prepends `QEMU_LD_PREFIX= qemu-aarch64 …` so qemu-user finds `ld-linux-aarch64.so.1`. See `tests/fixtures/cross-arch-aarch64/` for a working example. +For `--target=x86_64-w64-mingw32` (Windows from Linux), V2's pipeline cross-compiles as long as `mingw-w64` is installed. Pair it with `--runner=cmd:wine` (no VM needed) or `--runner=sshwin:winhost` (real Windows). Full MSVC ABI (`x86_64-pc-windows-msvc`) cross-compile from Linux is not yet wired; use MinGW for now. `-fuse-ld=lld` is auto-injected on every link, and `-lstdc++exp` is auto-linked for the mingw target so user projects don't need to declare those flags. -For `t.config.target = "x86_64-w64-mingw32"` (Windows from Linux) — V2's existing build pipeline cross-compiles on Linux as long as `mingw-w64` is installed. Combine with `TestRunner::Ssh("winvm", ...)` for end-to-end Windows-target testing from a Linux host. Full MSVC ABI (`x86_64-pc-windows-msvc`) cross-compile from Linux is not yet wired; use MinGW for now. +### Configuring runners -#### Global config: `TestRunner::FromEnv` +Two layers, CLI wins over env, both override the per-test default of `Local()`: -To avoid hardcoding hosts in every project's `project.cpp`, use `TestRunner::FromEnv(target)`: - -```cpp -t.runner = TestRunner::FromEnv(t.config.target); -``` - -This reads the env var `CRAFTER_BUILD_RUNNER_` (target triple with `-` and `.` replaced by `_`). Set it once per shell or in CI: +**Env var** (persistent, per shell or in CI): ```bash -export CRAFTER_BUILD_RUNNER_x86_64_w64_mingw32=ssh:winvm:/tmp/crafter-test -export CRAFTER_BUILD_RUNNER_aarch64_linux_gnu=qemu:qemu-aarch64 +export CRAFTER_BUILD_RUNNER_x86_64_w64_mingw32=cmd:wine +export CRAFTER_BUILD_RUNNER_aarch64_linux_gnu=cmd:qemu-aarch64 +crafter-build test --target=x86_64-w64-mingw32 # uses cmd:wine ``` -Spec grammar: `local`, `qemu:`, `ssh:`, `ssh::`, `sshwin:`, `sshwin::` (cmd.exe-shell variant for Windows remotes). Unset → falls back to the second arg of `FromEnv` (defaults to `Local()`). +The variable name is `CRAFTER_BUILD_RUNNER_` (target triple with `-` and `.` replaced by `_`). -For anything more elaborate (rsync, ssh ControlMaster, custom flags, env vars on the spawn, `QEMU_LD_PREFIX` for sysroots) — construct `TestRunner` manually with your own templates. The factories are sugar over the same fields. +**CLI** (per invocation, scoped to `--target=`): -Not yet supported: WSL2 / Windows-host execution (the runner currently requires a Linux host because `RunCommandWithTimeout` is Linux-only), MSVC ABI cross-compile from Linux. +```bash +crafter-build test --target=x86_64-w64-mingw32 --runner=cmd:wine +``` -#### Skipping unreachable runners +Spec grammar: -`TestRunner` carries a `probe` command that runs once per `RunTests` invocation (cached by `runner.name`); if it exits non-zero, every Test using that runner reports as `⏭ Skipped: runner '' not available` and the suite continues with everything else. The factories set sensible defaults: `Ssh`/`SshWin` probe via `ssh -q -o BatchMode=yes -o ConnectTimeout=5 …`, `QemuUser` via `which `, `Local` is unconditionally available. +``` +local +cmd: # cmd:wine, cmd:qemu-aarch64, cmd:valgrind, ... +ssh:[:] +sshwin:[:] # cmd.exe-shell variant for Windows remotes +wsl[:] # Windows host -> Linux binaries via WSL2 +``` -Effect: if you don't have a Windows VM at all, you don't have to do anything — the Windows variants in the in-tree suite skip cleanly and the Linux variants pass. If you do have one, set up an SSH host alias matching what the project.cpp expects (in this repo: `winvm` in `~/.ssh/config`) and the Windows variants run automatically. Skipped tests count toward the summary but do not fail the suite (`AllPassed()` returns true on skip-only runs). +Each runner carries a `probe` command (e.g. `which wine`, `ssh -BatchMode=yes true`); if it fails, every test using that runner is reported as ⏭ skipped and the rest of the suite continues. Skipped tests count toward the summary but don't fail the suite (`AllPassed()` returns true on skip-only runs). -#### Per-(target, runner) tests +### Linking the parent project's library -The 5 simple tests `HelloWorld`, `WithModule`, `Defines`, `CrossProjectModule`, `Diamond` are declared **directly** in the project's `project.cpp`, one entry per `(target, runner)` pair: +A test fixture can depend on the project's own library so it can `import` and exercise its API: ```cpp -std::vector targets = { - { "x86_64-pc-linux-gnu", TestRunner::Local(), {} }, - { "x86_64-w64-mingw32", TestRunner::SshWin("winvm", "C:/temp/crafter-tests"), {"-lstdc++exp"} }, -}; +// tests/UnitFoo/project.cpp +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "tests/UnitFoo/"; + cfg.name = "UnitFoo"; + cfg.outputName = "UnitFoo"; + cfg.target = "x86_64-pc-linux-gnu"; + cfg.type = ConfigurationType::Executable; + cfg.dependencies = { ParentLib("MyLib") }; // <-- the magic line + + std::array ifaces = {}; + std::array impls = { "main" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +} ``` -So `./bin/crafter-build test` (no env vars) spawns one Test per `(target, runner)` pair, all in one report. With the `winvm` SSH alias reachable: +`ParentLib(name)` walks the parent project's dependency graph (and the parent itself) to find a `Configuration*` by `Configuration::name`. The build links it into the test exe, so `import MyLib;` resolves naturally. See [examples/tests/](examples/tests/) for the complete worked example. -``` -✅ HelloWorld (5ms) -✅ HelloWorld (sshwin:winvm) (2348ms) -✅ WithModule (3ms) -✅ WithModule (sshwin:winvm) (2204ms) -… -18 passed -``` +## Examples -Without `winvm` reachable, the SshWin variants skip: +[examples/](examples/) contains progressively larger self-contained projects: -``` -✅ HelloWorld (5ms) -⏭ HelloWorld (sshwin:winvm) skipped: runner 'sshwin:winvm' not available -… -``` +| Example | Shows | +|---|---| +| [hello-world](examples/hello-world/) | Smallest possible project: `project.cpp` + `main.cpp` | +| [with-module](examples/with-module/) | Adding a C++ module interface | +| [library](examples/library/) | Library + executable, linked via `cfg.dependencies` | +| [tests](examples/tests/) | Auto-discovered tests + tests linking the parent library | -Local runner emits no parenthetical (existing default for the common case); cross-target runners show their name (e.g. `sshwin:winvm`, `qemu:qemu-aarch64`) so you see at a glance which environment ran each test. - -The 8 outer-driver tests (Incremental, BuildError, RunnerClassification, QemuUser, SshRunner, WindowsViaSsh, CrossArchAarch64, Libraries) stay one-line in the report because they're meta-tests — the test driver is the test, validating host-side build behavior or runner integration. They're not multi-target. +Each builds standalone: `cd examples/ && crafter-build`. ## Architecture diff --git a/build.cmd b/build.cmd index 10e6a6f..4ca107e 100644 --- a/build.cmd +++ b/build.cmd @@ -3,6 +3,10 @@ mkdir build 2>nul mkdir bin 2>nul mkdir lib 2>nul mkdir share\crafter-build 2>nul +mkdir share\crafter-build\wasi-runtime 2>nul + +copy /Y wasi-runtime\runtime.js share\crafter-build\wasi-runtime\ +copy /Y wasi-runtime\index.html.in share\crafter-build\wasi-runtime\ copy /Y interfaces\Crafter.Build.cppm share\crafter-build\ copy /Y interfaces\Crafter.Build-Shader.cppm share\crafter-build\ diff --git a/build.sh b/build.sh index 366349f..b93c2ee 100755 --- a/build.sh +++ b/build.sh @@ -2,6 +2,10 @@ mkdir -p build mkdir -p bin mkdir -p lib mkdir -p share/crafter-build +mkdir -p share/crafter-build/wasi-runtime + +cp wasi-runtime/runtime.js share/crafter-build/wasi-runtime/ +cp wasi-runtime/index.html.in share/crafter-build/wasi-runtime/ cp interfaces/Crafter.Build.cppm share/crafter-build/ cp interfaces/Crafter.Build-Shader.cppm share/crafter-build/ diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..d81c9f1 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,12 @@ +# Examples + +Self-contained projects, ordered from simplest to most full-featured. Each example has its own README explaining what it shows. + +| Example | What it shows | +|---|---| +| [hello-world](hello-world/) | The minimum: `project.cpp` + `main.cpp` | +| [with-module](with-module/) | Adding a C++ module interface | +| [library](library/) | Library + executable, linked via `cfg.dependencies` | +| [tests](tests/) | Auto-discovered tests + tests that link the parent library | + +Each example builds standalone: `cd examples/ && crafter-build`. Test examples: `crafter-build test`. diff --git a/examples/hello-world/README.md b/examples/hello-world/README.md new file mode 100644 index 0000000..8a21e0b --- /dev/null +++ b/examples/hello-world/README.md @@ -0,0 +1,11 @@ +# hello-world + +The smallest possible Crafter project: one `main.cpp`, one `project.cpp`. + +```sh +cd examples/hello-world +crafter-build +./bin/hello-x86_64-pc-linux-gnu-native/hello +``` + +`project.cpp` returns a `Configuration` describing the build. `GetInterfacesAndImplementations` takes lists of source-file *stems* (no extension) — the build system appends `.cpp` to implementations and `.cppm` to interfaces. diff --git a/examples/hello-world/main.cpp b/examples/hello-world/main.cpp new file mode 100644 index 0000000..3d87f76 --- /dev/null +++ b/examples/hello-world/main.cpp @@ -0,0 +1,6 @@ +import std; + +int main() { + std::println("hello from crafter-build"); + return 0; +} diff --git a/examples/hello-world/project.cpp b/examples/hello-world/project.cpp new file mode 100644 index 0000000..271bfb9 --- /dev/null +++ b/examples/hello-world/project.cpp @@ -0,0 +1,18 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "hello"; + cfg.outputName = "hello"; + cfg.target = "x86_64-pc-linux-gnu"; + cfg.type = ConfigurationType::Executable; + + std::array ifaces = {}; + std::array impls = { "main" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +} diff --git a/examples/library/README.md b/examples/library/README.md new file mode 100644 index 0000000..e25f19a --- /dev/null +++ b/examples/library/README.md @@ -0,0 +1,21 @@ +# library + +A static library + an executable that consumes it. + +```sh +cd examples/library +crafter-build +./bin/math-app-x86_64-pc-linux-gnu-native/math-app +``` + +Layout: + +``` +mylib/MyMath.cppm # the library's module interface +main.cpp # the consumer +project.cpp # declares both, with cfg.dependencies wiring them +``` + +Two `Configuration` objects: a `LibraryStatic` and an `Executable` whose `dependencies` points at the lib. The build runs them in parallel and links `libMyMath.a` into `math-app` automatically. `import MyMath;` in `main.cpp` resolves through the dep graph — no header paths to hand-wire. + +The `static unique_ptr` for the lib is the canonical lifetime pattern: the parent's `dependencies` is `vector` (raw), so something needs to keep the lib alive across the function return. File-local `static` does it cleanly without a global pool. diff --git a/examples/library/main.cpp b/examples/library/main.cpp new file mode 100644 index 0000000..7f9c0b3 --- /dev/null +++ b/examples/library/main.cpp @@ -0,0 +1,8 @@ +import std; +import MyMath; + +int main() { + std::println("Square(5) = {}", Square(5)); + std::println("Cube(3) = {}", Cube(3)); + return 0; +} diff --git a/examples/library/mylib/MyMath.cppm b/examples/library/mylib/MyMath.cppm new file mode 100644 index 0000000..2dfc98f --- /dev/null +++ b/examples/library/mylib/MyMath.cppm @@ -0,0 +1,5 @@ +export module MyMath; +import std; + +export int Square(int x) { return x * x; } +export int Cube(int x) { return x * x * x; } diff --git a/examples/library/project.cpp b/examples/library/project.cpp new file mode 100644 index 0000000..1335390 --- /dev/null +++ b/examples/library/project.cpp @@ -0,0 +1,37 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + // The library — built into mylib/bin/MyMath--/libMyMath.a + // and exposed to consumers via cfg.dependencies. The static unique_ptr + // keeps the Configuration alive for the duration of the build (the + // consumer holds a raw pointer to it). + static auto lib = std::make_unique(); + lib->path = "./mylib/"; + lib->name = "MyMath"; + lib->outputName = "MyMath"; + lib->target = "x86_64-pc-linux-gnu"; + lib->type = ConfigurationType::LibraryStatic; + { + std::array ifaces = { "MyMath" }; + std::array impls = {}; + lib->GetInterfacesAndImplementations(ifaces, impls); + } + + // The consumer — main.cpp imports MyMath. The build resolves the import + // through cfg.dependencies and links libMyMath.a automatically. + Configuration cfg; + cfg.path = "./"; + cfg.name = "math-app"; + cfg.outputName = "math-app"; + cfg.target = "x86_64-pc-linux-gnu"; + cfg.type = ConfigurationType::Executable; + cfg.dependencies = { lib.get() }; + + std::array ifaces = {}; + std::array impls = { "main" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +} diff --git a/examples/tests/README.md b/examples/tests/README.md new file mode 100644 index 0000000..5ff7d84 --- /dev/null +++ b/examples/tests/README.md @@ -0,0 +1,54 @@ +# tests + +Two ways to write tests, in one project. + +```sh +cd examples/tests +crafter-build test +``` + +Layout: + +``` +mylib/MyMath.cppm # the library being tested +project.cpp # declares the library + +tests/Smoke/main.cpp # zero-config test (no project.cpp) +tests/UnitMyMath/main.cpp # test that links MyMath and exercises it +tests/UnitMyMath/project.cpp # required for tests with deps +``` + +## Auto-discovery + +Each `tests//` directory becomes a test. Three layers, escalate only as needed: + +1. **`tests//main.cpp`** with no `project.cpp` — discovery synthesizes a Configuration. Top-level `*.cpp` files become implementations, `interfaces/*.cppm` become module interfaces. `Smoke` is this case. +2. **`tests//project.cpp`** — full control. Use this when you need defines, dependencies, or non-default targets. `UnitMyMath` is this case (it depends on `MyMath`). +3. Folders starting with `_` or `.` are skipped (e.g. `tests/_shared/` for cross-test helpers). + +## Test conventions + +- Exit code `0` = pass, anything nonzero = fail, **`77` = skipped** (autoconf convention). Use `std::exit(77)` for runtime skips like "tool not on PATH". +- Each test runs in its own subprocess; a segfault doesn't take down the runner. +- Default timeout is 60 s (`crafter-build test --timeout=N` overrides). +- Filter by name: `crafter-build test 'Unit*'`. List without running: `crafter-build test --list`. + +## Linking the parent project + +`UnitMyMath/project.cpp` shows how a test links the project's own library: + +```cpp +cfg.dependencies = { ParentLib("MyMath") }; +``` + +`ParentLib("name")` looks up a `Configuration*` in the parent project (the root project's own config + its dependency graph) by `Configuration::name`. The fixture's project.cpp can omit `cfg.path`, `cfg.name`, etc. — the discovery loop fills folder-derived defaults. + +## Cross-target test runs + +Tests parse `--target=...` from the project args you pass on the command line: + +```sh +crafter-build test --target=x86_64-w64-mingw32 --runner=cmd:wine +``` + +`--runner=` overrides the per-target runner for this invocation. Useful specs: `local`, `cmd:` (prefix-exec, e.g. `cmd:wine`, `cmd:qemu-aarch64`), `ssh:[:]`, `sshwin:[:]`. Or persist via env var: `CRAFTER_BUILD_RUNNER_=`. diff --git a/examples/tests/mylib/MyMath.cppm b/examples/tests/mylib/MyMath.cppm new file mode 100644 index 0000000..2dfc98f --- /dev/null +++ b/examples/tests/mylib/MyMath.cppm @@ -0,0 +1,5 @@ +export module MyMath; +import std; + +export int Square(int x) { return x * x; } +export int Cube(int x) { return x * x * x; } diff --git a/examples/tests/project.cpp b/examples/tests/project.cpp new file mode 100644 index 0000000..58661ae --- /dev/null +++ b/examples/tests/project.cpp @@ -0,0 +1,20 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +// A pure library project: the root Configuration is the lib itself. Tests +// under tests/ are auto-discovered (see tests/Smoke and tests/UnitMyMath). +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "./mylib/"; + cfg.name = "MyMath"; + cfg.outputName = "MyMath"; + cfg.target = "x86_64-pc-linux-gnu"; + cfg.type = ConfigurationType::LibraryStatic; + + std::array ifaces = { "MyMath" }; + std::array impls = {}; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +} diff --git a/examples/tests/tests/Smoke/main.cpp b/examples/tests/tests/Smoke/main.cpp new file mode 100644 index 0000000..483a103 --- /dev/null +++ b/examples/tests/tests/Smoke/main.cpp @@ -0,0 +1,8 @@ +// Single-file test: no project.cpp needed. The folder name "Smoke" becomes +// the test name. Exit 0 = pass, anything else = fail, exit 77 = skipped. +import std; + +int main() { + if (1 + 1 != 2) return 1; + return 0; +} diff --git a/examples/tests/tests/UnitMyMath/main.cpp b/examples/tests/tests/UnitMyMath/main.cpp new file mode 100644 index 0000000..fcc309b --- /dev/null +++ b/examples/tests/tests/UnitMyMath/main.cpp @@ -0,0 +1,8 @@ +import std; +import MyMath; + +int main() { + if (Square(5) != 25) return 1; + if (Cube(3) != 27) return 1; + return 0; +} diff --git a/examples/tests/tests/UnitMyMath/project.cpp b/examples/tests/tests/UnitMyMath/project.cpp new file mode 100644 index 0000000..935382a --- /dev/null +++ b/examples/tests/tests/UnitMyMath/project.cpp @@ -0,0 +1,22 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +// Test that links the parent project's library so it can `import MyMath;` +// and call its functions. ParentLib(name) walks the parent project's +// dependency graph (and the parent itself) to find a Configuration by name. +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "tests/UnitMyMath/"; + cfg.name = "UnitMyMath"; + cfg.outputName = "UnitMyMath"; + cfg.target = "x86_64-pc-linux-gnu"; + cfg.type = ConfigurationType::Executable; + cfg.dependencies = { ParentLib("MyMath") }; + + std::array ifaces = {}; + std::array impls = { "main" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +} diff --git a/examples/wasi/README.md b/examples/wasi/README.md new file mode 100644 index 0000000..7ac0f42 --- /dev/null +++ b/examples/wasi/README.md @@ -0,0 +1,40 @@ +# WASI + +Build a C++26 program for the `wasm32-wasi` target and run it in a browser. + +## Prerequisites + +- `wasi-libc`, `wasi-libc++`, `wasi-libc++abi` (Arch packages — provide + `/usr/share/wasi-sysroot/`, which `crafter-build` finds automatically). + Other distros: install the WASI SDK and set `cfg.sysroot` in `project.cpp`. + +## Build + +```bash +crafter-build +``` + +Output lands in `bin/wasi-hello-wasm32-wasip1-native/`: + +- `wasi-hello.wasm` — the compiled module +- `index.html` + `runtime.js` — a minimal in-browser WASI shim, dropped in by + `EnableWasiBrowserRuntime(cfg)` in `project.cpp`. Stdout goes to the browser + console. + +## Run in a browser + +```bash +./serve.sh # any static file server works +``` + +Open `http://localhost:8080/`. The string from `main()` shows up in the +DevTools console. + +## Run in a standalone runtime + +If you don't need the browser, drop the `EnableWasiBrowserRuntime(cfg)` line +from `project.cpp` and run the `.wasm` directly: + +```bash +wasmtime bin/wasi-hello-wasm32-wasip1-native/wasi-hello.wasm +``` diff --git a/examples/wasi/main.cpp b/examples/wasi/main.cpp new file mode 100644 index 0000000..7eb35d2 --- /dev/null +++ b/examples/wasi/main.cpp @@ -0,0 +1,6 @@ +import std; + +int main() { + std::println("Hello, WASI!"); + return 0; +} diff --git a/examples/wasi/project.cpp b/examples/wasi/project.cpp new file mode 100644 index 0000000..6c77c0b --- /dev/null +++ b/examples/wasi/project.cpp @@ -0,0 +1,24 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "wasi-hello"; + cfg.outputName = "wasi-hello"; + cfg.target = "wasm32-wasip1"; + cfg.type = ConfigurationType::Executable; + + std::array ifaces = {}; + std::array impls = { "main" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + + // Drop a small index.html + runtime.js next to the .wasm so a static file + // server is enough to run it in the browser. Skip this call when targeting + // a standalone runtime like wasmtime — you don't need the shim then. + EnableWasiBrowserRuntime(cfg); + + return cfg; +} diff --git a/examples/wasi/serve.sh b/examples/wasi/serve.sh new file mode 100755 index 0000000..2f99695 --- /dev/null +++ b/examples/wasi/serve.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +caddy file-server --listen :8080 --root bin/wasi-hello-wasm32-wasip1-native \ No newline at end of file diff --git a/examples/with-module/README.md b/examples/with-module/README.md new file mode 100644 index 0000000..171a274 --- /dev/null +++ b/examples/with-module/README.md @@ -0,0 +1,11 @@ +# with-module + +Adds a C++ module interface (`interfaces/Greeter.cppm`) imported by `main.cpp`. + +```sh +cd examples/with-module +crafter-build +./bin/greeter-app-x86_64-pc-linux-gnu-native/greeter-app +``` + +Interfaces are listed by their stem path (relative to `cfg.path`, no `.cppm` extension). The build emits `Greeter.pcm` once and `main_impl.o` consumes it. Touching `interfaces/Greeter.cppm` causes only `main_impl.o` to recompile, not the whole world. diff --git a/tests/fixtures/incremental/interfaces/Greeter.cppm b/examples/with-module/interfaces/Greeter.cppm similarity index 100% rename from tests/fixtures/incremental/interfaces/Greeter.cppm rename to examples/with-module/interfaces/Greeter.cppm diff --git a/examples/with-module/main.cpp b/examples/with-module/main.cpp new file mode 100644 index 0000000..902ba27 --- /dev/null +++ b/examples/with-module/main.cpp @@ -0,0 +1,7 @@ +import std; +import Greeter; + +int main() { + std::println("{}", Greet("world")); + return 0; +} diff --git a/examples/with-module/project.cpp b/examples/with-module/project.cpp new file mode 100644 index 0000000..ed9c285 --- /dev/null +++ b/examples/with-module/project.cpp @@ -0,0 +1,18 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "greeter-app"; + cfg.outputName = "greeter-app"; + cfg.target = "x86_64-pc-linux-gnu"; + cfg.type = ConfigurationType::Executable; + + std::array ifaces = { "interfaces/Greeter" }; + std::array impls = { "main" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +} diff --git a/implementations/Crafter.Build-Clang.cpp b/implementations/Crafter.Build-Clang.cpp index 668e8b4..910c2a3 100644 --- a/implementations/Crafter.Build-Clang.cpp +++ b/implementations/Crafter.Build-Clang.cpp @@ -197,6 +197,40 @@ void Configuration::GetInterfacesAndImplementations(std::span interfac } BuildResult Crafter::Build(Configuration& config, std::unordered_map>& 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 resetSeen; + std::function 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 { + 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 + // 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 options) { +std::string ConfigureCMake(const fs::path& cloneDir, const fs::path& cmakeBuildDir, std::span 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& 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{}(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; } diff --git a/implementations/Crafter.Build-Platform.cpp b/implementations/Crafter.Build-Platform.cpp index e6c84d5..e0fc8d1 100644 --- a/implementations/Crafter.Build-Platform.cpp +++ b/implementations/Crafter.Build-Platform.cpp @@ -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 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(std::min( + static_cast(timeout.count()) * 1000LL, + static_cast(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(exit); + } + result.exitCode = static_cast(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/share/libc++/v1/, the rest of + // the libc++ ecosystem (e.g. /opt/aarch64-rootfs) follows FHS at + // /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 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 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 g_parentProject{nullptr}; + + Configuration* FindLibInTree(Configuration* root, + std::string_view name, + std::unordered_set& 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 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//... 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(std::tolower(static_cast(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::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 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// 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 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 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 projectArgs) { TestSummary summary; + std::vector discoveryFailures; + + // Auto-discover tests one layer deep: each /tests// 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 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= 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 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 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; diff --git a/interfaces/Crafter.Build-Clang.cppm b/interfaces/Crafter.Build-Clang.cppm index 5265127..c43c47a 100644 --- a/interfaces/Crafter.Build-Clang.cppm +++ b/interfaces/Crafter.Build-Clang.cppm @@ -48,6 +48,12 @@ export namespace Crafter { struct Test; struct TestRunner { + // Quoting style for {args} when this runner is used. Args reach + // whichever shell the runner ultimately delivers them to: Host for + // Local + Cmd-prefix (host shell parses), Sh for Ssh (remote bash), + // Cmd for SshWin (remote cmd.exe). + enum class Shell { Host, Sh, Cmd }; + std::string copy; std::string exec; std::string cleanup; @@ -57,13 +63,16 @@ export namespace Crafter { // is available; non-zero = skip every Test using this runner with a // "runner unavailable" message. Empty = always available (e.g., Local). std::string probe; + Shell argsShell = Shell::Host; bool IsLocal() const { return exec.empty(); } static CRAFTER_API TestRunner Local(); static CRAFTER_API TestRunner Ssh(std::string host, std::string remoteDir = "/tmp/crafter-tests"); static CRAFTER_API TestRunner SshWin(std::string host, std::string remoteDir = "C:/temp/crafter-tests"); - static CRAFTER_API TestRunner QemuUser(std::string qemuBin); + static CRAFTER_API TestRunner Wsl(std::string remoteDir = "/tmp/crafter-tests-wsl"); + static CRAFTER_API TestRunner Cmd(std::string command); + static CRAFTER_API std::optional FromSpec(std::string_view spec); static CRAFTER_API TestRunner FromEnv(std::string_view target, TestRunner fallback = Local()); }; @@ -118,4 +127,12 @@ export namespace Crafter { CRAFTER_API BuildResult Build(Configuration& config, std::unordered_map>& depResults, std::mutex& depMutex); CRAFTER_API int Run(int argc, char** argv); + + // Add a small index.html + runtime.js pair next to the .wasm output so the + // build can be loaded directly in a browser (just `serve` the bin dir and + // open it). Opt-in: WASI builds destined for wasmtime/wasmer don't need + // this and shouldn't carry the extra files. Call from project.cpp after + // outputName is set; index.html is generated against the current + // outputName so renaming the binary later requires another call. + CRAFTER_API void EnableWasiBrowserRuntime(Configuration& cfg); } \ No newline at end of file diff --git a/interfaces/Crafter.Build-External.cppm b/interfaces/Crafter.Build-External.cppm index df4e191..70379fb 100644 --- a/interfaces/Crafter.Build-External.cppm +++ b/interfaces/Crafter.Build-External.cppm @@ -53,5 +53,6 @@ export namespace Crafter { CRAFTER_API ExternalBuildResult BuildExternal( const ExternalDependency& dep, + std::string_view target, std::atomic& cancelled); } diff --git a/interfaces/Crafter.Build-Platform.cppm b/interfaces/Crafter.Build-Platform.cppm index e50cecb..5103f5b 100644 --- a/interfaces/Crafter.Build-Platform.cppm +++ b/interfaces/Crafter.Build-Platform.cppm @@ -39,4 +39,8 @@ namespace Crafter { export CRAFTER_API CommandResult RunCommandWithTimeout(std::string_view command, std::chrono::seconds timeout); std::string GetBaseCommand(const Configuration& config); export CRAFTER_API Configuration LoadProject(const fs::path& projectFile, std::span args); + // Resolves the directory holding distributed runtime assets (Crafter.Build + // module sources, wasi-runtime/, etc). Honors CRAFTER_BUILD_HOME; otherwise + // derives /share/crafter-build from the running executable's path. + export CRAFTER_API fs::path GetCrafterBuildHome(); } \ No newline at end of file diff --git a/interfaces/Crafter.Build-Test.cppm b/interfaces/Crafter.Build-Test.cppm index 72a234f..5e630ba 100644 --- a/interfaces/Crafter.Build-Test.cppm +++ b/interfaces/Crafter.Build-Test.cppm @@ -29,6 +29,19 @@ export namespace Crafter { int jobs = 0; std::optional timeoutOverride; bool listOnly = false; + // Only tests whose Configuration::target equals targetFilter are run. + // Set from --target=... (host triple if unspecified). Tests for other + // targets are silently excluded so e.g. `--target=mingw` doesn't drag + // in host-only outer-driver tests. +#ifdef _WIN32 + std::string targetFilter = "x86_64-pc-windows-msvc"; +#else + std::string targetFilter = "x86_64-pc-linux-gnu"; +#endif + // CLI override for --runner=: applies to every test in the run. + // Target scoping is unnecessary because targetFilter ensures the run + // contains only one target's tests. + std::optional runnerOverride; }; struct TestSummary { @@ -41,6 +54,15 @@ export namespace Crafter { bool AllPassed() const { return failed == 0 && crashed == 0 && timedOut == 0; } }; - CRAFTER_API TestSummary RunTests(Configuration& projectCfg, const RunTestsOptions& opts); + CRAFTER_API TestSummary RunTests(Configuration& projectCfg, const RunTestsOptions& opts, std::span projectArgs = {}); CRAFTER_API TestResult RunSingleTest(const Test& test, const std::filesystem::path& binary, std::chrono::seconds timeout); + + // Parent-project access for test fixtures. Run() sets the pointer to the + // root project's Configuration before discovery; fixtures call ParentLib() + // to obtain a Configuration* for one of the parent's libraries (or the + // parent itself) so they can declare it as a dependency without rebuilding + // the lib config from scratch. Lookup is by Configuration::name and + // recurses through the parent's dependency graph. + CRAFTER_API void SetParentProject(Configuration* parent); + CRAFTER_API Configuration* ParentLib(std::string_view name); } diff --git a/project.cpp b/project.cpp index 2bd6ce6..af5b272 100644 --- a/project.cpp +++ b/project.cpp @@ -5,15 +5,17 @@ using namespace Crafter; extern "C" Configuration CrafterBuildProject(std::span args) { bool debug = false; + std::string target = "x86_64-pc-linux-gnu"; for (std::string_view arg : args) { if (arg == "--debug") debug = true; + else if (arg.starts_with("--target=")) target = std::string(arg.substr(std::string_view("--target=").size())); } static auto crafterBuildLib = std::make_unique(); crafterBuildLib->path = "./"; crafterBuildLib->name = "crafter.build-lib"; crafterBuildLib->outputName = "crafter-build"; - crafterBuildLib->target = "x86_64-pc-linux-gnu"; + crafterBuildLib->target = target; crafterBuildLib->type = ConfigurationType::LibraryStatic; crafterBuildLib->debug = debug; { @@ -44,6 +46,12 @@ extern "C" Configuration CrafterBuildProject(std::span a glslang.source.branch = "main"; glslang.builder = ExternalBuilder::CMake; glslang.options = { "-DENABLE_OPT=OFF" }; + // mingw cross-build: skip the standalone executable. We only consume the + // libraries, and glslang.exe pulls in libgcc_eh which needs pthread that + // mingw-w64 doesn't link by default. + if (target == "x86_64-w64-mingw32") { + glslang.options.push_back("-DENABLE_GLSLANG_BINARIES=OFF"); + } glslang.includeDirs = { "" }; glslang.libs = { "SPIRV", "GenericCodeGen", "glslang", "OSDependent", "MachineIndependent", "glslang-default-resource-limits" }; @@ -51,7 +59,7 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.path = "./"; cfg.name = "crafter.build-exe"; cfg.outputName = "crafter-build"; - cfg.target = "x86_64-pc-linux-gnu"; + cfg.target = target; cfg.type = ConfigurationType::Executable; cfg.debug = debug; cfg.dependencies = { crafterBuildLib.get() }; @@ -60,142 +68,10 @@ extern "C" Configuration CrafterBuildProject(std::span a std::array implementations = { "implementations/main" }; cfg.GetInterfacesAndImplementations(interfaces, implementations); } - cfg.linkFlags.push_back("-Wl,--export-dynamic"); - cfg.linkFlags.push_back("-ldl"); - - // ----- Single-driver, multi-target tests (each appears once per (target, runner)). ----- - - // Lifetime holders for per-target lib Configurations referenced by Tests. - static std::vector> testLibPool; - - struct TargetRunner { - std::string target; - TestRunner runner; - std::vector extraLinkFlags; - }; - std::vector targets = { - { "x86_64-pc-linux-gnu", TestRunner::Local(), {} }, - { "x86_64-w64-mingw32", TestRunner::SshWin("winvm", "C:/temp/crafter-tests"), {"-lstdc++exp"} }, - }; - - auto addPerTarget = [&](std::string name, auto buildOne) { - for (auto& tr : targets) { - Test t; - t.config.name = name; - t.config.target = tr.target; - t.config.type = ConfigurationType::Executable; - t.config.linkFlags.push_back("-fuse-ld=lld"); - for (auto& f : tr.extraLinkFlags) t.config.linkFlags.push_back(f); - buildOne(t, tr.target); - t.runner = tr.runner; - cfg.tests.push_back(std::move(t)); - } - }; - - addPerTarget("HelloWorld", [](Test& t, std::string_view) { - t.config.path = "tests/fixtures/hello-world/"; - t.config.outputName = "hello"; - std::array ifaces = {}; - std::array impls = { "main" }; - t.config.GetInterfacesAndImplementations(ifaces, impls); - }); - - addPerTarget("WithModule", [](Test& t, std::string_view) { - t.config.path = "tests/fixtures/with-module/"; - t.config.outputName = "hello-mod"; - std::array ifaces = { "interfaces/Greeter" }; - std::array impls = { "main" }; - t.config.GetInterfacesAndImplementations(ifaces, impls); - }); - - addPerTarget("Defines", [](Test& t, std::string_view) { - t.config.path = "tests/fixtures/defines/"; - t.config.outputName = "defines-app"; - std::array ifaces = {}; - std::array impls = { "main" }; - t.config.GetInterfacesAndImplementations(ifaces, impls); - t.config.defines.push_back({"CRAFTER_TEST_FOO", "42"}); - }); - - addPerTarget("CrossProjectModule", [&](Test& t, std::string_view target) { - auto fooLib = std::make_unique(); - fooLib->path = "tests/fixtures/cross-project/lib/"; - fooLib->name = std::format("Foo-cross-project-{}", target); - fooLib->outputName = "Foo"; - fooLib->target = std::string(target); - fooLib->type = ConfigurationType::LibraryStatic; - std::array libIfaces = { "Foo" }; - std::array libImpls = {}; - fooLib->GetInterfacesAndImplementations(libIfaces, libImpls); - Configuration* fooLibPtr = fooLib.get(); - testLibPool.push_back(std::move(fooLib)); - - t.config.path = "tests/fixtures/cross-project/"; - t.config.outputName = "cross-app"; - t.config.dependencies = { fooLibPtr }; - std::array mainIfaces = {}; - std::array mainImpls = { "main" }; - t.config.GetInterfacesAndImplementations(mainIfaces, mainImpls); - }); - - auto makeDiamondLib = [&](std::string_view dir, std::string_view modName, - std::string_view target, std::span deps) { - auto lib = std::make_unique(); - lib->path = std::format("tests/fixtures/diamond/{}/", dir); - lib->name = std::format("{}-diamond-{}", modName, target); - lib->outputName = std::string(modName); - lib->target = std::string(target); - lib->type = ConfigurationType::LibraryStatic; - lib->dependencies.assign(deps.begin(), deps.end()); - std::array ifaces = { fs::path(modName) }; - std::array impls = {}; - lib->GetInterfacesAndImplementations(ifaces, impls); - return lib; - }; - - addPerTarget("Diamond", [&](Test& t, std::string_view target) { - auto X = makeDiamondLib("X", "X", target, {}); - Configuration* xDeps[] = { X.get() }; - auto B = makeDiamondLib("B", "B", target, xDeps); - auto C = makeDiamondLib("C", "C", target, xDeps); - Configuration* mainDeps[] = { B.get(), C.get() }; - - t.config.path = "tests/fixtures/diamond/"; - t.config.outputName = "diamond-app"; - t.config.dependencies.assign(mainDeps, mainDeps + 2); - std::array ifaces = {}; - std::array impls = { "main" }; - t.config.GetInterfacesAndImplementations(ifaces, impls); - - testLibPool.push_back(std::move(X)); - testLibPool.push_back(std::move(B)); - testLibPool.push_back(std::move(C)); - }); - - // ----- Outer-driver tests (still need their own driver source for multi-step - // logic or meta-runner behavior). ----- - - auto addOuterDriverTest = [&](std::string name) { - Test t; - t.config.path = "./"; - t.config.name = name; - t.config.outputName = name; - t.config.target = "x86_64-pc-linux-gnu"; - t.config.type = ConfigurationType::Executable; - std::array empty = {}; - std::array impls = { fs::path("tests") / name }; - t.config.GetInterfacesAndImplementations(empty, impls); - cfg.tests.push_back(std::move(t)); - }; - - addOuterDriverTest("Incremental"); - addOuterDriverTest("BuildError"); - addOuterDriverTest("Libraries"); - addOuterDriverTest("RunnerClassification"); - addOuterDriverTest("QemuUser"); - addOuterDriverTest("SshRunner"); - addOuterDriverTest("CrossArchAarch64"); - addOuterDriverTest("WindowsViaSsh"); + if (target == "x86_64-pc-linux-gnu") { + cfg.linkFlags.push_back("-Wl,--export-dynamic"); + cfg.linkFlags.push_back("-ldl"); + } return cfg; } diff --git a/tests/BuildError.cpp b/tests/BuildError.cpp deleted file mode 100644 index d7b8375..0000000 --- a/tests/BuildError.cpp +++ /dev/null @@ -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; - } -} diff --git a/tests/BuildError/BuildError.cpp b/tests/BuildError/BuildError.cpp new file mode 100644 index 0000000..f53c327 --- /dev/null +++ b/tests/BuildError/BuildError.cpp @@ -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; + } +} diff --git a/tests/fixtures/build-error/main.cpp b/tests/BuildError/inner/main.cpp similarity index 100% rename from tests/fixtures/build-error/main.cpp rename to tests/BuildError/inner/main.cpp diff --git a/tests/fixtures/build-error/project.cpp b/tests/BuildError/inner/project.cpp similarity index 100% rename from tests/fixtures/build-error/project.cpp rename to tests/BuildError/inner/project.cpp diff --git a/tests/BuildError/project.cpp b/tests/BuildError/project.cpp new file mode 100644 index 0000000..d9beea3 --- /dev/null +++ b/tests/BuildError/project.cpp @@ -0,0 +1,20 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + 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 ifaces = {}; + std::array impls = { "BuildError" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +} diff --git a/tests/CrossArchAarch64.cpp b/tests/CrossArchAarch64/CrossArchAarch64.cpp similarity index 55% rename from tests/CrossArchAarch64.cpp rename to tests/CrossArchAarch64/CrossArchAarch64.cpp index d2e2e3d..888369e 100644 --- a/tests/CrossArchAarch64.cpp +++ b/tests/CrossArchAarch64/CrossArchAarch64.cpp @@ -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; } diff --git a/tests/fixtures/cross-arch-aarch64/main.cpp b/tests/CrossArchAarch64/inner/main.cpp similarity index 100% rename from tests/fixtures/cross-arch-aarch64/main.cpp rename to tests/CrossArchAarch64/inner/main.cpp diff --git a/tests/fixtures/cross-arch-aarch64/project.cpp b/tests/CrossArchAarch64/inner/project.cpp similarity index 93% rename from tests/fixtures/cross-arch-aarch64/project.cpp rename to tests/CrossArchAarch64/inner/project.cpp index 5a751a8..5300832 100644 --- a/tests/fixtures/cross-arch-aarch64/project.cpp +++ b/tests/CrossArchAarch64/inner/project.cpp @@ -18,7 +18,6 @@ extern "C" Configuration CrafterBuildProject(std::span) std::array impls = { "main" }; cfg.GetInterfacesAndImplementations(ifaces, impls); - cfg.linkFlags.push_back("-fuse-ld=lld"); return cfg; } diff --git a/tests/CrossArchAarch64/project.cpp b/tests/CrossArchAarch64/project.cpp new file mode 100644 index 0000000..f073afb --- /dev/null +++ b/tests/CrossArchAarch64/project.cpp @@ -0,0 +1,20 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + 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 ifaces = {}; + std::array impls = { "CrossArchAarch64" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +} diff --git a/tests/fixtures/cross-project/lib/Foo.cppm b/tests/CrossProjectModule/lib/Foo.cppm similarity index 100% rename from tests/fixtures/cross-project/lib/Foo.cppm rename to tests/CrossProjectModule/lib/Foo.cppm diff --git a/tests/fixtures/cross-project/main.cpp b/tests/CrossProjectModule/main.cpp similarity index 100% rename from tests/fixtures/cross-project/main.cpp rename to tests/CrossProjectModule/main.cpp diff --git a/tests/CrossProjectModule/project.cpp b/tests/CrossProjectModule/project.cpp new file mode 100644 index 0000000..916b887 --- /dev/null +++ b/tests/CrossProjectModule/project.cpp @@ -0,0 +1,36 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span 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(); + fooLib->path = "tests/CrossProjectModule/lib/"; + fooLib->name = std::format("Foo-{}", target); + fooLib->outputName = "Foo"; + fooLib->target = target; + fooLib->type = ConfigurationType::LibraryStatic; + { + std::array ifaces = { "Foo" }; + std::array 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 ifaces = {}; + std::array impls = { "main" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +} diff --git a/tests/fixtures/defines/main.cpp b/tests/Defines/main.cpp similarity index 100% rename from tests/fixtures/defines/main.cpp rename to tests/Defines/main.cpp diff --git a/tests/Defines/project.cpp b/tests/Defines/project.cpp new file mode 100644 index 0000000..03087fd --- /dev/null +++ b/tests/Defines/project.cpp @@ -0,0 +1,24 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span 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 ifaces = {}; + std::array impls = { "main" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +} diff --git a/tests/fixtures/diamond/B/B.cppm b/tests/Diamond/B/B.cppm similarity index 100% rename from tests/fixtures/diamond/B/B.cppm rename to tests/Diamond/B/B.cppm diff --git a/tests/fixtures/diamond/C/C.cppm b/tests/Diamond/C/C.cppm similarity index 100% rename from tests/fixtures/diamond/C/C.cppm rename to tests/Diamond/C/C.cppm diff --git a/tests/fixtures/diamond/X/X.cppm b/tests/Diamond/X/X.cppm similarity index 100% rename from tests/fixtures/diamond/X/X.cppm rename to tests/Diamond/X/X.cppm diff --git a/tests/fixtures/diamond/main.cpp b/tests/Diamond/main.cpp similarity index 100% rename from tests/fixtures/diamond/main.cpp rename to tests/Diamond/main.cpp diff --git a/tests/Diamond/project.cpp b/tests/Diamond/project.cpp new file mode 100644 index 0000000..54c2d4e --- /dev/null +++ b/tests/Diamond/project.cpp @@ -0,0 +1,49 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +namespace { + std::unique_ptr MakeLib(std::string_view dir, std::string_view modName, + std::string_view target, + std::span deps) { + auto lib = std::make_unique(); + lib->path = std::format("tests/Diamond/{}/", dir); + lib->name = std::format("{}-Diamond-{}", modName, target); + lib->outputName = std::string(modName); + lib->target = std::string(target); + lib->type = ConfigurationType::LibraryStatic; + lib->dependencies.assign(deps.begin(), deps.end()); + std::array ifaces = { fs::path(modName) }; + std::array impls = {}; + lib->GetInterfacesAndImplementations(ifaces, impls); + return lib; + } +} + +extern "C" Configuration CrafterBuildProject(std::span 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 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 ifaces = {}; + std::array impls = { "main" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +} diff --git a/tests/fixtures/hello-world/main.cpp b/tests/HelloWorld/main.cpp similarity index 100% rename from tests/fixtures/hello-world/main.cpp rename to tests/HelloWorld/main.cpp diff --git a/tests/Incremental.cpp b/tests/Incremental/Incremental.cpp similarity index 50% rename from tests/Incremental.cpp rename to tests/Incremental/Incremental.cpp index 8825f41..d25f1eb 100644 --- a/tests/Incremental.cpp +++ b/tests/Incremental/Incremental.cpp @@ -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; } diff --git a/tests/fixtures/with-module/interfaces/Greeter.cppm b/tests/Incremental/inner/interfaces/Greeter.cppm similarity index 100% rename from tests/fixtures/with-module/interfaces/Greeter.cppm rename to tests/Incremental/inner/interfaces/Greeter.cppm diff --git a/tests/fixtures/incremental/main.cpp b/tests/Incremental/inner/main.cpp similarity index 100% rename from tests/fixtures/incremental/main.cpp rename to tests/Incremental/inner/main.cpp diff --git a/tests/fixtures/incremental/project.cpp b/tests/Incremental/inner/project.cpp similarity index 100% rename from tests/fixtures/incremental/project.cpp rename to tests/Incremental/inner/project.cpp diff --git a/tests/Incremental/project.cpp b/tests/Incremental/project.cpp new file mode 100644 index 0000000..e398462 --- /dev/null +++ b/tests/Incremental/project.cpp @@ -0,0 +1,20 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + 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 ifaces = {}; + std::array impls = { "Incremental" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +} diff --git a/tests/Libraries.cpp b/tests/Libraries/Libraries.cpp similarity index 63% rename from tests/Libraries.cpp rename to tests/Libraries/Libraries.cpp index 8977eac..0dc0000 100644 --- a/tests/Libraries.cpp +++ b/tests/Libraries/Libraries.cpp @@ -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; diff --git a/tests/fixtures/libraries/greetlib/GreetLib.cppm b/tests/Libraries/inner/greetlib/GreetLib.cppm similarity index 100% rename from tests/fixtures/libraries/greetlib/GreetLib.cppm rename to tests/Libraries/inner/greetlib/GreetLib.cppm diff --git a/tests/fixtures/libraries/main.cpp b/tests/Libraries/inner/main.cpp similarity index 100% rename from tests/fixtures/libraries/main.cpp rename to tests/Libraries/inner/main.cpp diff --git a/tests/fixtures/libraries/mathlib/MathLib.cppm b/tests/Libraries/inner/mathlib/MathLib.cppm similarity index 100% rename from tests/fixtures/libraries/mathlib/MathLib.cppm rename to tests/Libraries/inner/mathlib/MathLib.cppm diff --git a/tests/fixtures/libraries/project.cpp b/tests/Libraries/inner/project.cpp similarity index 100% rename from tests/fixtures/libraries/project.cpp rename to tests/Libraries/inner/project.cpp diff --git a/tests/Libraries/project.cpp b/tests/Libraries/project.cpp new file mode 100644 index 0000000..1b5a963 --- /dev/null +++ b/tests/Libraries/project.cpp @@ -0,0 +1,20 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + 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 ifaces = {}; + std::array impls = { "Libraries" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +} diff --git a/tests/QemuUser.cpp b/tests/QemuUser.cpp deleted file mode 100644 index 0958981..0000000 --- a/tests/QemuUser.cpp +++ /dev/null @@ -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; - } -} diff --git a/tests/QemuUser/QemuUser.cpp b/tests/QemuUser/QemuUser.cpp new file mode 100644 index 0000000..66ee83a --- /dev/null +++ b/tests/QemuUser/QemuUser.cpp @@ -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; + } +} diff --git a/tests/fixtures/qemu-runner/project.cpp b/tests/QemuUser/inner/project.cpp similarity index 100% rename from tests/fixtures/qemu-runner/project.cpp rename to tests/QemuUser/inner/project.cpp diff --git a/tests/fixtures/qemu-runner/tests/Hello.cpp b/tests/QemuUser/inner/tests/Hello.cpp similarity index 100% rename from tests/fixtures/qemu-runner/tests/Hello.cpp rename to tests/QemuUser/inner/tests/Hello.cpp diff --git a/tests/QemuUser/project.cpp b/tests/QemuUser/project.cpp new file mode 100644 index 0000000..ad47121 --- /dev/null +++ b/tests/QemuUser/project.cpp @@ -0,0 +1,20 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + 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 ifaces = {}; + std::array impls = { "QemuUser" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +} diff --git a/tests/RunnerClassification.cpp b/tests/RunnerClassification.cpp deleted file mode 100644 index 4cb169c..0000000 --- a/tests/RunnerClassification.cpp +++ /dev/null @@ -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; - } -} diff --git a/tests/RunnerClassification/RunnerClassification.cpp b/tests/RunnerClassification/RunnerClassification.cpp new file mode 100644 index 0000000..b8dcbbf --- /dev/null +++ b/tests/RunnerClassification/RunnerClassification.cpp @@ -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; + } +} diff --git a/tests/fixtures/runner-classification/project.cpp b/tests/RunnerClassification/inner/project.cpp similarity index 100% rename from tests/fixtures/runner-classification/project.cpp rename to tests/RunnerClassification/inner/project.cpp diff --git a/tests/fixtures/runner-classification/tests/Crash.cpp b/tests/RunnerClassification/inner/tests/Crash.cpp similarity index 100% rename from tests/fixtures/runner-classification/tests/Crash.cpp rename to tests/RunnerClassification/inner/tests/Crash.cpp diff --git a/tests/fixtures/runner-classification/tests/Fail.cpp b/tests/RunnerClassification/inner/tests/Fail.cpp similarity index 100% rename from tests/fixtures/runner-classification/tests/Fail.cpp rename to tests/RunnerClassification/inner/tests/Fail.cpp diff --git a/tests/fixtures/runner-classification/tests/Hang.cpp b/tests/RunnerClassification/inner/tests/Hang.cpp similarity index 100% rename from tests/fixtures/runner-classification/tests/Hang.cpp rename to tests/RunnerClassification/inner/tests/Hang.cpp diff --git a/tests/fixtures/runner-classification/tests/Pass.cpp b/tests/RunnerClassification/inner/tests/Pass.cpp similarity index 100% rename from tests/fixtures/runner-classification/tests/Pass.cpp rename to tests/RunnerClassification/inner/tests/Pass.cpp diff --git a/tests/RunnerClassification/project.cpp b/tests/RunnerClassification/project.cpp new file mode 100644 index 0000000..136afb5 --- /dev/null +++ b/tests/RunnerClassification/project.cpp @@ -0,0 +1,20 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + 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 ifaces = {}; + std::array impls = { "RunnerClassification" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +} diff --git a/tests/SshRunner.cpp b/tests/SshRunner.cpp deleted file mode 100644 index ce0f850..0000000 --- a/tests/SshRunner.cpp +++ /dev/null @@ -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; - } -} diff --git a/tests/SshRunner/SshRunner.cpp b/tests/SshRunner/SshRunner.cpp new file mode 100644 index 0000000..d32ec7a --- /dev/null +++ b/tests/SshRunner/SshRunner.cpp @@ -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; + } +} diff --git a/tests/fixtures/ssh-runner/project.cpp b/tests/SshRunner/inner/project.cpp similarity index 100% rename from tests/fixtures/ssh-runner/project.cpp rename to tests/SshRunner/inner/project.cpp diff --git a/tests/fixtures/ssh-runner/tests/Hello.cpp b/tests/SshRunner/inner/tests/Hello.cpp similarity index 100% rename from tests/fixtures/ssh-runner/tests/Hello.cpp rename to tests/SshRunner/inner/tests/Hello.cpp diff --git a/tests/SshRunner/project.cpp b/tests/SshRunner/project.cpp new file mode 100644 index 0000000..b9d86da --- /dev/null +++ b/tests/SshRunner/project.cpp @@ -0,0 +1,20 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + 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 ifaces = {}; + std::array impls = { "SshRunner" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +} diff --git a/tests/TestUtil.h b/tests/TestUtil.h deleted file mode 100644 index 106f5f5..0000000 --- a/tests/TestUtil.h +++ /dev/null @@ -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)}; - } -} diff --git a/tests/UnitLib/main.cpp b/tests/UnitLib/main.cpp new file mode 100644 index 0000000..470b5de --- /dev/null +++ b/tests/UnitLib/main.cpp @@ -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; +} diff --git a/tests/UnitLib/project.cpp b/tests/UnitLib/project.cpp new file mode 100644 index 0000000..2f715ee --- /dev/null +++ b/tests/UnitLib/project.cpp @@ -0,0 +1,21 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + 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 ifaces = {}; + std::array impls = { "main" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +} diff --git a/tests/Wasi/Wasi.cpp b/tests/Wasi/Wasi.cpp new file mode 100644 index 0000000..dcb22a7 --- /dev/null +++ b/tests/Wasi/Wasi.cpp @@ -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; + } +} diff --git a/tests/Wasi/inner/main.cpp b/tests/Wasi/inner/main.cpp new file mode 100644 index 0000000..7eb35d2 --- /dev/null +++ b/tests/Wasi/inner/main.cpp @@ -0,0 +1,6 @@ +import std; + +int main() { + std::println("Hello, WASI!"); + return 0; +} diff --git a/tests/Wasi/inner/project.cpp b/tests/Wasi/inner/project.cpp new file mode 100644 index 0000000..aeee1f0 --- /dev/null +++ b/tests/Wasi/inner/project.cpp @@ -0,0 +1,19 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "wasi-hello"; + cfg.outputName = "wasi-hello"; + cfg.target = "wasm32-wasip1"; + cfg.type = ConfigurationType::Executable; + + std::array ifaces = {}; + std::array impls = { "main" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + + return cfg; +} diff --git a/tests/Wasi/project.cpp b/tests/Wasi/project.cpp new file mode 100644 index 0000000..54900c7 --- /dev/null +++ b/tests/Wasi/project.cpp @@ -0,0 +1,20 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + 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 ifaces = {}; + std::array impls = { "Wasi" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +} diff --git a/tests/WindowsViaSsh.cpp b/tests/WindowsViaSsh.cpp deleted file mode 100644 index 4ea10e7..0000000 --- a/tests/WindowsViaSsh.cpp +++ /dev/null @@ -1,85 +0,0 @@ -/* -Crafter® Build -Copyright (C) 2026 Catcrafts® -Catcrafts.net - -LGPL-3.0-only. - -End-to-end Linux→Windows via SSH: -the fixture cross-compiles main.cpp for x86_64-w64-mingw32 (V2's existing MinGW -build path), the resulting .exe + runtime DLLs (which Build() already copies -into the output dir for the mingw target) get scp'd to a Windows host (winvm by -default), then ssh runs the .exe under cmd.exe and we capture stdout. Exercises -the same build-system features HelloWorld covers, but for the Windows target -path. Gated on: - - mingw cross-toolchain installed on the host (x86_64-w64-mingw32-g++) - - CRAFTER_TEST_WIN_SSH_HOST env var set (defaults to no-skip if "winvm" is - reachable, but explicit opt-in keeps CI green by default) -*/ - -import std; -#include "TestUtil.h" -namespace fs = std::filesystem; -using namespace TestUtil; - -namespace { - bool ToolPresent(std::string_view name) { - std::string cmd = std::format("which {} > /dev/null 2>&1", name); - return std::system(cmd.c_str()) == 0; - } - - bool Contains(std::string_view haystack, std::string_view needle) { - return haystack.find(needle) != std::string_view::npos; - } -} - -int main() { - try { - const char* hostEnv = std::getenv("CRAFTER_TEST_WIN_SSH_HOST"); - if (!hostEnv || !*hostEnv) { - std::println("(skipped: set CRAFTER_TEST_WIN_SSH_HOST to enable, e.g. winvm)"); - return 0; - } - std::string host = hostEnv; - - if (!ToolPresent("x86_64-w64-mingw32-g++")) { - std::println("(skipped: mingw cross-toolchain not on PATH)"); - return 0; - } - - // Probe the SSH host; skip on transient unreachability rather than fail. - std::string probe = std::format( - "ssh -o BatchMode=yes -o ConnectTimeout=5 {} \"ver\" > /dev/null 2>&1", host); - if (std::system(probe.c_str()) != 0) { - std::println("(skipped: ssh {} not reachable)", host); - return 0; - } - - fs::path projectRoot = fs::current_path(); - fs::path src = projectRoot / "tests" / "fixtures" / "windows-via-ssh"; - fs::path crafterBuild = projectRoot / "bin" / "crafter-build"; - - fs::path work = CopyFixtureToTemp("WindowsViaSsh", src); - - std::string remoteDir = "C:/temp/crafter-test-winhello"; - auto run = RunInDir(work, std::format( - "CRAFTER_BUILD_RUNNER_x86_64_w64_mingw32='sshwin:{}:{}' '{}' test", - host, remoteDir, crafterBuild.string())); - - if (run.exitCode != 0) { - std::println(std::cerr, "inner runner failed (rc={}):\n{}", run.exitCode, run.output); - return 1; - } - std::string marker = std::format("\xE2\x9C\x85 winhello (sshwin:{})", host); - if (!Contains(run.output, marker)) { - std::println(std::cerr, - "expected marker {:?} not found in inner output:\n{}", - marker, run.output); - return 1; - } - return 0; - } catch (const std::exception& e) { - std::println(std::cerr, "test exception: {}", e.what()); - return 1; - } -} diff --git a/tests/WindowsViaSsh/WindowsViaSsh.cpp b/tests/WindowsViaSsh/WindowsViaSsh.cpp new file mode 100644 index 0000000..428bc15 --- /dev/null +++ b/tests/WindowsViaSsh/WindowsViaSsh.cpp @@ -0,0 +1,68 @@ +/* +Crafter® Build +Copyright (C) 2026 Catcrafts® +Catcrafts.net + +LGPL-3.0-only. + +End-to-end Linux→Windows 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; + } +} diff --git a/tests/fixtures/windows-via-ssh/main.cpp b/tests/WindowsViaSsh/inner/main.cpp similarity index 100% rename from tests/fixtures/windows-via-ssh/main.cpp rename to tests/WindowsViaSsh/inner/main.cpp diff --git a/tests/fixtures/windows-via-ssh/project.cpp b/tests/WindowsViaSsh/inner/project.cpp similarity index 89% rename from tests/fixtures/windows-via-ssh/project.cpp rename to tests/WindowsViaSsh/inner/project.cpp index d23958f..059f7a9 100644 --- a/tests/fixtures/windows-via-ssh/project.cpp +++ b/tests/WindowsViaSsh/inner/project.cpp @@ -20,8 +20,6 @@ extern "C" Configuration CrafterBuildProject(std::span) std::array ifaces = {}; std::array impls = { "main" }; t.config.GetInterfacesAndImplementations(ifaces, impls); - t.config.linkFlags.push_back("-fuse-ld=lld"); - t.config.linkFlags.push_back("-lstdc++exp"); t.runner = TestRunner::FromEnv(t.config.target); diff --git a/tests/WindowsViaSsh/project.cpp b/tests/WindowsViaSsh/project.cpp new file mode 100644 index 0000000..7acd28b --- /dev/null +++ b/tests/WindowsViaSsh/project.cpp @@ -0,0 +1,20 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + 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 ifaces = {}; + std::array impls = { "WindowsViaSsh" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +} diff --git a/tests/WithModule/interfaces/Greeter.cppm b/tests/WithModule/interfaces/Greeter.cppm new file mode 100644 index 0000000..1294d98 --- /dev/null +++ b/tests/WithModule/interfaces/Greeter.cppm @@ -0,0 +1,6 @@ +export module Greeter; +import std; + +export std::string Greet(std::string_view name) { + return std::format("hello, {}!", name); +} diff --git a/tests/fixtures/with-module/main.cpp b/tests/WithModule/main.cpp similarity index 100% rename from tests/fixtures/with-module/main.cpp rename to tests/WithModule/main.cpp diff --git a/tests/_shared/TestUtil.h b/tests/_shared/TestUtil.h new file mode 100644 index 0000000..f810280 --- /dev/null +++ b/tests/_shared/TestUtil.h @@ -0,0 +1,76 @@ +#pragma once +#include // 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> 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 /share/crafter-build before + // loading. The lib's default sourceDir is derived from /proc/self/exe, but + // test exes live in tests//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 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); + } +} diff --git a/wasi-runtime/index.html.in b/wasi-runtime/index.html.in new file mode 100644 index 0000000..a1cc797 --- /dev/null +++ b/wasi-runtime/index.html.in @@ -0,0 +1,10 @@ + + + + + {{WASM}} + + + + + diff --git a/wasi-runtime/runtime.js b/wasi-runtime/runtime.js new file mode 100644 index 0000000..4de1270 --- /dev/null +++ b/wasi-runtime/runtime.js @@ -0,0 +1,167 @@ +// Minimal browser WASI shim. Loaded by index.html, which sets +// window.CRAFTER_WASM_URL before this script runs so a single runtime.js +// can serve any output name. Most syscalls return 0 — enough to host a +// hello-world that uses fd_write (stdout) and the args/environ probes +// libc invokes during startup. Extend as needed. + +const textEncoder = new TextEncoder(); + +class Wasi { + #encodedStdin; + #envEncodedStrings; + #argEncodedStrings; + instance; + + constructor({ env, stdin, args }) { + this.#encodedStdin = textEncoder.encode(stdin); + const envStrings = Object.entries(env).map(([k, v]) => `${k}=${v}`); + this.#envEncodedStrings = envStrings.map(s => textEncoder.encode(s + "\0")); + this.#argEncodedStrings = args.map(s => textEncoder.encode(s + "\0")); + this.bind(); + } + + bind() { + this.args_get = this.args_get.bind(this); + this.args_sizes_get = this.args_sizes_get.bind(this); + this.environ_get = this.environ_get.bind(this); + this.environ_sizes_get = this.environ_sizes_get.bind(this); + this.fd_read = this.fd_read.bind(this); + this.fd_write = this.fd_write.bind(this); + } + + args_sizes_get(argCountPtr, argBufferSizePtr) { + const argByteLength = this.#argEncodedStrings.reduce((sum, val) => sum + val.byteLength, 0); + const countPointerBuffer = new Uint32Array(this.instance.exports.memory.buffer, argCountPtr, 1); + const sizePointerBuffer = new Uint32Array(this.instance.exports.memory.buffer, argBufferSizePtr, 1); + countPointerBuffer[0] = this.#argEncodedStrings.length; + sizePointerBuffer[0] = argByteLength; + return 0; + } + args_get(argsPtr, argBufferPtr) { + const argsByteLength = this.#argEncodedStrings.reduce((sum, val) => sum + val.byteLength, 0); + const argsPointerBuffer = new Uint32Array(this.instance.exports.memory.buffer, argsPtr, this.#argEncodedStrings.length); + const argsBuffer = new Uint8Array(this.instance.exports.memory.buffer, argBufferPtr, argsByteLength); + + let pointerOffset = 0; + for (let i = 0; i < this.#argEncodedStrings.length; i++) { + argsPointerBuffer[i] = argBufferPtr + pointerOffset; + argsBuffer.set(this.#argEncodedStrings[i], pointerOffset); + pointerOffset += this.#argEncodedStrings[i].byteLength; + } + return 0; + } + fd_write(fd, iovsPtr, iovsLength, bytesWrittenPtr) { + const iovs = new Uint32Array(this.instance.exports.memory.buffer, iovsPtr, iovsLength * 2); + if (fd === 1 || fd === 2) { + let text = ""; + let totalBytesWritten = 0; + const decoder = new TextDecoder(); + for (let i = 0; i < iovsLength * 2; i += 2) { + const offset = iovs[i]; + const length = iovs[i + 1]; + text += decoder.decode(new Int8Array(this.instance.exports.memory.buffer, offset, length)); + totalBytesWritten += length; + } + const dataView = new DataView(this.instance.exports.memory.buffer); + dataView.setInt32(bytesWrittenPtr, totalBytesWritten, true); + (fd === 2 ? console.error : console.log)(text); + } + return 0; + } + fd_read(fd, iovsPtr, iovsLength, bytesReadPtr) { + const memory = new Uint8Array(this.instance.exports.memory.buffer); + const iovs = new Uint32Array(this.instance.exports.memory.buffer, iovsPtr, iovsLength * 2); + let totalBytesRead = 0; + if (fd === 0) { + for (let i = 0; i < iovsLength * 2; i += 2) { + const offset = iovs[i]; + const length = iovs[i + 1]; + const chunk = this.#encodedStdin.slice(0, length); + this.#encodedStdin = this.#encodedStdin.slice(length); + memory.set(chunk, offset); + totalBytesRead += chunk.byteLength; + if (this.#encodedStdin.length === 0) break; + } + const dataView = new DataView(this.instance.exports.memory.buffer); + dataView.setInt32(bytesReadPtr, totalBytesRead, true); + } + return 0; + } + fd_advise() { return 0; } + fd_close() { return 0; } + fd_fdstat_get() { return 0; } + fd_prestat_get() { return 0; } + fd_prestat_dir_name() { return 0; } + clock_res_get() { return 0; } + clock_time_get() { return 0; } + fd_seek() { return 0; } + fd_allocate() { return 0; } + fd_datasync() { return 0; } + fd_fdstat_set_flags() { return 0; } + fd_fdstat_set_rights() { return 0; } + fd_filestat_get() { return 0; } + fd_filestat_set_size() { return 0; } + fd_filestat_set_times() { return 0; } + fd_pread() { return 0; } + fd_pwrite() { return 0; } + fd_readdir() { return 0; } + fd_renumber() { return 0; } + fd_sync() { return 0; } + fd_tell() { return 0; } + path_create_directory() { return 0; } + path_filestat_get() { return 0; } + path_filestat_set_times() { return 0; } + path_link() { return 0; } + path_open() { return 0; } + path_readlink() { return 0; } + path_remove_directory() { return 0; } + path_rename() { return 0; } + path_symlink() { return 0; } + path_unlink_file() { return 0; } + poll_oneoff() { return 0; } + sched_yield() { return 0; } + random_get() { return 0; } + sock_accept() { return 0; } + sock_recv() { return 0; } + sock_send() { return 0; } + sock_shutdown() { return 0; } + environ_get(environPtr, environBufferPtr) { + const envByteLength = this.#envEncodedStrings.reduce((sum, val) => sum + val.byteLength, 0); + const environsPointerBuffer = new Uint32Array(this.instance.exports.memory.buffer, environPtr, this.#envEncodedStrings.length); + const environsBuffer = new Uint8Array(this.instance.exports.memory.buffer, environBufferPtr, envByteLength); + + let pointerOffset = 0; + for (let i = 0; i < this.#envEncodedStrings.length; i++) { + environsPointerBuffer[i] = environBufferPtr + pointerOffset; + environsBuffer.set(this.#envEncodedStrings[i], pointerOffset); + pointerOffset += this.#envEncodedStrings[i].byteLength; + } + return 0; + } + environ_sizes_get(environCountPtr, environBufferSizePtr) { + const envByteLength = this.#envEncodedStrings.reduce((sum, val) => sum + val.byteLength, 0); + const countPointerBuffer = new Uint32Array(this.instance.exports.memory.buffer, environCountPtr, 1); + const sizePointerBuffer = new Uint32Array(this.instance.exports.memory.buffer, environBufferSizePtr, 1); + countPointerBuffer[0] = this.#envEncodedStrings.length; + sizePointerBuffer[0] = envByteLength; + return 0; + } + proc_exit(code) { + console.log(`[wasi] proc_exit(${code})`); + } +} + +const wasmUrl = window.CRAFTER_WASM_URL; +if (!wasmUrl) { + throw new Error("runtime.js: window.CRAFTER_WASM_URL is not set (set it in index.html before loading runtime.js)"); +} + +const wasi = new Wasi({ stdin: "", env: {}, args: [] }); + +const { instance } = await WebAssembly.instantiateStreaming(fetch(wasmUrl), { + wasi_snapshot_preview1: wasi, +}); +wasi.instance = instance; +window.crafter_wasi = wasi; + +instance.exports._start();