V2: WASI, -r flag, CI pipeline, examples & tests cleanup #1
102 changed files with 2211 additions and 686 deletions
V2: WASI, -r flag, CI pipeline, examples & tests cleanup
Some checks failed
CI / build-test-release (pull_request) Failing after 44s
Some checks failed
CI / build-test-release (pull_request) Failing after 44s
WASI / wasm32 target support
- Auto-detect /usr/share/wasi-sysroot on Linux when target starts_with("wasm32")
- Skip -march/-mtune for wasm (clang rejects them)
- Apply -fno-exceptions -fno-c++-static-destructors -mllvm -wasm-enable-sjlj
-D_WASI_EMULATED_SIGNAL to wasm builds (compile + std PCM, kept in sync)
- .wasm output extension in expectedOutputFor and link command
- EnableWasiBrowserRuntime(cfg): opt-in helper that drops index.html +
runtime.js next to the .wasm; runtime.js reads window.CRAFTER_WASM_URL
set in the templated index.html so a single shim handles any output name
-r run flag in the CLI: build then exec the artifact (host targets only;
rejects libraries; auto .exe/.wasm extension handling)
CI pipeline (.forgejo/workflows/ci.yaml)
- Triggers: PR/push to master + manual dispatch
- Single arch-latest container job: install deps, bootstrap, self-rebuild,
run tests, cross-compile mingw, package both archives, upload artifacts
- Rolling 'latest' release published only on push/dispatch to master
mingw cross-compile from Linux now works end-to-end:
- ExternalDependency cache key includes target so per-target glslang builds
don't collide; CMAKE_BUILD_TYPE=Release pinned (otherwise glslang appends
'd' to lib names and breaks linking); cross-compile cmake flags
(CMAKE_SYSTEM_NAME=Windows, CMAKE_*_COMPILER_TARGET=...)
- project.cpp accepts --target=<triple>; Linux-only -Wl,--export-dynamic
and -ldl are gated; mingw glslang skips the standalone exe (its libgcc_eh
link pulls pthread which mingw doesn't link by default)
- mingw compile uses -femulated-tls so std::__once_callable etc reference
the same emutls symbols libstdc++ provides
- mingw link auto-adds -lstdc++exp -lpthread
GetCrafterBuildHome() exposed from the Platform module; LoadProject (Linux
+ Windows) now both use it instead of duplicating the resolution.
Examples reorg: hello-world, library, with-module, wasi, tests — each with
its own README. Tests reorg: per-test directory with inner/ fixture, no
shared tests/fixtures/ tree. New Wasi test verifies .wasm magic bytes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
commit
eaee502e8c
102
.forgejo/workflows/ci.yaml
Normal file
102
.forgejo/workflows/ci.yaml
Normal file
|
|
@ -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 }}
|
||||||
6
PKGBUILD
6
PKGBUILD
|
|
@ -6,6 +6,9 @@ arch=('x86_64')
|
||||||
url='https://forgejo.catcrafts.net/Catcrafts/Crafter.Build'
|
url='https://forgejo.catcrafts.net/Catcrafts/Crafter.Build'
|
||||||
license=('LGPL-3.0-only')
|
license=('LGPL-3.0-only')
|
||||||
depends=('clang' 'libc++' 'lld')
|
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')
|
makedepends=('cmake' 'git')
|
||||||
source=()
|
source=()
|
||||||
sha256sums=()
|
sha256sums=()
|
||||||
|
|
@ -24,4 +27,7 @@ package() {
|
||||||
install -dm755 "$pkgdir/usr/share/crafter-build"
|
install -dm755 "$pkgdir/usr/share/crafter-build"
|
||||||
install -m644 share/crafter-build/*.cppm "$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 -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/"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
165
README.md
165
README.md
|
|
@ -5,7 +5,7 @@ A C++26 modules build system. Project descriptions are written in C++ — no JSO
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
- **Linux**: working end-to-end, packageable as a distro package. Verified on Arch Linux via [PKGBUILD](PKGBUILD) in a fresh container.
|
- **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)
|
## Quick start (Linux)
|
||||||
|
|
||||||
|
|
@ -50,7 +50,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Run `crafter-build` in that directory; outputs land at `bin/myapp-<target>-<march>/myapp` and intermediates at `build/myapp-<target>-<march>/`.
|
Run `crafter-build` in that directory; outputs land at `bin/myapp-<target>-<march>/myapp` and intermediates at `build/myapp-<target>-<march>/`. Add `-r` to run the produced executable straight after a successful build (host targets only — for cross targets use `crafter-build test --runner=...`).
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
|
|
@ -100,115 +100,136 @@ Diamond deps (`A → {B, C}; B → X; C → X`) build `X` exactly once via a `st
|
||||||
|
|
||||||
## Tests
|
## 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/<Name>/`. The simplest case is a single C++ file:
|
||||||
|
|
||||||
Declare tests by pushing onto `cfg.tests` in your `project.cpp`:
|
```
|
||||||
|
tests/Smoke/main.cpp # int main() — exit 0 = pass, nonzero = fail, 77 = skipped
|
||||||
```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<fs::path, 0> ifaces = {};
|
|
||||||
std::array<fs::path, 1> impls = { "tests/VectorMath" };
|
|
||||||
t.config.GetInterfacesAndImplementations(ifaces, impls);
|
|
||||||
cfg.tests.push_back(std::move(t));
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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/<name>.log` when a test fails, crashes, or times out.
|
Run them all:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
crafter-build test # build + run all tests
|
crafter-build test
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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/<Name>/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/<Name>/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/<name>.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
|
### 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
|
```cpp
|
||||||
t.runner = TestRunner::Local(); // default
|
TestRunner::Local() // run on this machine (default)
|
||||||
t.runner = TestRunner::QemuUser("qemu-aarch64"); // cross-arch via qemu user-mode
|
TestRunner::Cmd("wine") // local exec via prefix command
|
||||||
t.runner = TestRunner::Ssh("winvm", "/tmp/crafter-test"); // cross-OS via scp + ssh
|
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/<name>-<target>-<march>/` 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 `<cmd> <bin> <args>` prefix runner — covers wine, qemu-user, valgrind, gdbserver, anything with that shape. Probes via `which <cmd>` (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 `<qemuBin> {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=<path>` is passed to clang, and `<sysroot>/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=<path>` is passed to clang, and `<sysroot>/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 <rootfs> --config <alarm-pacman.conf> --dbpath <rootfs>/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 <rootfs> --config <alarm-pacman.conf> --dbpath <rootfs>/var/lib/pacman -Sy …` (point pacman at ALARM's mirrors with an explicit config to avoid pulling host-arch packages). Then `cfg.sysroot = "<rootfs>";` plus a manual `TestRunner` whose exec template prepends `QEMU_LD_PREFIX=<rootfs> 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)`:
|
**Env var** (persistent, per shell or in CI):
|
||||||
|
|
||||||
```cpp
|
|
||||||
t.runner = TestRunner::FromEnv(t.config.target);
|
|
||||||
```
|
|
||||||
|
|
||||||
This reads the env var `CRAFTER_BUILD_RUNNER_<NORMALIZED_TARGET>` (target triple with `-` and `.` replaced by `_`). Set it once per shell or in CI:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export CRAFTER_BUILD_RUNNER_x86_64_w64_mingw32=ssh:winvm:/tmp/crafter-test
|
export CRAFTER_BUILD_RUNNER_x86_64_w64_mingw32=cmd:wine
|
||||||
export CRAFTER_BUILD_RUNNER_aarch64_linux_gnu=qemu:qemu-aarch64
|
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:<qemu-bin>`, `ssh:<host>`, `ssh:<host>:<remoteDir>`, `sshwin:<host>`, `sshwin:<host>:<remoteDir>` (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_<NORMALIZED_TARGET>` (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 '<name>' 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 <host> …`, `QemuUser` via `which <qemu-bin>`, `Local` is unconditionally available.
|
```
|
||||||
|
local
|
||||||
|
cmd:<command> # cmd:wine, cmd:qemu-aarch64, cmd:valgrind, ...
|
||||||
|
ssh:<host>[:<remoteDir>]
|
||||||
|
sshwin:<host>[:<remoteDir>] # cmd.exe-shell variant for Windows remotes
|
||||||
|
wsl[:<remoteDir>] # 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 <host> 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
|
```cpp
|
||||||
std::vector<TargetRunner> targets = {
|
// tests/UnitFoo/project.cpp
|
||||||
{ "x86_64-pc-linux-gnu", TestRunner::Local(), {} },
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||||
{ "x86_64-w64-mingw32", TestRunner::SshWin("winvm", "C:/temp/crafter-tests"), {"-lstdc++exp"} },
|
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<fs::path, 0> ifaces = {};
|
||||||
|
std::array<fs::path, 1> 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.
|
||||||
|
|
||||||
```
|
## Examples
|
||||||
✅ HelloWorld (5ms)
|
|
||||||
✅ HelloWorld (sshwin:winvm) (2348ms)
|
|
||||||
✅ WithModule (3ms)
|
|
||||||
✅ WithModule (sshwin:winvm) (2204ms)
|
|
||||||
…
|
|
||||||
18 passed
|
|
||||||
```
|
|
||||||
|
|
||||||
Without `winvm` reachable, the SshWin variants skip:
|
[examples/](examples/) contains progressively larger self-contained projects:
|
||||||
|
|
||||||
```
|
| Example | Shows |
|
||||||
✅ HelloWorld (5ms)
|
|---|---|
|
||||||
⏭ HelloWorld (sshwin:winvm) skipped: runner 'sshwin:winvm' not available
|
| [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.
|
Each builds standalone: `cd examples/<name> && crafter-build`.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@ mkdir build 2>nul
|
||||||
mkdir bin 2>nul
|
mkdir bin 2>nul
|
||||||
mkdir lib 2>nul
|
mkdir lib 2>nul
|
||||||
mkdir share\crafter-build 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.cppm share\crafter-build\
|
||||||
copy /Y interfaces\Crafter.Build-Shader.cppm share\crafter-build\
|
copy /Y interfaces\Crafter.Build-Shader.cppm share\crafter-build\
|
||||||
|
|
|
||||||
4
build.sh
4
build.sh
|
|
@ -2,6 +2,10 @@ mkdir -p build
|
||||||
mkdir -p bin
|
mkdir -p bin
|
||||||
mkdir -p lib
|
mkdir -p lib
|
||||||
mkdir -p share/crafter-build
|
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.cppm share/crafter-build/
|
||||||
cp interfaces/Crafter.Build-Shader.cppm share/crafter-build/
|
cp interfaces/Crafter.Build-Shader.cppm share/crafter-build/
|
||||||
|
|
|
||||||
12
examples/README.md
Normal file
12
examples/README.md
Normal file
|
|
@ -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/<name> && crafter-build`. Test examples: `crafter-build test`.
|
||||||
11
examples/hello-world/README.md
Normal file
11
examples/hello-world/README.md
Normal file
|
|
@ -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.
|
||||||
6
examples/hello-world/main.cpp
Normal file
6
examples/hello-world/main.cpp
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import std;
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
std::println("hello from crafter-build");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
18
examples/hello-world/project.cpp
Normal file
18
examples/hello-world/project.cpp
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||||
|
Configuration cfg;
|
||||||
|
cfg.path = "./";
|
||||||
|
cfg.name = "hello";
|
||||||
|
cfg.outputName = "hello";
|
||||||
|
cfg.target = "x86_64-pc-linux-gnu";
|
||||||
|
cfg.type = ConfigurationType::Executable;
|
||||||
|
|
||||||
|
std::array<fs::path, 0> ifaces = {};
|
||||||
|
std::array<fs::path, 1> impls = { "main" };
|
||||||
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
21
examples/library/README.md
Normal file
21
examples/library/README.md
Normal file
|
|
@ -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<Configuration>` for the lib is the canonical lifetime pattern: the parent's `dependencies` is `vector<Configuration*>` (raw), so something needs to keep the lib alive across the function return. File-local `static` does it cleanly without a global pool.
|
||||||
8
examples/library/main.cpp
Normal file
8
examples/library/main.cpp
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import std;
|
||||||
|
import MyMath;
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
std::println("Square(5) = {}", Square(5));
|
||||||
|
std::println("Cube(3) = {}", Cube(3));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
5
examples/library/mylib/MyMath.cppm
Normal file
5
examples/library/mylib/MyMath.cppm
Normal file
|
|
@ -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; }
|
||||||
37
examples/library/project.cpp
Normal file
37
examples/library/project.cpp
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||||
|
// The library — built into mylib/bin/MyMath-<target>-<march>/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<Configuration>();
|
||||||
|
lib->path = "./mylib/";
|
||||||
|
lib->name = "MyMath";
|
||||||
|
lib->outputName = "MyMath";
|
||||||
|
lib->target = "x86_64-pc-linux-gnu";
|
||||||
|
lib->type = ConfigurationType::LibraryStatic;
|
||||||
|
{
|
||||||
|
std::array<fs::path, 1> ifaces = { "MyMath" };
|
||||||
|
std::array<fs::path, 0> 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<fs::path, 0> ifaces = {};
|
||||||
|
std::array<fs::path, 1> impls = { "main" };
|
||||||
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
54
examples/tests/README.md
Normal file
54
examples/tests/README.md
Normal file
|
|
@ -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/<Name>/` directory becomes a test. Three layers, escalate only as needed:
|
||||||
|
|
||||||
|
1. **`tests/<Name>/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/<Name>/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=<spec>` overrides the per-target runner for this invocation. Useful specs: `local`, `cmd:<command>` (prefix-exec, e.g. `cmd:wine`, `cmd:qemu-aarch64`), `ssh:<host>[:<remoteDir>]`, `sshwin:<host>[:<remoteDir>]`. Or persist via env var: `CRAFTER_BUILD_RUNNER_<normalized_target>=<spec>`.
|
||||||
5
examples/tests/mylib/MyMath.cppm
Normal file
5
examples/tests/mylib/MyMath.cppm
Normal file
|
|
@ -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; }
|
||||||
20
examples/tests/project.cpp
Normal file
20
examples/tests/project.cpp
Normal file
|
|
@ -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<const std::string_view>) {
|
||||||
|
Configuration cfg;
|
||||||
|
cfg.path = "./mylib/";
|
||||||
|
cfg.name = "MyMath";
|
||||||
|
cfg.outputName = "MyMath";
|
||||||
|
cfg.target = "x86_64-pc-linux-gnu";
|
||||||
|
cfg.type = ConfigurationType::LibraryStatic;
|
||||||
|
|
||||||
|
std::array<fs::path, 1> ifaces = { "MyMath" };
|
||||||
|
std::array<fs::path, 0> impls = {};
|
||||||
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
8
examples/tests/tests/Smoke/main.cpp
Normal file
8
examples/tests/tests/Smoke/main.cpp
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
8
examples/tests/tests/UnitMyMath/main.cpp
Normal file
8
examples/tests/tests/UnitMyMath/main.cpp
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import std;
|
||||||
|
import MyMath;
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
if (Square(5) != 25) return 1;
|
||||||
|
if (Cube(3) != 27) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
22
examples/tests/tests/UnitMyMath/project.cpp
Normal file
22
examples/tests/tests/UnitMyMath/project.cpp
Normal file
|
|
@ -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<const std::string_view>) {
|
||||||
|
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<fs::path, 0> ifaces = {};
|
||||||
|
std::array<fs::path, 1> impls = { "main" };
|
||||||
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
40
examples/wasi/README.md
Normal file
40
examples/wasi/README.md
Normal file
|
|
@ -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
|
||||||
|
```
|
||||||
6
examples/wasi/main.cpp
Normal file
6
examples/wasi/main.cpp
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import std;
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
std::println("Hello, WASI!");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
24
examples/wasi/project.cpp
Normal file
24
examples/wasi/project.cpp
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||||
|
Configuration cfg;
|
||||||
|
cfg.path = "./";
|
||||||
|
cfg.name = "wasi-hello";
|
||||||
|
cfg.outputName = "wasi-hello";
|
||||||
|
cfg.target = "wasm32-wasip1";
|
||||||
|
cfg.type = ConfigurationType::Executable;
|
||||||
|
|
||||||
|
std::array<fs::path, 0> ifaces = {};
|
||||||
|
std::array<fs::path, 1> impls = { "main" };
|
||||||
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
2
examples/wasi/serve.sh
Executable file
2
examples/wasi/serve.sh
Executable file
|
|
@ -0,0 +1,2 @@
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
caddy file-server --listen :8080 --root bin/wasi-hello-wasm32-wasip1-native
|
||||||
11
examples/with-module/README.md
Normal file
11
examples/with-module/README.md
Normal file
|
|
@ -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.
|
||||||
7
examples/with-module/main.cpp
Normal file
7
examples/with-module/main.cpp
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import std;
|
||||||
|
import Greeter;
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
std::println("{}", Greet("world"));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
18
examples/with-module/project.cpp
Normal file
18
examples/with-module/project.cpp
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||||
|
Configuration cfg;
|
||||||
|
cfg.path = "./";
|
||||||
|
cfg.name = "greeter-app";
|
||||||
|
cfg.outputName = "greeter-app";
|
||||||
|
cfg.target = "x86_64-pc-linux-gnu";
|
||||||
|
cfg.type = ConfigurationType::Executable;
|
||||||
|
|
||||||
|
std::array<fs::path, 1> ifaces = { "interfaces/Greeter" };
|
||||||
|
std::array<fs::path, 1> impls = { "main" };
|
||||||
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
@ -197,6 +197,40 @@ void Configuration::GetInterfacesAndImplementations(std::span<fs::path> interfac
|
||||||
}
|
}
|
||||||
|
|
||||||
BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, std::shared_future<BuildResult>>& depResults, std::mutex& depMutex) {
|
BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, std::shared_future<BuildResult>>& depResults, std::mutex& depMutex) {
|
||||||
|
// Reset per-build cached state on every Module/ModulePartition so that
|
||||||
|
// successive Build() calls on the same Configuration re-evaluate mtimes
|
||||||
|
// (incremental-rebuild test scenarios). Walks cfg.dependencies recursively
|
||||||
|
// with a seen-set so diamond deps don't loop.
|
||||||
|
{
|
||||||
|
std::unordered_set<Configuration*> resetSeen;
|
||||||
|
std::function<void(Configuration*)> reset = [&](Configuration* c) {
|
||||||
|
if (!resetSeen.insert(c).second) return;
|
||||||
|
for (auto& iface : c->interfaces) {
|
||||||
|
iface->checked = false;
|
||||||
|
iface->compiled.store(false);
|
||||||
|
for (auto& part : iface->partitions) {
|
||||||
|
part->checked = false;
|
||||||
|
part->compiled.store(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (Configuration* dep : c->dependencies) {
|
||||||
|
reset(dep);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reset(&config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-detect the WASI sysroot before any compile step runs so BuildStdPcm
|
||||||
|
// and the main compile command see the same value. Linux-only — Windows
|
||||||
|
// users supply cfg.sysroot pointing at their wasi-sdk install. Covers all
|
||||||
|
// wasm32-* triples (wasi, wasip1, wasip2, ...); the sysroot's per-triple
|
||||||
|
// subdirs handle the differences.
|
||||||
|
#ifdef CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_linux_gnu
|
||||||
|
if (config.sysroot.empty() && config.target.starts_with("wasm32")) {
|
||||||
|
config.sysroot = "/usr/share/wasi-sysroot";
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
fs::path buildDir = config.path/"build"/std::format("{}-{}-{}", config.name, config.target, config.march);
|
fs::path 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);
|
fs::path outputDir = config.path/"bin"/std::format("{}-{}-{}", config.name, config.target, config.march);
|
||||||
|
|
||||||
|
|
@ -287,7 +321,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
|
||||||
for (std::size_t i = 0; i < config.externalDependencies.size(); ++i) {
|
for (std::size_t i = 0; i < config.externalDependencies.size(); ++i) {
|
||||||
externalThreads.emplace_back([&, i]() {
|
externalThreads.emplace_back([&, i]() {
|
||||||
if (buildCancelled.load(std::memory_order_relaxed)) return;
|
if (buildCancelled.load(std::memory_order_relaxed)) return;
|
||||||
externalResults[i] = BuildExternal(config.externalDependencies[i], buildCancelled);
|
externalResults[i] = BuildExternal(config.externalDependencies[i], config.target, buildCancelled);
|
||||||
if (!externalResults[i].error.empty()) {
|
if (!externalResults[i].error.empty()) {
|
||||||
bool expected = false;
|
bool expected = false;
|
||||||
if (buildCancelled.compare_exchange_strong(expected, true)) {
|
if (buildCancelled.compare_exchange_strong(expected, true)) {
|
||||||
|
|
@ -324,11 +358,31 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
|
||||||
std::string editedTarget = config.target;
|
std::string editedTarget = config.target;
|
||||||
std::replace(editedTarget.begin(), editedTarget.end(), '-', '_');
|
std::replace(editedTarget.begin(), editedTarget.end(), '-', '_');
|
||||||
|
|
||||||
std::string command = std::format("{} --target={} -march={} -mtune={} -std=c++26 -D CRAFTER_BUILD_CONFIGURATION_TARGET=\\\"{}\\\" -D CRAFTER_BUILD_CONFIGURATION_TARGET_{} -fprebuilt-module-path={} -fprebuilt-module-path={}", GetBaseCommand(config), config.target, config.march, config.mtune, editedTarget, editedTarget, stdPcmDir.string(), pcmDir.string());
|
// wasm32 targets reject -march and silently ignore -mtune (clang errors on
|
||||||
|
// the former). Skip both for any wasm32-* triple.
|
||||||
|
bool isWasm = config.target.starts_with("wasm32");
|
||||||
|
std::string archFlags = isWasm
|
||||||
|
? std::string()
|
||||||
|
: std::format(" -march={} -mtune={}", config.march, config.mtune);
|
||||||
|
std::string command = std::format("{} --target={}{} -std=c++26 -D CRAFTER_BUILD_CONFIGURATION_TARGET=\\\"{}\\\" -D CRAFTER_BUILD_CONFIGURATION_TARGET_{} -fprebuilt-module-path={} -fprebuilt-module-path={}", GetBaseCommand(config), config.target, archFlags, editedTarget, editedTarget, stdPcmDir.string(), pcmDir.string());
|
||||||
|
|
||||||
if (!config.sysroot.empty()) {
|
if (!config.sysroot.empty()) {
|
||||||
command += std::format(" --sysroot={}", config.sysroot);
|
command += std::format(" --sysroot={}", config.sysroot);
|
||||||
}
|
}
|
||||||
|
if (config.target.starts_with("wasm32")) {
|
||||||
|
// -mllvm is consumed by codegen but not the link driver, which is the
|
||||||
|
// same command line; quiet the unused-flag warning rather than split
|
||||||
|
// compile and link commands.
|
||||||
|
command += " -fno-exceptions -fno-c++-static-destructors -mllvm -wasm-enable-sjlj -D_WASI_EMULATED_SIGNAL -Wno-unused-command-line-argument";
|
||||||
|
}
|
||||||
|
if (config.target == "x86_64-w64-mingw32") {
|
||||||
|
// mingw libstdc++ defines TLS via __emutls_v.* (emulated TLS); without
|
||||||
|
// -femulated-tls clang generates native-TLS references that don't
|
||||||
|
// match. Symptom: undefined std::__once_callable / __once_call at
|
||||||
|
// link time. Also -Wno-unused… because -femulated-tls is a codegen
|
||||||
|
// flag the link driver doesn't consume.
|
||||||
|
command += " -femulated-tls -Wno-unused-command-line-argument";
|
||||||
|
}
|
||||||
|
|
||||||
if(config.type == ConfigurationType::LibraryDynamic) {
|
if(config.type == ConfigurationType::LibraryDynamic) {
|
||||||
#ifdef CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_linux_gnu
|
#ifdef CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_linux_gnu
|
||||||
|
|
@ -565,6 +619,61 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
|
||||||
for(const std::string& flag : buildResult.libs) {
|
for(const std::string& flag : buildResult.libs) {
|
||||||
linkExtras += " " + flag;
|
linkExtras += " " + flag;
|
||||||
}
|
}
|
||||||
|
// mingw uses libstdc++; C++26 std::print/format extras live in libstdc++exp.
|
||||||
|
// libstdc++ on mingw uses winpthreads for std::atomic_wait /
|
||||||
|
// counting_semaphore / stop_token, so -lpthread is required as soon as
|
||||||
|
// those primitives appear (they do, transitively, in any non-trivial std
|
||||||
|
// import). -static-libstdc++ bundles libstdc++ into the exe so we don't
|
||||||
|
// chase libstdc++-6.dll TLS symbol mismatches across mingw versions and
|
||||||
|
// the resulting binary stands alone. Auto-link so user projects don't
|
||||||
|
// carry boilerplate.
|
||||||
|
if (config.target == "x86_64-w64-mingw32") {
|
||||||
|
// mingw runtime DLLs (libstdc++-6.dll, libgcc_s_seh-1.dll,
|
||||||
|
// libwinpthread-1.dll) get auto-copied next to the .exe by the
|
||||||
|
// post-build step below, so dynamic linkage is fine. -lstdc++exp =
|
||||||
|
// C++26 std::print/format extras; -lpthread = winpthreads symbols
|
||||||
|
// that libstdc++ uses for std::atomic_wait, counting_semaphore,
|
||||||
|
// stop_token. clang++ adds the main -lstdc++ implicitly.
|
||||||
|
linkExtras += " -lstdc++exp -lpthread";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force a relink if the expected output is missing or older than any dep
|
||||||
|
// artifact. Missing covers: previous build produced a different outputName,
|
||||||
|
// or the binary was deleted by hand. Older-than-dep covers: dep's library
|
||||||
|
// was rebuilt by an earlier run (so dep.repack is false this time around)
|
||||||
|
// but the consumer was never relinked against the new dep.
|
||||||
|
{
|
||||||
|
auto expectedOutputFor = [](const Configuration& c) -> fs::path {
|
||||||
|
fs::path dir = c.path/"bin"/std::format("{}-{}-{}", c.name, c.target, c.march);
|
||||||
|
if (c.type == ConfigurationType::Executable) {
|
||||||
|
if (c.target.starts_with("wasm32"))
|
||||||
|
return dir / (c.outputName + ".wasm");
|
||||||
|
return c.target == "x86_64-w64-mingw32"
|
||||||
|
? dir / (c.outputName + ".exe")
|
||||||
|
: dir / c.outputName;
|
||||||
|
}
|
||||||
|
if (c.type == ConfigurationType::LibraryStatic) {
|
||||||
|
return c.target == "x86_64-w64-mingw32" || c.target == "x86_64-pc-windows-msvc"
|
||||||
|
? dir / std::format("{}.lib", c.outputName)
|
||||||
|
: dir / std::format("lib{}.a", c.outputName);
|
||||||
|
}
|
||||||
|
return dir / std::format("lib{}.so", c.outputName);
|
||||||
|
};
|
||||||
|
|
||||||
|
fs::path expected = expectedOutputFor(config);
|
||||||
|
if (!fs::exists(expected)) {
|
||||||
|
buildResult.repack = true;
|
||||||
|
} else {
|
||||||
|
auto consumerMtime = fs::last_write_time(expected);
|
||||||
|
for (Configuration* dep : config.dependencies) {
|
||||||
|
fs::path depArtifact = expectedOutputFor(*dep);
|
||||||
|
if (fs::exists(depArtifact) && fs::last_write_time(depArtifact) > consumerMtime) {
|
||||||
|
buildResult.repack = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if(buildResult.repack) {
|
if(buildResult.repack) {
|
||||||
if(config.type == ConfigurationType::Executable) {
|
if(config.type == ConfigurationType::Executable) {
|
||||||
|
|
@ -589,7 +698,11 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
|
||||||
return {e.what(), false, {}};
|
return {e.what(), false, {}};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
buildResult.result = RunCommand(std::format("{}{} -o {} -fuse-ld=lld{}", command, files, (outputDir/config.outputName).string(), linkExtras));
|
if (config.target.starts_with("wasm32")) {
|
||||||
|
buildResult.result = RunCommand(std::format("{}{} -o {}.wasm -fuse-ld=lld{}", command, files, (outputDir/config.outputName).string(), linkExtras));
|
||||||
|
} else {
|
||||||
|
buildResult.result = RunCommand(std::format("{}{} -o {} -fuse-ld=lld{}", command, files, (outputDir/config.outputName).string(), linkExtras));
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
|
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
|
||||||
|
|
@ -616,6 +729,33 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
|
||||||
|
|
||||||
return buildResult;
|
return buildResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Crafter::EnableWasiBrowserRuntime(Configuration& cfg) {
|
||||||
|
fs::path runtimeDir = GetCrafterBuildHome() / "wasi-runtime";
|
||||||
|
fs::path runtimeJs = runtimeDir / "runtime.js";
|
||||||
|
fs::path htmlTemplate = runtimeDir / "index.html.in";
|
||||||
|
if (!fs::exists(runtimeJs) || !fs::exists(htmlTemplate)) {
|
||||||
|
throw std::runtime_error(std::format(
|
||||||
|
"wasi-runtime assets missing under {} (set CRAFTER_BUILD_HOME or reinstall)",
|
||||||
|
runtimeDir.string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::path htmlOutDir = cfg.path / "build" / "wasi-runtime" / cfg.name;
|
||||||
|
fs::create_directories(htmlOutDir);
|
||||||
|
fs::path htmlPath = htmlOutDir / "index.html";
|
||||||
|
|
||||||
|
std::ifstream in(htmlTemplate);
|
||||||
|
std::stringstream buf;
|
||||||
|
buf << in.rdbuf();
|
||||||
|
std::string html = std::regex_replace(buf.str(), std::regex(R"(\{\{WASM\}\})"), cfg.outputName + ".wasm");
|
||||||
|
std::ofstream out(htmlPath);
|
||||||
|
out << html;
|
||||||
|
out.close();
|
||||||
|
|
||||||
|
cfg.files.push_back(runtimeJs);
|
||||||
|
cfg.files.push_back(htmlPath);
|
||||||
|
}
|
||||||
|
|
||||||
int Crafter::Run(int argc, char** argv) {
|
int Crafter::Run(int argc, char** argv) {
|
||||||
try {
|
try {
|
||||||
fs::path projectFile = "./project.cpp";
|
fs::path projectFile = "./project.cpp";
|
||||||
|
|
@ -623,12 +763,15 @@ int Crafter::Run(int argc, char** argv) {
|
||||||
projectArgs.reserve(argc);
|
projectArgs.reserve(argc);
|
||||||
|
|
||||||
bool runTests = false;
|
bool runTests = false;
|
||||||
|
bool runAfterBuild = false;
|
||||||
RunTestsOptions testOpts;
|
RunTestsOptions testOpts;
|
||||||
|
|
||||||
for (int i = 1; i < argc; ++i) {
|
for (int i = 1; i < argc; ++i) {
|
||||||
std::string_view arg = argv[i];
|
std::string_view arg = argv[i];
|
||||||
if (arg == "test") {
|
if (arg == "test") {
|
||||||
runTests = true;
|
runTests = true;
|
||||||
|
} else if (arg == "-r") {
|
||||||
|
runAfterBuild = true;
|
||||||
} else if (arg.starts_with("--project=")) {
|
} else if (arg.starts_with("--project=")) {
|
||||||
projectFile = arg.substr(std::string_view("--project=").size());
|
projectFile = arg.substr(std::string_view("--project=").size());
|
||||||
} else if (runTests && arg.starts_with("--jobs=")) {
|
} else if (runTests && arg.starts_with("--jobs=")) {
|
||||||
|
|
@ -637,6 +780,8 @@ int Crafter::Run(int argc, char** argv) {
|
||||||
testOpts.timeoutOverride = std::chrono::seconds(std::stoi(std::string(arg.substr(std::string_view("--timeout=").size()))));
|
testOpts.timeoutOverride = std::chrono::seconds(std::stoi(std::string(arg.substr(std::string_view("--timeout=").size()))));
|
||||||
} else if (runTests && arg == "--list") {
|
} else if (runTests && arg == "--list") {
|
||||||
testOpts.listOnly = true;
|
testOpts.listOnly = true;
|
||||||
|
} else if (runTests && arg.starts_with("--runner=")) {
|
||||||
|
testOpts.runnerOverride = std::string(arg.substr(std::string_view("--runner=").size()));
|
||||||
} else if (runTests && !arg.starts_with("-")) {
|
} else if (runTests && !arg.starts_with("-")) {
|
||||||
testOpts.globs.emplace_back(arg);
|
testOpts.globs.emplace_back(arg);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -644,15 +789,25 @@ int Crafter::Run(int argc, char** argv) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The test run is target-scoped: only tests whose cfg.target equals
|
||||||
|
// testOpts.targetFilter are included. Default = host triple, so a
|
||||||
|
// bare `crafter-build test` runs everything that targets host.
|
||||||
|
for (auto& a : projectArgs) {
|
||||||
|
if (a.starts_with("--target=")) {
|
||||||
|
testOpts.targetFilter = std::string(a.substr(std::string_view("--target=").size()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!fs::exists(projectFile)) {
|
if (!fs::exists(projectFile)) {
|
||||||
std::println(std::cerr, "No project file at {}", projectFile.string());
|
std::println(std::cerr, "No project file at {}", projectFile.string());
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
Configuration config = LoadProject(projectFile, projectArgs);
|
Configuration config = LoadProject(projectFile, projectArgs);
|
||||||
|
SetParentProject(&config);
|
||||||
|
|
||||||
if (runTests) {
|
if (runTests) {
|
||||||
TestSummary summary = RunTests(config, testOpts);
|
TestSummary summary = RunTests(config, testOpts, projectArgs);
|
||||||
return summary.AllPassed() ? 0 : 1;
|
return summary.AllPassed() ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -665,6 +820,21 @@ int Crafter::Run(int argc, char** argv) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (runAfterBuild) {
|
||||||
|
if (config.type != ConfigurationType::Executable) {
|
||||||
|
std::println(std::cerr, "-r: cannot run a library");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
fs::path dir = config.path / "bin" / std::format("{}-{}-{}", config.name, config.target, config.march);
|
||||||
|
fs::path artifact = dir / config.outputName;
|
||||||
|
if (config.target.starts_with("wasm32")) {
|
||||||
|
artifact += ".wasm";
|
||||||
|
} else if (config.target == "x86_64-w64-mingw32" || config.target == "x86_64-pc-windows-msvc") {
|
||||||
|
artifact += ".exe";
|
||||||
|
}
|
||||||
|
return std::system(artifact.string().c_str()) == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
std::println(std::cerr, "{}", e.what());
|
std::println(std::cerr, "{}", e.what());
|
||||||
|
|
|
||||||
|
|
@ -110,22 +110,37 @@ std::string FetchGit(const GitSource& source, const fs::path& cloneDir) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string BuildInjectedCMakeFlags(const fs::path& cmakeBuildDir) {
|
std::string BuildInjectedCMakeFlags(const fs::path& cmakeBuildDir, std::string_view target) {
|
||||||
std::string out;
|
std::string out;
|
||||||
|
// Without an explicit build type, some packages (glslang) append a 'd'
|
||||||
|
// suffix to library names assuming a debug build, breaking -l<name>
|
||||||
|
// linking. Pin to Release.
|
||||||
|
out += " -DCMAKE_BUILD_TYPE=Release";
|
||||||
out += " -DCMAKE_C_COMPILER=clang";
|
out += " -DCMAKE_C_COMPILER=clang";
|
||||||
out += " -DCMAKE_CXX_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
|
#ifdef CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_linux_gnu
|
||||||
out += " -DCMAKE_CXX_FLAGS=-stdlib=libc++";
|
if (target == "x86_64-pc-linux-gnu") {
|
||||||
out += " -DCMAKE_EXE_LINKER_FLAGS=-stdlib=libc++";
|
out += " -DCMAKE_CXX_FLAGS=-stdlib=libc++";
|
||||||
out += " -DCMAKE_SHARED_LINKER_FLAGS=-stdlib=libc++";
|
out += " -DCMAKE_EXE_LINKER_FLAGS=-stdlib=libc++";
|
||||||
|
out += " -DCMAKE_SHARED_LINKER_FLAGS=-stdlib=libc++";
|
||||||
|
}
|
||||||
#endif
|
#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);
|
fs::path absBuildDir = fs::absolute(cmakeBuildDir);
|
||||||
out += std::format(" -DCMAKE_ARCHIVE_OUTPUT_DIRECTORY={}", ShellQuote(absBuildDir.string()));
|
out += std::format(" -DCMAKE_ARCHIVE_OUTPUT_DIRECTORY={}", ShellQuote(absBuildDir.string()));
|
||||||
out += std::format(" -DCMAKE_LIBRARY_OUTPUT_DIRECTORY={}", ShellQuote(absBuildDir.string()));
|
out += std::format(" -DCMAKE_LIBRARY_OUTPUT_DIRECTORY={}", ShellQuote(absBuildDir.string()));
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string ConfigureCMake(const fs::path& cloneDir, const fs::path& cmakeBuildDir, std::span<const std::string> options) {
|
std::string ConfigureCMake(const fs::path& cloneDir, const fs::path& cmakeBuildDir, std::span<const std::string> options, std::string_view target) {
|
||||||
fs::path optionsFile = cmakeBuildDir / ".crafter-options";
|
fs::path optionsFile = cmakeBuildDir / ".crafter-options";
|
||||||
std::string optionsKey = JoinOptions(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 {}{}",
|
std::string cmd = std::format("cmake -S {} -B {}{}",
|
||||||
ShellQuote(fs::absolute(cloneDir).string()),
|
ShellQuote(fs::absolute(cloneDir).string()),
|
||||||
ShellQuote(fs::absolute(cmakeBuildDir).string()),
|
ShellQuote(fs::absolute(cmakeBuildDir).string()),
|
||||||
BuildInjectedCMakeFlags(cmakeBuildDir));
|
BuildInjectedCMakeFlags(cmakeBuildDir, target));
|
||||||
if (!options.empty()) {
|
if (!options.empty()) {
|
||||||
cmd += " ";
|
cmd += " ";
|
||||||
cmd += optionsKey;
|
cmd += optionsKey;
|
||||||
|
|
@ -176,6 +191,7 @@ std::string BuildCMake(const fs::path& cmakeBuildDir) {
|
||||||
|
|
||||||
ExternalBuildResult Crafter::BuildExternal(
|
ExternalBuildResult Crafter::BuildExternal(
|
||||||
const ExternalDependency& dep,
|
const ExternalDependency& dep,
|
||||||
|
std::string_view target,
|
||||||
std::atomic<bool>& cancelled) {
|
std::atomic<bool>& cancelled) {
|
||||||
ExternalBuildResult result;
|
ExternalBuildResult result;
|
||||||
|
|
||||||
|
|
@ -197,8 +213,8 @@ ExternalBuildResult Crafter::BuildExternal(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string keyMaterial = std::format("{}|{}|{}|{}",
|
std::string keyMaterial = std::format("{}|{}|{}|{}|{}",
|
||||||
dep.source.url, dep.source.branch, dep.source.commit, JoinOptions(dep.options));
|
dep.source.url, dep.source.branch, dep.source.commit, JoinOptions(dep.options), target);
|
||||||
std::size_t key = std::hash<std::string>{}(keyMaterial);
|
std::size_t key = std::hash<std::string>{}(keyMaterial);
|
||||||
fs::path cloneDir = externalRoot / std::format("{}-{:016x}", name, key);
|
fs::path cloneDir = externalRoot / std::format("{}-{:016x}", name, key);
|
||||||
|
|
||||||
|
|
@ -213,7 +229,7 @@ ExternalBuildResult Crafter::BuildExternal(
|
||||||
fs::path cmakeBuildDir;
|
fs::path cmakeBuildDir;
|
||||||
if (dep.builder == ExternalBuilder::CMake) {
|
if (dep.builder == ExternalBuilder::CMake) {
|
||||||
cmakeBuildDir = cloneDir / "build";
|
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);
|
result.error = std::format("cmake configure for '{}': {}", name, err);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,22 @@ import :Clang;
|
||||||
namespace fs = std::filesystem;
|
namespace fs = std::filesystem;
|
||||||
using namespace Crafter;
|
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)
|
#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) {
|
std::string Crafter::RunCommand(const std::string_view cmd) {
|
||||||
|
|
@ -76,8 +92,103 @@ CommandResult Crafter::RunCommandChecked(std::string_view cmd) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandResult Crafter::RunCommandWithTimeout(std::string_view, std::chrono::seconds) {
|
CommandResult Crafter::RunCommandWithTimeout(std::string_view cmd, std::chrono::seconds timeout) {
|
||||||
throw std::runtime_error("RunCommandWithTimeout not yet implemented on Windows");
|
CommandResult result{};
|
||||||
|
|
||||||
|
SECURITY_ATTRIBUTES sa{ sizeof(sa), nullptr, TRUE };
|
||||||
|
HANDLE readEnd = nullptr, writeEnd = nullptr;
|
||||||
|
if (!CreatePipe(&readEnd, &writeEnd, &sa, 0)) {
|
||||||
|
throw std::runtime_error("CreatePipe failed");
|
||||||
|
}
|
||||||
|
SetHandleInformation(readEnd, HANDLE_FLAG_INHERIT, 0);
|
||||||
|
|
||||||
|
// KILL_ON_JOB_CLOSE so the cmd.exe wrapper plus whatever it spawned
|
||||||
|
// (the test binary, ssh, etc.) all die when the job handle goes away —
|
||||||
|
// that's how we enforce the timeout reliably across the whole tree.
|
||||||
|
HANDLE job = CreateJobObjectA(nullptr, nullptr);
|
||||||
|
if (!job) {
|
||||||
|
CloseHandle(readEnd);
|
||||||
|
CloseHandle(writeEnd);
|
||||||
|
throw std::runtime_error("CreateJobObject failed");
|
||||||
|
}
|
||||||
|
JOBOBJECT_EXTENDED_LIMIT_INFORMATION jeli{};
|
||||||
|
jeli.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||||
|
SetInformationJobObject(job, JobObjectExtendedLimitInformation, &jeli, sizeof(jeli));
|
||||||
|
|
||||||
|
STARTUPINFOA si{};
|
||||||
|
si.cb = sizeof(si);
|
||||||
|
si.dwFlags = STARTF_USESTDHANDLES;
|
||||||
|
si.hStdOutput = writeEnd;
|
||||||
|
si.hStdError = writeEnd;
|
||||||
|
si.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
|
||||||
|
|
||||||
|
PROCESS_INFORMATION pi{};
|
||||||
|
// cmd /C "...": gives users the same shell-syntax surface (redirects,
|
||||||
|
// &&, ||) as popen("/bin/sh -c …") does on Linux. cmd's special-case
|
||||||
|
// /C parsing strips the outer quote pair when the inner text contains
|
||||||
|
// additional quotes, which is the common case here.
|
||||||
|
std::string wrapped = "cmd /C \"" + std::string(cmd) + "\"";
|
||||||
|
std::vector<char> cmdBuf(wrapped.begin(), wrapped.end());
|
||||||
|
cmdBuf.push_back('\0');
|
||||||
|
|
||||||
|
BOOL ok = CreateProcessA(
|
||||||
|
nullptr, cmdBuf.data(), nullptr, nullptr, TRUE,
|
||||||
|
CREATE_NO_WINDOW | CREATE_SUSPENDED,
|
||||||
|
nullptr, nullptr, &si, &pi);
|
||||||
|
if (!ok) {
|
||||||
|
CloseHandle(readEnd);
|
||||||
|
CloseHandle(writeEnd);
|
||||||
|
CloseHandle(job);
|
||||||
|
throw std::runtime_error("CreateProcess failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
AssignProcessToJobObject(job, pi.hProcess);
|
||||||
|
ResumeThread(pi.hThread);
|
||||||
|
// Drop our writer ref so the read end sees EOF once the child (its
|
||||||
|
// own dup of writeEnd) exits.
|
||||||
|
CloseHandle(writeEnd);
|
||||||
|
|
||||||
|
std::string output;
|
||||||
|
std::jthread reader([&output, readEnd]() {
|
||||||
|
char buf[4096];
|
||||||
|
DWORD n = 0;
|
||||||
|
while (ReadFile(readEnd, buf, sizeof(buf), &n, nullptr) && n > 0) {
|
||||||
|
output.append(buf, n);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
DWORD waitMs = static_cast<DWORD>(std::min<long long>(
|
||||||
|
static_cast<long long>(timeout.count()) * 1000LL,
|
||||||
|
static_cast<long long>(INFINITE) - 1));
|
||||||
|
DWORD waitResult = WaitForSingleObject(pi.hProcess, waitMs);
|
||||||
|
|
||||||
|
if (waitResult == WAIT_TIMEOUT) {
|
||||||
|
TerminateJobObject(job, 1);
|
||||||
|
WaitForSingleObject(pi.hProcess, INFINITE);
|
||||||
|
result.timedOut = true;
|
||||||
|
result.exitCode = 124;
|
||||||
|
} else {
|
||||||
|
DWORD exit = 0;
|
||||||
|
GetExitCodeProcess(pi.hProcess, &exit);
|
||||||
|
// NTSTATUS error severity (top two bits set) means the process
|
||||||
|
// terminated by exception — STATUS_ACCESS_VIOLATION 0xC0000005,
|
||||||
|
// STATUS_STACK_OVERFLOW 0xC00000FD, etc. Surface those as crashes
|
||||||
|
// so the runner shows 💥 instead of a numeric exit code.
|
||||||
|
if ((exit & 0xC0000000) == 0xC0000000) {
|
||||||
|
result.crashed = true;
|
||||||
|
result.signal = static_cast<int>(exit);
|
||||||
|
}
|
||||||
|
result.exitCode = static_cast<int>(exit);
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.join();
|
||||||
|
CloseHandle(readEnd);
|
||||||
|
CloseHandle(pi.hThread);
|
||||||
|
CloseHandle(pi.hProcess);
|
||||||
|
CloseHandle(job);
|
||||||
|
|
||||||
|
result.output = std::move(output);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
|
std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
|
||||||
|
|
@ -155,10 +266,7 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
|
||||||
}
|
}
|
||||||
fs::path hostExe(std::string(hostExeBuf, hostExeLen));
|
fs::path hostExe(std::string(hostExeBuf, hostExeLen));
|
||||||
|
|
||||||
const char* envHome = std::getenv("CRAFTER_BUILD_HOME");
|
fs::path sourceDir = Crafter::GetCrafterBuildHome();
|
||||||
fs::path sourceDir = envHome
|
|
||||||
? fs::path(envHome)
|
|
||||||
: hostExe.parent_path().parent_path() / "share" / "crafter-build";
|
|
||||||
|
|
||||||
Configuration hostConfig;
|
Configuration hostConfig;
|
||||||
hostConfig.target = "x86_64-pc-windows-msvc";
|
hostConfig.target = "x86_64-pc-windows-msvc";
|
||||||
|
|
@ -318,19 +426,37 @@ std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
|
||||||
fs::path stdCc = fs::path(std::format("/usr/x86_64-w64-mingw32/include/c++/{}/bits/std.cc", mingWversion));
|
fs::path stdCc = fs::path(std::format("/usr/x86_64-w64-mingw32/include/c++/{}/bits/std.cc", mingWversion));
|
||||||
|
|
||||||
if(!fs::exists(stdPcm) || fs::last_write_time(stdPcm) < fs::last_write_time(stdCc)) {
|
if(!fs::exists(stdPcm) || fs::last_write_time(stdPcm) < fs::last_write_time(stdCc)) {
|
||||||
return RunCommand(std::format("cp {} {}/std.cppm\nclang++ --target={} -march={} -mtune={} -O3 -std=c++26 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile {}/std.cppm -o {}", stdCc.string(), stdPcm.parent_path().string(), config.target, config.march, config.mtune, stdPcm.parent_path().string(), stdPcm.string()));
|
// -femulated-tls keeps PCM TLS access matching libstdc++'s emutls
|
||||||
|
// definitions; mismatch surfaces as undefined std::__once_callable.
|
||||||
|
return RunCommand(std::format("cp {} {}/std.cppm\nclang++ --target={} -march={} -mtune={} -femulated-tls -O3 -std=c++26 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile {}/std.cppm -o {}", stdCc.string(), stdPcm.parent_path().string(), config.target, config.march, config.mtune, stdPcm.parent_path().string(), stdPcm.string()));
|
||||||
} else {
|
} else {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
std::string stdCppm = config.sysroot.empty()
|
bool isWasm = config.target.starts_with("wasm32");
|
||||||
? std::string("/usr/share/libc++/v1/std.cppm")
|
// wasi-sdk drops std.cppm at <sysroot>/share/libc++/v1/, the rest of
|
||||||
: std::format("{}/usr/share/libc++/v1/std.cppm", config.sysroot);
|
// the libc++ ecosystem (e.g. /opt/aarch64-rootfs) follows FHS at
|
||||||
|
// <sysroot>/usr/share/libc++/v1/.
|
||||||
|
std::string stdCppm;
|
||||||
|
if (isWasm) {
|
||||||
|
stdCppm = std::format("{}/share/libc++/v1/std.cppm", config.sysroot);
|
||||||
|
} else if (config.sysroot.empty()) {
|
||||||
|
stdCppm = "/usr/share/libc++/v1/std.cppm";
|
||||||
|
} else {
|
||||||
|
stdCppm = std::format("{}/usr/share/libc++/v1/std.cppm", config.sysroot);
|
||||||
|
}
|
||||||
std::string sysrootFlag = config.sysroot.empty()
|
std::string sysrootFlag = config.sysroot.empty()
|
||||||
? std::string()
|
? std::string()
|
||||||
: std::format(" --sysroot={}", config.sysroot);
|
: 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)) {
|
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 {
|
} else {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
@ -406,10 +532,7 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
|
||||||
|
|
||||||
fs::path hostExe = fs::read_symlink("/proc/self/exe");
|
fs::path hostExe = fs::read_symlink("/proc/self/exe");
|
||||||
|
|
||||||
const char* envHome = std::getenv("CRAFTER_BUILD_HOME");
|
fs::path sourceDir = Crafter::GetCrafterBuildHome();
|
||||||
fs::path sourceDir = envHome
|
|
||||||
? fs::path(envHome)
|
|
||||||
: hostExe.parent_path().parent_path() / "share" / "crafter-build";
|
|
||||||
|
|
||||||
Configuration hostConfig;
|
Configuration hostConfig;
|
||||||
hostConfig.target = "x86_64-pc-linux-gnu";
|
hostConfig.target = "x86_64-pc-linux-gnu";
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ namespace {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string ShellQuote(std::string_view s) {
|
std::string ShellQuoteSh(std::string_view s) {
|
||||||
std::string out;
|
std::string out;
|
||||||
out.reserve(s.size() + 2);
|
out.reserve(s.size() + 2);
|
||||||
out.push_back('\'');
|
out.push_back('\'');
|
||||||
|
|
@ -75,11 +75,42 @@ namespace {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string JoinAndQuoteArgs(std::span<const std::string> args) {
|
// cmd.exe doesn't recognize '...' as quoting (it would pass the single
|
||||||
|
// quotes through to the executable). Wrap in "..." for cmd; embedded "
|
||||||
|
// is rare in paths but escape it to be safe. Backslash sequences before
|
||||||
|
// " don't need the MS-CRT doubling rules because we go through
|
||||||
|
// `cmd /C "..."`, which uses cmd's parser, not the CRT's argv splitter.
|
||||||
|
std::string ShellQuoteCmd(std::string_view s) {
|
||||||
|
std::string out;
|
||||||
|
out.reserve(s.size() + 2);
|
||||||
|
out.push_back('"');
|
||||||
|
for (char c : s) {
|
||||||
|
if (c == '"') out += "\\\"";
|
||||||
|
else out.push_back(c);
|
||||||
|
}
|
||||||
|
out.push_back('"');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host-shell quoting: sh on Linux, cmd on Windows. For args/paths that
|
||||||
|
// hit the local shell (Local runner exec, Cmd-prefix runner exec).
|
||||||
|
std::string ShellQuoteHost(std::string_view s) {
|
||||||
|
#ifdef _WIN32
|
||||||
|
return ShellQuoteCmd(s);
|
||||||
|
#else
|
||||||
|
return ShellQuoteSh(s);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string JoinAndQuoteArgs(std::span<const std::string> args, TestRunner::Shell shell) {
|
||||||
std::string out;
|
std::string out;
|
||||||
for (const auto& a : args) {
|
for (const auto& a : args) {
|
||||||
if (!out.empty()) out.push_back(' ');
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
@ -167,9 +198,42 @@ namespace {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
std::atomic<Configuration*> g_parentProject{nullptr};
|
||||||
|
|
||||||
|
Configuration* FindLibInTree(Configuration* root,
|
||||||
|
std::string_view name,
|
||||||
|
std::unordered_set<Configuration*>& seen) {
|
||||||
|
if (!seen.insert(root).second) return nullptr;
|
||||||
|
if (root->name == name) return root;
|
||||||
|
for (Configuration* dep : root->dependencies) {
|
||||||
|
if (auto found = FindLibInTree(dep, name, seen)) return found;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Crafter::SetParentProject(Configuration* parent) {
|
||||||
|
g_parentProject.store(parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
Configuration* Crafter::ParentLib(std::string_view name) {
|
||||||
|
Configuration* root = g_parentProject.load();
|
||||||
|
if (!root) {
|
||||||
|
throw std::runtime_error(std::format(
|
||||||
|
"Crafter::ParentLib('{}'): no parent project set", name));
|
||||||
|
}
|
||||||
|
std::unordered_set<Configuration*> seen;
|
||||||
|
if (auto found = FindLibInTree(root, name, seen)) return found;
|
||||||
|
throw std::runtime_error(std::format(
|
||||||
|
"Crafter::ParentLib('{}'): not found in parent project '{}'",
|
||||||
|
name, root->name));
|
||||||
|
}
|
||||||
|
|
||||||
TestRunner TestRunner::Local() {
|
TestRunner TestRunner::Local() {
|
||||||
TestRunner r;
|
TestRunner r;
|
||||||
r.name = "local";
|
r.name = "local";
|
||||||
|
r.argsShell = Shell::Host;
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -177,10 +241,18 @@ TestRunner TestRunner::Ssh(std::string host, std::string remoteDir) {
|
||||||
TestRunner r;
|
TestRunner r;
|
||||||
r.name = std::format("ssh:{}", host);
|
r.name = std::format("ssh:{}", host);
|
||||||
r.remoteDir = std::move(remoteDir);
|
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.argsShell = Shell::Sh;
|
||||||
r.exec = std::format("ssh -q {} 'cd {{remote_bundle}} && ./{{bin_name}} {{args}}'", host);
|
// Outer "..." (not '...') so the wrapper survives both sh (Linux host)
|
||||||
r.cleanup = std::format("ssh -q {} 'rm -rf {{remote_bundle}}' || true", host);
|
// and cmd (Windows host). cmd doesn't honor single quotes, which would
|
||||||
r.probe = std::format("ssh -q -o BatchMode=yes -o ConnectTimeout=5 {} true > /dev/null 2>&1", host);
|
// 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;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,6 +260,7 @@ TestRunner TestRunner::SshWin(std::string host, std::string remoteDir) {
|
||||||
TestRunner r;
|
TestRunner r;
|
||||||
r.name = std::format("sshwin:{}", host);
|
r.name = std::format("sshwin:{}", host);
|
||||||
r.remoteDir = std::move(remoteDir);
|
r.remoteDir = std::move(remoteDir);
|
||||||
|
r.argsShell = Shell::Cmd;
|
||||||
// cmd.exe-friendly templates. Use backslash variants of remote paths because
|
// cmd.exe-friendly templates. Use backslash variants of remote paths because
|
||||||
// cmd's mkdir won't auto-create intermediate directories with forward
|
// cmd's mkdir won't auto-create intermediate directories with forward
|
||||||
// slashes. scp tolerates either, so we keep forward slashes for the scp
|
// 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.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.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.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;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
TestRunner TestRunner::QemuUser(std::string qemuBin) {
|
TestRunner TestRunner::Wsl(std::string remoteDir) {
|
||||||
TestRunner r;
|
TestRunner r;
|
||||||
r.name = std::format("qemu:{}", qemuBin);
|
r.name = "wsl";
|
||||||
r.exec = std::format("{} {{bin}} {{args}}", qemuBin);
|
r.argsShell = Shell::Sh;
|
||||||
r.probe = std::format("which {} > /dev/null 2>&1", qemuBin);
|
r.remoteDir = std::move(remoteDir);
|
||||||
|
// Transport runner: stage the test bundle into WSL's native filesystem
|
||||||
|
// (faster than executing in-place from /mnt/c) then run via bash. {bundle_wsl}
|
||||||
|
// is the local Windows path translated to /mnt/<drive>/... form so wsl cp
|
||||||
|
// can read it. Args are POSIX-quoted because they reach a Linux shell.
|
||||||
|
r.copy = "wsl mkdir -p {remote_bundle} && wsl cp -r {bundle_wsl}/. {remote_bundle}/";
|
||||||
|
r.exec = "wsl bash -c \"cd {remote_bundle} && ./{bin_name} {args}\"";
|
||||||
|
r.cleanup = "wsl rm -rf {remote_bundle}";
|
||||||
|
// `where wsl` matches the host-shell convention; on a non-Windows host
|
||||||
|
// it fails (no `where` command), which correctly skips this runner.
|
||||||
|
r.probe = "where wsl";
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
TestRunner TestRunner::Cmd(std::string command) {
|
||||||
|
TestRunner r;
|
||||||
|
r.name = std::format("cmd:{}", command);
|
||||||
|
r.argsShell = Shell::Host;
|
||||||
|
r.exec = std::format("{} {{bin}} {{args}}", command);
|
||||||
|
#ifdef _WIN32
|
||||||
|
r.probe = std::format("where {}", command);
|
||||||
|
#else
|
||||||
|
r.probe = std::format("which {}", command);
|
||||||
|
#endif
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -225,7 +322,7 @@ namespace {
|
||||||
}
|
}
|
||||||
if (parts.empty()) return std::nullopt;
|
if (parts.empty()) return std::nullopt;
|
||||||
if (parts[0] == "local") return TestRunner::Local();
|
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() == 2) return TestRunner::Ssh(parts[1]);
|
||||||
if (parts[0] == "ssh" && parts.size() >= 3) {
|
if (parts[0] == "ssh" && parts.size() >= 3) {
|
||||||
std::string remote;
|
std::string remote;
|
||||||
|
|
@ -244,9 +341,34 @@ namespace {
|
||||||
}
|
}
|
||||||
return TestRunner::SshWin(parts[1], remote);
|
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(
|
throw std::runtime_error(std::format(
|
||||||
"TestRunner::FromEnv: unrecognized runner spec '{}'", spec));
|
"TestRunner::FromSpec: unrecognized runner spec '{}'", spec));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// C:\Users\jorij -> /mnt/c/Users/jorij. Idempotent on paths that don't
|
||||||
|
// start with a drive letter, so callers can compute it unconditionally.
|
||||||
|
std::string WindowsPathToWsl(std::string_view p) {
|
||||||
|
if (p.size() < 2 || p[1] != ':') return std::string(p);
|
||||||
|
char drive = static_cast<char>(std::tolower(static_cast<unsigned char>(p[0])));
|
||||||
|
std::string out = std::format("/mnt/{}", drive);
|
||||||
|
for (std::size_t i = 2; i < p.size(); ++i) {
|
||||||
|
out.push_back(p[i] == '\\' ? '/' : p[i]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<TestRunner> TestRunner::FromSpec(std::string_view spec) {
|
||||||
|
return ParseRunnerSpec(spec);
|
||||||
}
|
}
|
||||||
|
|
||||||
TestRunner TestRunner::FromEnv(std::string_view target, TestRunner fallback) {
|
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;
|
result.name = test.config.name;
|
||||||
|
|
||||||
std::map<std::string, std::string> ph;
|
std::map<std::string, std::string> ph;
|
||||||
ph["{args}"] = JoinAndQuoteArgs(test.args);
|
ph["{args}"] = JoinAndQuoteArgs(test.args, test.runner.argsShell);
|
||||||
ph["{bin_name}"] = binary.filename().string();
|
ph["{bin_name}"] = binary.filename().string();
|
||||||
ph["{bundle}"] = binary.parent_path().string();
|
ph["{bundle}"] = binary.parent_path().string();
|
||||||
|
|
||||||
|
|
@ -271,8 +393,8 @@ TestResult Crafter::RunSingleTest(const Test& test, const fs::path& binary, std:
|
||||||
CommandResult r;
|
CommandResult r;
|
||||||
|
|
||||||
if (test.runner.exec.empty()) {
|
if (test.runner.exec.empty()) {
|
||||||
// Pure-local runner: spawn the binary directly.
|
// Pure-local runner: spawn the binary directly through the host shell.
|
||||||
std::string cmd = std::format("{} {}", ShellQuote(binary.string()), ph["{args}"]);
|
std::string cmd = std::format("{} {}", ShellQuoteHost(binary.string()), ph["{args}"]);
|
||||||
r = RunCommandWithTimeout(cmd, timeout);
|
r = RunCommandWithTimeout(cmd, timeout);
|
||||||
} else if (test.runner.copy.empty()) {
|
} else if (test.runner.copy.empty()) {
|
||||||
// Prefix runner (qemu-user, wsl, ...): templated exec wraps a local binary.
|
// 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(), '/', '\\');
|
std::replace(ph["{remote_bundle_win}"].begin(), ph["{remote_bundle_win}"].end(), '/', '\\');
|
||||||
ph["{bin_win}"] = ph["{bin}"];
|
ph["{bin_win}"] = ph["{bin}"];
|
||||||
std::replace(ph["{bin_win}"].begin(), ph["{bin_win}"].end(), '/', '\\');
|
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);
|
CommandResult cp = RunCommandWithTimeout(Substitute(test.runner.copy, ph), 5min);
|
||||||
if (cp.exitCode != 0) {
|
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;
|
if (r.timedOut) result.outcome = TestOutcome::Timeout;
|
||||||
else if (r.crashed) result.outcome = TestOutcome::Crash;
|
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 if (r.exitCode != 0) result.outcome = TestOutcome::Fail;
|
||||||
else result.outcome = TestOutcome::Pass;
|
else result.outcome = TestOutcome::Pass;
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
TestSummary Crafter::RunTests(Configuration& projectCfg, const RunTestsOptions& opts) {
|
namespace {
|
||||||
|
// Synthesize a Configuration for tests/<Name>/ folders that don't contain
|
||||||
|
// a project.cpp. Convention: cfg.path = the folder, cfg.name/outputName =
|
||||||
|
// folder basename, cfg.target = the run's targetFilter, cfg.type = exe.
|
||||||
|
// Sources: top-level *.cpp (excluding project.cpp) become implementations,
|
||||||
|
// interfaces/*.cppm become module interfaces (matching the layout used
|
||||||
|
// elsewhere in this codebase). Tests with deeper layouts, defines, or
|
||||||
|
// dependencies still need an explicit project.cpp.
|
||||||
|
Configuration SynthesizeTest(const fs::path& dir, std::string_view target) {
|
||||||
|
Configuration cfg;
|
||||||
|
cfg.path = dir;
|
||||||
|
cfg.name = dir.filename().string();
|
||||||
|
cfg.outputName = cfg.name;
|
||||||
|
cfg.target = std::string(target);
|
||||||
|
cfg.type = ConfigurationType::Executable;
|
||||||
|
|
||||||
|
std::vector<fs::path> impls;
|
||||||
|
for (auto& e : fs::directory_iterator(dir)) {
|
||||||
|
if (!e.is_regular_file()) continue;
|
||||||
|
auto p = e.path();
|
||||||
|
if (p.extension() != ".cpp") continue;
|
||||||
|
if (p.filename() == "project.cpp") continue;
|
||||||
|
impls.push_back(p.stem());
|
||||||
|
}
|
||||||
|
std::ranges::sort(impls);
|
||||||
|
|
||||||
|
std::vector<fs::path> ifaces;
|
||||||
|
fs::path interfacesDir = dir / "interfaces";
|
||||||
|
if (fs::exists(interfacesDir) && fs::is_directory(interfacesDir)) {
|
||||||
|
for (auto& e : fs::directory_iterator(interfacesDir)) {
|
||||||
|
if (!e.is_regular_file()) continue;
|
||||||
|
auto p = e.path();
|
||||||
|
if (p.extension() != ".cppm") continue;
|
||||||
|
ifaces.push_back(fs::path("interfaces") / p.stem());
|
||||||
|
}
|
||||||
|
std::ranges::sort(ifaces);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (impls.empty() && ifaces.empty()) {
|
||||||
|
throw std::runtime_error(std::format(
|
||||||
|
"no .cpp or interfaces/*.cppm files found in {}", dir.string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TestSummary Crafter::RunTests(Configuration& projectCfg, const RunTestsOptions& opts, std::span<const std::string_view> projectArgs) {
|
||||||
TestSummary summary;
|
TestSummary summary;
|
||||||
|
std::vector<TestResult> discoveryFailures;
|
||||||
|
|
||||||
|
// Auto-discover tests one layer deep: each <project>/tests/<Name>/ folder
|
||||||
|
// is a test. If it contains project.cpp, that's loaded for full control;
|
||||||
|
// otherwise the Configuration is synthesized from the folder contents.
|
||||||
|
// Folders whose name starts with '_' or '.' are skipped (so tests/_shared/
|
||||||
|
// holds cross-test code without becoming a test). Each project.cpp receives
|
||||||
|
// the same args the root project did, so --target=... propagates through.
|
||||||
|
//
|
||||||
|
// Discovery is keyed off cwd (= the project root, since crafter-build loads
|
||||||
|
// ./project.cpp), not projectCfg.path: tests live at the project root even
|
||||||
|
// when projectCfg.path points at a subdirectory like "./src/" or "./lib/".
|
||||||
|
fs::path testsDir = fs::current_path() / "tests";
|
||||||
|
if (fs::exists(testsDir) && fs::is_directory(testsDir)) {
|
||||||
|
struct TestEntry { fs::path dir; fs::path pcpp; }; // pcpp empty = synth
|
||||||
|
std::vector<TestEntry> entries;
|
||||||
|
for (auto& entry : fs::directory_iterator(testsDir)) {
|
||||||
|
if (!entry.is_directory()) continue;
|
||||||
|
auto stem = entry.path().filename().string();
|
||||||
|
if (stem.empty() || stem[0] == '_' || stem[0] == '.') continue;
|
||||||
|
TestEntry te;
|
||||||
|
te.dir = entry.path();
|
||||||
|
auto pcpp = te.dir / "project.cpp";
|
||||||
|
if (fs::exists(pcpp)) te.pcpp = pcpp;
|
||||||
|
entries.push_back(std::move(te));
|
||||||
|
}
|
||||||
|
std::ranges::sort(entries, [](auto& a, auto& b) { return a.dir < b.dir; });
|
||||||
|
|
||||||
|
// Inject --target=<filter> into the args we hand each fixture so its
|
||||||
|
// CrafterBuildProject can default to the run's target. The CLI's own
|
||||||
|
// --target=... propagates through projectArgs already; this only
|
||||||
|
// appends when missing so an explicit user choice wins.
|
||||||
|
std::string targetArg = std::format("--target={}", opts.targetFilter);
|
||||||
|
std::vector<std::string_view> fixtureArgs(projectArgs.begin(), projectArgs.end());
|
||||||
|
bool hasTarget = std::ranges::any_of(fixtureArgs, [](std::string_view a) {
|
||||||
|
return a.starts_with("--target=");
|
||||||
|
});
|
||||||
|
if (!hasTarget) fixtureArgs.push_back(targetArg);
|
||||||
|
|
||||||
|
for (auto& te : entries) {
|
||||||
|
Test t;
|
||||||
|
try {
|
||||||
|
if (!te.pcpp.empty()) {
|
||||||
|
t.config = LoadProject(te.pcpp, fixtureArgs);
|
||||||
|
} else {
|
||||||
|
t.config = SynthesizeTest(te.dir, opts.targetFilter);
|
||||||
|
}
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
// A broken fixture shouldn't kill the whole run. Surface as a
|
||||||
|
// Fail and let other tests proceed.
|
||||||
|
TestResult r;
|
||||||
|
r.name = te.dir.filename().string();
|
||||||
|
r.outcome = TestOutcome::Fail;
|
||||||
|
r.exitCode = -1;
|
||||||
|
r.output = !te.pcpp.empty()
|
||||||
|
? std::format("project.cpp failed to load: {}", e.what())
|
||||||
|
: std::format("test discovery failed: {}", e.what());
|
||||||
|
discoveryFailures.push_back(std::move(r));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (t.config.target != opts.targetFilter) continue;
|
||||||
|
t.runner = TestRunner::FromEnv(t.config.target, TestRunner::Local());
|
||||||
|
if (opts.runnerOverride) {
|
||||||
|
if (auto r = TestRunner::FromSpec(*opts.runnerOverride)) {
|
||||||
|
t.runner = std::move(*r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
projectCfg.tests.push_back(std::move(t));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
std::vector<Test*> filtered;
|
std::vector<Test*> filtered;
|
||||||
filtered.reserve(projectCfg.tests.size());
|
filtered.reserve(projectCfg.tests.size());
|
||||||
|
|
@ -336,17 +581,28 @@ TestSummary Crafter::RunTests(Configuration& projectCfg, const RunTestsOptions&
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.listOnly) {
|
if (opts.listOnly) {
|
||||||
|
for (auto& r : discoveryFailures) {
|
||||||
|
std::println("{} (project.cpp broken)", r.name);
|
||||||
|
}
|
||||||
for (auto* t : filtered) {
|
for (auto* t : filtered) {
|
||||||
std::println("{}", t->config.name);
|
std::println("{}", t->config.name);
|
||||||
}
|
}
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filtered.empty()) {
|
if (filtered.empty() && discoveryFailures.empty()) {
|
||||||
std::println("No tests matched.");
|
std::println("No tests matched.");
|
||||||
return summary;
|
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
|
int jobs = opts.jobs > 0
|
||||||
? opts.jobs
|
? opts.jobs
|
||||||
: std::max(1u, std::thread::hardware_concurrency());
|
: 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()) {
|
if (auto it = probeCache.find(runner.name); it != probeCache.end()) {
|
||||||
return it->second;
|
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;
|
probeCache[runner.name] = ok;
|
||||||
return ok;
|
return ok;
|
||||||
};
|
};
|
||||||
|
|
@ -446,6 +704,10 @@ TestSummary Crafter::RunTests(Configuration& projectCfg, const RunTestsOptions&
|
||||||
}
|
}
|
||||||
threads.clear(); // joins all jthreads
|
threads.clear(); // joins all jthreads
|
||||||
|
|
||||||
|
for (auto& r : discoveryFailures) {
|
||||||
|
results.push_back(std::move(r));
|
||||||
|
}
|
||||||
|
|
||||||
for (auto& r : results) {
|
for (auto& r : results) {
|
||||||
switch (r.outcome) {
|
switch (r.outcome) {
|
||||||
case TestOutcome::Pass: summary.passed++; break;
|
case TestOutcome::Pass: summary.passed++; break;
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,12 @@ export namespace Crafter {
|
||||||
struct Test;
|
struct Test;
|
||||||
|
|
||||||
struct TestRunner {
|
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 copy;
|
||||||
std::string exec;
|
std::string exec;
|
||||||
std::string cleanup;
|
std::string cleanup;
|
||||||
|
|
@ -57,13 +63,16 @@ export namespace Crafter {
|
||||||
// is available; non-zero = skip every Test using this runner with a
|
// is available; non-zero = skip every Test using this runner with a
|
||||||
// "runner unavailable" message. Empty = always available (e.g., Local).
|
// "runner unavailable" message. Empty = always available (e.g., Local).
|
||||||
std::string probe;
|
std::string probe;
|
||||||
|
Shell argsShell = Shell::Host;
|
||||||
|
|
||||||
bool IsLocal() const { return exec.empty(); }
|
bool IsLocal() const { return exec.empty(); }
|
||||||
|
|
||||||
static CRAFTER_API TestRunner Local();
|
static CRAFTER_API TestRunner Local();
|
||||||
static CRAFTER_API TestRunner Ssh(std::string host, std::string remoteDir = "/tmp/crafter-tests");
|
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 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<TestRunner> FromSpec(std::string_view spec);
|
||||||
static CRAFTER_API TestRunner FromEnv(std::string_view target, TestRunner fallback = Local());
|
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<fs::path, std::shared_future<BuildResult>>& depResults, std::mutex& depMutex);
|
CRAFTER_API BuildResult Build(Configuration& config, std::unordered_map<fs::path, std::shared_future<BuildResult>>& depResults, std::mutex& depMutex);
|
||||||
|
|
||||||
CRAFTER_API int Run(int argc, char** argv);
|
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);
|
||||||
}
|
}
|
||||||
|
|
@ -53,5 +53,6 @@ export namespace Crafter {
|
||||||
|
|
||||||
CRAFTER_API ExternalBuildResult BuildExternal(
|
CRAFTER_API ExternalBuildResult BuildExternal(
|
||||||
const ExternalDependency& dep,
|
const ExternalDependency& dep,
|
||||||
|
std::string_view target,
|
||||||
std::atomic<bool>& cancelled);
|
std::atomic<bool>& cancelled);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,4 +39,8 @@ namespace Crafter {
|
||||||
export CRAFTER_API CommandResult RunCommandWithTimeout(std::string_view command, std::chrono::seconds timeout);
|
export CRAFTER_API CommandResult RunCommandWithTimeout(std::string_view command, std::chrono::seconds timeout);
|
||||||
std::string GetBaseCommand(const Configuration& config);
|
std::string GetBaseCommand(const Configuration& config);
|
||||||
export CRAFTER_API Configuration LoadProject(const fs::path& projectFile, std::span<const std::string_view> args);
|
export CRAFTER_API Configuration LoadProject(const fs::path& projectFile, std::span<const std::string_view> args);
|
||||||
|
// Resolves the directory holding distributed runtime assets (Crafter.Build
|
||||||
|
// module sources, wasi-runtime/, etc). Honors CRAFTER_BUILD_HOME; otherwise
|
||||||
|
// derives <prefix>/share/crafter-build from the running executable's path.
|
||||||
|
export CRAFTER_API fs::path GetCrafterBuildHome();
|
||||||
}
|
}
|
||||||
|
|
@ -29,6 +29,19 @@ export namespace Crafter {
|
||||||
int jobs = 0;
|
int jobs = 0;
|
||||||
std::optional<std::chrono::seconds> timeoutOverride;
|
std::optional<std::chrono::seconds> timeoutOverride;
|
||||||
bool listOnly = false;
|
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=<spec>: applies to every test in the run.
|
||||||
|
// Target scoping is unnecessary because targetFilter ensures the run
|
||||||
|
// contains only one target's tests.
|
||||||
|
std::optional<std::string> runnerOverride;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct TestSummary {
|
struct TestSummary {
|
||||||
|
|
@ -41,6 +54,15 @@ export namespace Crafter {
|
||||||
bool AllPassed() const { return failed == 0 && crashed == 0 && timedOut == 0; }
|
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<const std::string_view> projectArgs = {});
|
||||||
CRAFTER_API TestResult RunSingleTest(const Test& test, const std::filesystem::path& binary, std::chrono::seconds timeout);
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
152
project.cpp
152
project.cpp
|
|
@ -5,15 +5,17 @@ using namespace Crafter;
|
||||||
|
|
||||||
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
|
||||||
bool debug = false;
|
bool debug = false;
|
||||||
|
std::string target = "x86_64-pc-linux-gnu";
|
||||||
for (std::string_view arg : args) {
|
for (std::string_view arg : args) {
|
||||||
if (arg == "--debug") debug = true;
|
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<Configuration>();
|
static auto crafterBuildLib = std::make_unique<Configuration>();
|
||||||
crafterBuildLib->path = "./";
|
crafterBuildLib->path = "./";
|
||||||
crafterBuildLib->name = "crafter.build-lib";
|
crafterBuildLib->name = "crafter.build-lib";
|
||||||
crafterBuildLib->outputName = "crafter-build";
|
crafterBuildLib->outputName = "crafter-build";
|
||||||
crafterBuildLib->target = "x86_64-pc-linux-gnu";
|
crafterBuildLib->target = target;
|
||||||
crafterBuildLib->type = ConfigurationType::LibraryStatic;
|
crafterBuildLib->type = ConfigurationType::LibraryStatic;
|
||||||
crafterBuildLib->debug = debug;
|
crafterBuildLib->debug = debug;
|
||||||
{
|
{
|
||||||
|
|
@ -44,6 +46,12 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
glslang.source.branch = "main";
|
glslang.source.branch = "main";
|
||||||
glslang.builder = ExternalBuilder::CMake;
|
glslang.builder = ExternalBuilder::CMake;
|
||||||
glslang.options = { "-DENABLE_OPT=OFF" };
|
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.includeDirs = { "" };
|
||||||
glslang.libs = { "SPIRV", "GenericCodeGen", "glslang", "OSDependent", "MachineIndependent", "glslang-default-resource-limits" };
|
glslang.libs = { "SPIRV", "GenericCodeGen", "glslang", "OSDependent", "MachineIndependent", "glslang-default-resource-limits" };
|
||||||
|
|
||||||
|
|
@ -51,7 +59,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
cfg.path = "./";
|
cfg.path = "./";
|
||||||
cfg.name = "crafter.build-exe";
|
cfg.name = "crafter.build-exe";
|
||||||
cfg.outputName = "crafter-build";
|
cfg.outputName = "crafter-build";
|
||||||
cfg.target = "x86_64-pc-linux-gnu";
|
cfg.target = target;
|
||||||
cfg.type = ConfigurationType::Executable;
|
cfg.type = ConfigurationType::Executable;
|
||||||
cfg.debug = debug;
|
cfg.debug = debug;
|
||||||
cfg.dependencies = { crafterBuildLib.get() };
|
cfg.dependencies = { crafterBuildLib.get() };
|
||||||
|
|
@ -60,142 +68,10 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
std::array<fs::path, 1> implementations = { "implementations/main" };
|
std::array<fs::path, 1> implementations = { "implementations/main" };
|
||||||
cfg.GetInterfacesAndImplementations(interfaces, implementations);
|
cfg.GetInterfacesAndImplementations(interfaces, implementations);
|
||||||
}
|
}
|
||||||
cfg.linkFlags.push_back("-Wl,--export-dynamic");
|
if (target == "x86_64-pc-linux-gnu") {
|
||||||
cfg.linkFlags.push_back("-ldl");
|
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<std::unique_ptr<Configuration>> testLibPool;
|
|
||||||
|
|
||||||
struct TargetRunner {
|
|
||||||
std::string target;
|
|
||||||
TestRunner runner;
|
|
||||||
std::vector<std::string> extraLinkFlags;
|
|
||||||
};
|
|
||||||
std::vector<TargetRunner> 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<fs::path, 0> ifaces = {};
|
|
||||||
std::array<fs::path, 1> 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<fs::path, 1> ifaces = { "interfaces/Greeter" };
|
|
||||||
std::array<fs::path, 1> 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<fs::path, 0> ifaces = {};
|
|
||||||
std::array<fs::path, 1> 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<Configuration>();
|
|
||||||
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<fs::path, 1> libIfaces = { "Foo" };
|
|
||||||
std::array<fs::path, 0> 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<fs::path, 0> mainIfaces = {};
|
|
||||||
std::array<fs::path, 1> mainImpls = { "main" };
|
|
||||||
t.config.GetInterfacesAndImplementations(mainIfaces, mainImpls);
|
|
||||||
});
|
|
||||||
|
|
||||||
auto makeDiamondLib = [&](std::string_view dir, std::string_view modName,
|
|
||||||
std::string_view target, std::span<Configuration*> deps) {
|
|
||||||
auto lib = std::make_unique<Configuration>();
|
|
||||||
lib->path = std::format("tests/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<fs::path, 1> ifaces = { fs::path(modName) };
|
|
||||||
std::array<fs::path, 0> 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<fs::path, 0> ifaces = {};
|
|
||||||
std::array<fs::path, 1> 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<fs::path, 0> empty = {};
|
|
||||||
std::array<fs::path, 1> impls = { fs::path("tests") / name };
|
|
||||||
t.config.GetInterfacesAndImplementations(empty, impls);
|
|
||||||
cfg.tests.push_back(std::move(t));
|
|
||||||
};
|
|
||||||
|
|
||||||
addOuterDriverTest("Incremental");
|
|
||||||
addOuterDriverTest("BuildError");
|
|
||||||
addOuterDriverTest("Libraries");
|
|
||||||
addOuterDriverTest("RunnerClassification");
|
|
||||||
addOuterDriverTest("QemuUser");
|
|
||||||
addOuterDriverTest("SshRunner");
|
|
||||||
addOuterDriverTest("CrossArchAarch64");
|
|
||||||
addOuterDriverTest("WindowsViaSsh");
|
|
||||||
|
|
||||||
return cfg;
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
41
tests/BuildError/BuildError.cpp
Normal file
41
tests/BuildError/BuildError.cpp
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
/*
|
||||||
|
Crafter® Build
|
||||||
|
Copyright (C) 2026 Catcrafts®
|
||||||
|
Catcrafts.net
|
||||||
|
|
||||||
|
LGPL-3.0-only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
#include "../_shared/TestUtil.h"
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using namespace TestUtil;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
try {
|
||||||
|
fs::path src = fs::current_path() / "tests" / "BuildError" / "inner";
|
||||||
|
Configuration cfg = LoadFixture("BuildError", src);
|
||||||
|
fs::path work = fs::current_path();
|
||||||
|
|
||||||
|
auto br = BuildOnce(cfg);
|
||||||
|
if (br.result.empty()) {
|
||||||
|
std::println(std::cerr, "expected build failure, got success");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (br.result.find("undefined_symbol_xyzzy_oqv") == std::string::npos) {
|
||||||
|
std::println(std::cerr, "diagnostic missing unresolved-name reference:\n{}", br.result);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
fs::path artifact = work / "bin" / "broken-x86_64-pc-linux-gnu-native" / "broken";
|
||||||
|
if (fs::exists(artifact)) {
|
||||||
|
std::println(std::cerr, "artifact unexpectedly produced at {}", artifact.string());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
std::println(std::cerr, "test exception: {}", e.what());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
tests/BuildError/project.cpp
Normal file
20
tests/BuildError/project.cpp
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||||
|
Configuration cfg;
|
||||||
|
cfg.path = "tests/BuildError/";
|
||||||
|
cfg.name = "BuildError";
|
||||||
|
cfg.outputName = "BuildError";
|
||||||
|
cfg.target = "x86_64-pc-linux-gnu";
|
||||||
|
cfg.type = ConfigurationType::Executable;
|
||||||
|
cfg.dependencies = { ParentLib("crafter.build-lib") };
|
||||||
|
cfg.linkFlags.push_back("-Wl,--export-dynamic");
|
||||||
|
cfg.linkFlags.push_back("-ldl");
|
||||||
|
std::array<fs::path, 0> ifaces = {};
|
||||||
|
std::array<fs::path, 1> impls = { "BuildError" };
|
||||||
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
@ -6,22 +6,23 @@ Catcrafts.net
|
||||||
LGPL-3.0-only.
|
LGPL-3.0-only.
|
||||||
|
|
||||||
End-to-end cross-arch build through the V2 pipeline:
|
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 inner 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 Arch Linux ARM rootfs at /opt/aarch64-rootfs. crafter.build-lib
|
||||||
the C++ source (with the libc++ std module from the sysroot), produces a real
|
cross-compiles the C++ source (with the libc++ std module from the sysroot),
|
||||||
aarch64 ELF, and qemu-aarch64 (with QEMU_LD_PREFIX pointing at the sysroot so it
|
produces a real aarch64 ELF, and qemu-aarch64 (with QEMU_LD_PREFIX pointing at
|
||||||
can find ld-linux-aarch64.so.1) executes it.
|
the sysroot so it can find ld-linux-aarch64.so.1) executes it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import std;
|
import std;
|
||||||
#include "TestUtil.h"
|
import Crafter.Build;
|
||||||
|
#include "../_shared/TestUtil.h"
|
||||||
namespace fs = std::filesystem;
|
namespace fs = std::filesystem;
|
||||||
using namespace TestUtil;
|
using namespace TestUtil;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
bool ToolPresent(std::string_view name) {
|
bool ToolPresent(std::string_view name) {
|
||||||
std::string cmd = std::format("which {} > /dev/null 2>&1", name);
|
return std::system(std::format("which {} > /dev/null 2>&1", name).c_str()) == 0;
|
||||||
return std::system(cmd.c_str()) == 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,32 +30,23 @@ int main() {
|
||||||
try {
|
try {
|
||||||
const fs::path sysroot = "/opt/aarch64-rootfs";
|
const fs::path sysroot = "/opt/aarch64-rootfs";
|
||||||
if (!fs::exists(sysroot / "usr/share/libc++/v1/std.cppm")) {
|
if (!fs::exists(sysroot / "usr/share/libc++/v1/std.cppm")) {
|
||||||
std::println("(skipped: aarch64 sysroot missing at {} — see README)", sysroot.string());
|
Skip(std::format("aarch64 sysroot missing at {} — see README", sysroot.string()));
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (!ToolPresent("qemu-aarch64")) {
|
|
||||||
std::println("(skipped: qemu-aarch64 not on PATH)");
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
if (!ToolPresent("qemu-aarch64")) Skip("qemu-aarch64 not on PATH");
|
||||||
|
|
||||||
fs::path projectRoot = fs::current_path();
|
fs::path src = fs::current_path() / "tests" / "CrossArchAarch64" / "inner";
|
||||||
fs::path src = projectRoot / "tests" / "fixtures" / "cross-arch-aarch64";
|
Configuration cfg = LoadFixture("CrossArchAarch64", src);
|
||||||
fs::path crafterBuild = projectRoot / "bin" / "crafter-build";
|
fs::path work = fs::current_path();
|
||||||
|
|
||||||
fs::path work = CopyFixtureToTemp("CrossArchAarch64", src);
|
auto br = BuildOnce(cfg);
|
||||||
|
if (!br.result.empty()) {
|
||||||
// Build through the V2 pipeline. The fixture's project.cpp pins
|
std::println(std::cerr, "build failed:\n{}", br.result);
|
||||||
// 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);
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
fs::path artifact = work / "bin" / "aarch-hello-aarch64-linux-gnu-armv8-a" / "aarch-hello";
|
fs::path artifact = work / "bin" / "aarch-hello-aarch64-linux-gnu-armv8-a" / "aarch-hello";
|
||||||
if (!fs::exists(artifact)) {
|
if (!fs::exists(artifact)) {
|
||||||
std::println(std::cerr, "expected artifact missing at {}\nbuild log:\n{}",
|
std::println(std::cerr, "expected artifact missing at {}", artifact.string());
|
||||||
artifact.string(), build.output);
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -18,7 +18,6 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>)
|
||||||
std::array<fs::path, 1> impls = { "main" };
|
std::array<fs::path, 1> impls = { "main" };
|
||||||
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
|
||||||
cfg.linkFlags.push_back("-fuse-ld=lld");
|
|
||||||
|
|
||||||
return cfg;
|
return cfg;
|
||||||
}
|
}
|
||||||
20
tests/CrossArchAarch64/project.cpp
Normal file
20
tests/CrossArchAarch64/project.cpp
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||||
|
Configuration cfg;
|
||||||
|
cfg.path = "tests/CrossArchAarch64/";
|
||||||
|
cfg.name = "CrossArchAarch64";
|
||||||
|
cfg.outputName = "CrossArchAarch64";
|
||||||
|
cfg.target = "x86_64-pc-linux-gnu";
|
||||||
|
cfg.type = ConfigurationType::Executable;
|
||||||
|
cfg.dependencies = { ParentLib("crafter.build-lib") };
|
||||||
|
cfg.linkFlags.push_back("-Wl,--export-dynamic");
|
||||||
|
cfg.linkFlags.push_back("-ldl");
|
||||||
|
std::array<fs::path, 0> ifaces = {};
|
||||||
|
std::array<fs::path, 1> impls = { "CrossArchAarch64" };
|
||||||
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
36
tests/CrossProjectModule/project.cpp
Normal file
36
tests/CrossProjectModule/project.cpp
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
|
||||||
|
std::string target = "x86_64-pc-linux-gnu";
|
||||||
|
for (auto a : args) {
|
||||||
|
if (a.starts_with("--target=")) target = std::string(a.substr(9));
|
||||||
|
}
|
||||||
|
|
||||||
|
static auto fooLib = std::make_unique<Configuration>();
|
||||||
|
fooLib->path = "tests/CrossProjectModule/lib/";
|
||||||
|
fooLib->name = std::format("Foo-{}", target);
|
||||||
|
fooLib->outputName = "Foo";
|
||||||
|
fooLib->target = target;
|
||||||
|
fooLib->type = ConfigurationType::LibraryStatic;
|
||||||
|
{
|
||||||
|
std::array<fs::path, 1> ifaces = { "Foo" };
|
||||||
|
std::array<fs::path, 0> impls = {};
|
||||||
|
fooLib->GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
}
|
||||||
|
|
||||||
|
Configuration cfg;
|
||||||
|
cfg.path = "tests/CrossProjectModule/";
|
||||||
|
cfg.name = "CrossProjectModule";
|
||||||
|
cfg.outputName = "cross-app";
|
||||||
|
cfg.target = target;
|
||||||
|
cfg.type = ConfigurationType::Executable;
|
||||||
|
cfg.dependencies = { fooLib.get() };
|
||||||
|
|
||||||
|
std::array<fs::path, 0> ifaces = {};
|
||||||
|
std::array<fs::path, 1> impls = { "main" };
|
||||||
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
24
tests/Defines/project.cpp
Normal file
24
tests/Defines/project.cpp
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
|
||||||
|
std::string target = "x86_64-pc-linux-gnu";
|
||||||
|
for (auto a : args) {
|
||||||
|
if (a.starts_with("--target=")) target = std::string(a.substr(9));
|
||||||
|
}
|
||||||
|
|
||||||
|
Configuration cfg;
|
||||||
|
cfg.path = "tests/Defines/";
|
||||||
|
cfg.name = "Defines";
|
||||||
|
cfg.outputName = "defines-app";
|
||||||
|
cfg.target = target;
|
||||||
|
cfg.type = ConfigurationType::Executable;
|
||||||
|
cfg.defines.push_back({"CRAFTER_TEST_FOO", "42"});
|
||||||
|
|
||||||
|
std::array<fs::path, 0> ifaces = {};
|
||||||
|
std::array<fs::path, 1> impls = { "main" };
|
||||||
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
49
tests/Diamond/project.cpp
Normal file
49
tests/Diamond/project.cpp
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
std::unique_ptr<Configuration> MakeLib(std::string_view dir, std::string_view modName,
|
||||||
|
std::string_view target,
|
||||||
|
std::span<Configuration*> deps) {
|
||||||
|
auto lib = std::make_unique<Configuration>();
|
||||||
|
lib->path = std::format("tests/Diamond/{}/", dir);
|
||||||
|
lib->name = std::format("{}-Diamond-{}", modName, target);
|
||||||
|
lib->outputName = std::string(modName);
|
||||||
|
lib->target = std::string(target);
|
||||||
|
lib->type = ConfigurationType::LibraryStatic;
|
||||||
|
lib->dependencies.assign(deps.begin(), deps.end());
|
||||||
|
std::array<fs::path, 1> ifaces = { fs::path(modName) };
|
||||||
|
std::array<fs::path, 0> impls = {};
|
||||||
|
lib->GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
return lib;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
|
||||||
|
std::string target = "x86_64-pc-linux-gnu";
|
||||||
|
for (auto a : args) {
|
||||||
|
if (a.starts_with("--target=")) target = std::string(a.substr(9));
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::unique_ptr<Configuration> X, B, C;
|
||||||
|
X = MakeLib("X", "X", target, {});
|
||||||
|
Configuration* xDeps[] = { X.get() };
|
||||||
|
B = MakeLib("B", "B", target, xDeps);
|
||||||
|
C = MakeLib("C", "C", target, xDeps);
|
||||||
|
Configuration* mainDeps[] = { B.get(), C.get() };
|
||||||
|
|
||||||
|
Configuration cfg;
|
||||||
|
cfg.path = "tests/Diamond/";
|
||||||
|
cfg.name = "Diamond";
|
||||||
|
cfg.outputName = "diamond-app";
|
||||||
|
cfg.target = target;
|
||||||
|
cfg.type = ConfigurationType::Executable;
|
||||||
|
cfg.dependencies.assign(mainDeps, mainDeps + 2);
|
||||||
|
|
||||||
|
std::array<fs::path, 0> ifaces = {};
|
||||||
|
std::array<fs::path, 1> impls = { "main" };
|
||||||
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
@ -7,28 +7,28 @@ LGPL-3.0-only.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import std;
|
import std;
|
||||||
#include "TestUtil.h"
|
import Crafter.Build;
|
||||||
|
#include "../_shared/TestUtil.h"
|
||||||
namespace fs = std::filesystem;
|
namespace fs = std::filesystem;
|
||||||
using namespace std::chrono_literals;
|
using namespace std::chrono_literals;
|
||||||
using namespace TestUtil;
|
using namespace TestUtil;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
try {
|
try {
|
||||||
fs::path projectRoot = fs::current_path();
|
fs::path src = fs::current_path() / "tests" / "Incremental" / "inner";
|
||||||
fs::path src = projectRoot / "tests" / "fixtures" / "incremental";
|
Configuration cfg = LoadFixture("Incremental", src);
|
||||||
fs::path crafterBuild = projectRoot / "bin" / "crafter-build";
|
fs::path work = fs::current_path();
|
||||||
|
|
||||||
fs::path work = CopyFixtureToTemp("Incremental", src);
|
auto cold = BuildOnce(cfg);
|
||||||
fs::path buildDir = work / "build" / "hello-mod-x86_64-pc-linux-gnu-native";
|
if (!cold.result.empty()) {
|
||||||
fs::path greeterObj = buildDir / "Greeter.o";
|
std::println(std::cerr, "cold build failed:\n{}", cold.result);
|
||||||
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);
|
|
||||||
return 1;
|
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)) {
|
if (!fs::exists(greeterObj) || !fs::exists(mainObj)) {
|
||||||
std::println(std::cerr, "expected .o files missing after cold build");
|
std::println(std::cerr, "expected .o files missing after cold build");
|
||||||
return 1;
|
return 1;
|
||||||
|
|
@ -36,35 +36,32 @@ int main() {
|
||||||
auto greeter_t1 = fs::last_write_time(greeterObj);
|
auto greeter_t1 = fs::last_write_time(greeterObj);
|
||||||
auto main_t1 = fs::last_write_time(mainObj);
|
auto main_t1 = fs::last_write_time(mainObj);
|
||||||
|
|
||||||
// 2. no-op rebuild: nothing should be regenerated
|
auto noop = BuildOnce(cfg);
|
||||||
if (auto r = RunInDir(work, buildCmd); r.exitCode != 0) {
|
if (!noop.result.empty()) {
|
||||||
std::println(std::cerr, "no-op rebuild failed (rc={}):\n{}", r.exitCode, r.output);
|
std::println(std::cerr, "no-op rebuild failed:\n{}", noop.result);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
auto greeter_t2 = fs::last_write_time(greeterObj);
|
if (fs::last_write_time(greeterObj) != greeter_t1) {
|
||||||
auto main_t2 = fs::last_write_time(mainObj);
|
|
||||||
if (greeter_t2 != greeter_t1) {
|
|
||||||
std::println(std::cerr, "no-op rebuild regenerated Greeter.o");
|
std::println(std::cerr, "no-op rebuild regenerated Greeter.o");
|
||||||
return 1;
|
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");
|
std::println(std::cerr, "no-op rebuild regenerated main_impl.o");
|
||||||
return 1;
|
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);
|
fs::last_write_time(work / "main.cpp", std::chrono::file_clock::now() + 2s);
|
||||||
if (auto r = RunInDir(work, buildCmd); r.exitCode != 0) {
|
auto touched = BuildOnce(cfg);
|
||||||
std::println(std::cerr, "rebuild after touch failed (rc={}):\n{}", r.exitCode, r.output);
|
if (!touched.result.empty()) {
|
||||||
|
std::println(std::cerr, "rebuild after touch failed:\n{}", touched.result);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
auto greeter_t3 = fs::last_write_time(greeterObj);
|
if (fs::last_write_time(greeterObj) != greeter_t1) {
|
||||||
auto main_t3 = fs::last_write_time(mainObj);
|
|
||||||
if (greeter_t3 != greeter_t1) {
|
|
||||||
std::println(std::cerr, "touching main.cpp unnecessarily rebuilt Greeter.o");
|
std::println(std::cerr, "touching main.cpp unnecessarily rebuilt Greeter.o");
|
||||||
return 1;
|
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");
|
std::println(std::cerr, "touching main.cpp did NOT rebuild main_impl.o");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
20
tests/Incremental/project.cpp
Normal file
20
tests/Incremental/project.cpp
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||||
|
Configuration cfg;
|
||||||
|
cfg.path = "tests/Incremental/";
|
||||||
|
cfg.name = "Incremental";
|
||||||
|
cfg.outputName = "Incremental";
|
||||||
|
cfg.target = "x86_64-pc-linux-gnu";
|
||||||
|
cfg.type = ConfigurationType::Executable;
|
||||||
|
cfg.dependencies = { ParentLib("crafter.build-lib") };
|
||||||
|
cfg.linkFlags.push_back("-Wl,--export-dynamic");
|
||||||
|
cfg.linkFlags.push_back("-ldl");
|
||||||
|
std::array<fs::path, 0> ifaces = {};
|
||||||
|
std::array<fs::path, 1> impls = { "Incremental" };
|
||||||
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
@ -7,21 +7,21 @@ LGPL-3.0-only.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import std;
|
import std;
|
||||||
#include "TestUtil.h"
|
import Crafter.Build;
|
||||||
|
#include "../_shared/TestUtil.h"
|
||||||
namespace fs = std::filesystem;
|
namespace fs = std::filesystem;
|
||||||
using namespace TestUtil;
|
using namespace TestUtil;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
try {
|
try {
|
||||||
fs::path projectRoot = fs::current_path();
|
fs::path src = fs::current_path() / "tests" / "Libraries" / "inner";
|
||||||
fs::path src = projectRoot / "tests" / "fixtures" / "libraries";
|
Configuration cfg = LoadFixture("Libraries", src);
|
||||||
fs::path crafterBuild = projectRoot / "bin" / "crafter-build";
|
fs::path work = fs::current_path();
|
||||||
|
|
||||||
fs::path work = CopyFixtureToTemp("Libraries", src);
|
auto br = BuildOnce(cfg);
|
||||||
|
if (!br.result.empty()) {
|
||||||
auto build = RunInDir(work, std::format("'{}'", crafterBuild.string()));
|
std::println(std::cerr, "build failed:\n{}", br.result);
|
||||||
if (build.exitCode != 0) {
|
|
||||||
std::println(std::cerr, "build failed (rc={}):\n{}", build.exitCode, build.output);
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,14 +38,12 @@ int main() {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
if (!fs::exists(artifact)) {
|
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;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The exe linked against a dynamic .so needs LD_LIBRARY_PATH or rpath to find it.
|
// Linked against the dynamic .so which lives in greetlib/bin/...; set
|
||||||
// Build() already passes -Wl,-rpath,'$ORIGIN' for shared libs, but the .so lives in
|
// LD_LIBRARY_PATH explicitly for the artifact run.
|
||||||
// greetlib/bin/... while the exe lives in bin/libs-app-... — different dirs. Set
|
|
||||||
// LD_LIBRARY_PATH explicitly.
|
|
||||||
auto run = RunInDir(work, std::format(
|
auto run = RunInDir(work, std::format(
|
||||||
"LD_LIBRARY_PATH='{}' '{}'",
|
"LD_LIBRARY_PATH='{}' '{}'",
|
||||||
dynamicSO.parent_path().string(), artifact.string()));
|
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);
|
std::println(std::cerr, "artifact exited nonzero (rc={}):\n{}", run.exitCode, run.output);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (run.output != "hi=42\n") {
|
if (run.output != "hi=42\n") {
|
||||||
std::println(std::cerr, "output mismatch:\n expected: \"hi=42\\n\"\n got: {:?}", run.output);
|
std::println(std::cerr, "output mismatch:\n expected: \"hi=42\\n\"\n got: {:?}", run.output);
|
||||||
return 1;
|
return 1;
|
||||||
20
tests/Libraries/project.cpp
Normal file
20
tests/Libraries/project.cpp
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||||
|
Configuration cfg;
|
||||||
|
cfg.path = "tests/Libraries/";
|
||||||
|
cfg.name = "Libraries";
|
||||||
|
cfg.outputName = "Libraries";
|
||||||
|
cfg.target = "x86_64-pc-linux-gnu";
|
||||||
|
cfg.type = ConfigurationType::Executable;
|
||||||
|
cfg.dependencies = { ParentLib("crafter.build-lib") };
|
||||||
|
cfg.linkFlags.push_back("-Wl,--export-dynamic");
|
||||||
|
cfg.linkFlags.push_back("-ldl");
|
||||||
|
std::array<fs::path, 0> ifaces = {};
|
||||||
|
std::array<fs::path, 1> impls = { "Libraries" };
|
||||||
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
58
tests/QemuUser/QemuUser.cpp
Normal file
58
tests/QemuUser/QemuUser.cpp
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
/*
|
||||||
|
Crafter® Build
|
||||||
|
Copyright (C) 2026 Catcrafts®
|
||||||
|
Catcrafts.net
|
||||||
|
|
||||||
|
LGPL-3.0-only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
#include "../_shared/TestUtil.h"
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using namespace TestUtil;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
std::string PickQemu() {
|
||||||
|
if (const char* v = std::getenv("CRAFTER_TEST_QEMU"); v && *v) return v;
|
||||||
|
return "qemu-x86_64";
|
||||||
|
}
|
||||||
|
bool QemuPresent(const std::string& qemu) {
|
||||||
|
return std::system(std::format("which {} > /dev/null 2>&1", qemu).c_str()) == 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
try {
|
||||||
|
std::string qemu = PickQemu();
|
||||||
|
if (!QemuPresent(qemu)) Skip(std::format("{} not on PATH", qemu));
|
||||||
|
|
||||||
|
std::string spec = std::format("cmd:{}", qemu);
|
||||||
|
::setenv("CRAFTER_BUILD_RUNNER_x86_64_pc_linux_gnu", spec.c_str(), 1);
|
||||||
|
|
||||||
|
// Verify env-var translation independently of RunTests.
|
||||||
|
auto runner = TestRunner::FromEnv("x86_64-pc-linux-gnu", TestRunner::Local());
|
||||||
|
if (runner.name != spec) {
|
||||||
|
std::println(std::cerr, "FromEnv produced '{}', expected '{}'", runner.name, spec);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::path src = fs::current_path() / "tests" / "QemuUser" / "inner";
|
||||||
|
Configuration cfg = LoadFixture("QemuUser", src);
|
||||||
|
RunTestsOptions opts;
|
||||||
|
TestSummary summary = RunTests(cfg, opts);
|
||||||
|
|
||||||
|
if (summary.passed != 1 || summary.failed != 0 || summary.crashed != 0 ||
|
||||||
|
summary.timedOut != 0 || summary.skipped != 0) {
|
||||||
|
std::println(std::cerr,
|
||||||
|
"outcome counts mismatch: passed={} failed={} crashed={} timedOut={} skipped={}",
|
||||||
|
summary.passed, summary.failed, summary.crashed, summary.timedOut, summary.skipped);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
std::println(std::cerr, "test exception: {}", e.what());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
tests/QemuUser/project.cpp
Normal file
20
tests/QemuUser/project.cpp
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||||
|
Configuration cfg;
|
||||||
|
cfg.path = "tests/QemuUser/";
|
||||||
|
cfg.name = "QemuUser";
|
||||||
|
cfg.outputName = "QemuUser";
|
||||||
|
cfg.target = "x86_64-pc-linux-gnu";
|
||||||
|
cfg.type = ConfigurationType::Executable;
|
||||||
|
cfg.dependencies = { ParentLib("crafter.build-lib") };
|
||||||
|
cfg.linkFlags.push_back("-Wl,--export-dynamic");
|
||||||
|
cfg.linkFlags.push_back("-ldl");
|
||||||
|
std::array<fs::path, 0> ifaces = {};
|
||||||
|
std::array<fs::path, 1> impls = { "QemuUser" };
|
||||||
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
37
tests/RunnerClassification/RunnerClassification.cpp
Normal file
37
tests/RunnerClassification/RunnerClassification.cpp
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
/*
|
||||||
|
Crafter® Build
|
||||||
|
Copyright (C) 2026 Catcrafts®
|
||||||
|
Catcrafts.net
|
||||||
|
|
||||||
|
LGPL-3.0-only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
#include "../_shared/TestUtil.h"
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using namespace TestUtil;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
try {
|
||||||
|
fs::path src = fs::current_path() / "tests" / "RunnerClassification" / "inner";
|
||||||
|
Configuration cfg = LoadFixture("RunnerClassification", src);
|
||||||
|
|
||||||
|
RunTestsOptions opts;
|
||||||
|
opts.timeoutOverride = std::chrono::seconds(2); // Hang test bounded
|
||||||
|
TestSummary summary = RunTests(cfg, opts);
|
||||||
|
|
||||||
|
if (summary.passed != 1 || summary.failed != 1 ||
|
||||||
|
summary.crashed != 1 || summary.timedOut != 1 || summary.skipped != 0) {
|
||||||
|
std::println(std::cerr,
|
||||||
|
"outcome counts mismatch: passed={} failed={} crashed={} timedOut={} skipped={}",
|
||||||
|
summary.passed, summary.failed, summary.crashed, summary.timedOut, summary.skipped);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
std::println(std::cerr, "test exception: {}", e.what());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
tests/RunnerClassification/project.cpp
Normal file
20
tests/RunnerClassification/project.cpp
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||||
|
Configuration cfg;
|
||||||
|
cfg.path = "tests/RunnerClassification/";
|
||||||
|
cfg.name = "RunnerClassification";
|
||||||
|
cfg.outputName = "RunnerClassification";
|
||||||
|
cfg.target = "x86_64-pc-linux-gnu";
|
||||||
|
cfg.type = ConfigurationType::Executable;
|
||||||
|
cfg.dependencies = { ParentLib("crafter.build-lib") };
|
||||||
|
cfg.linkFlags.push_back("-Wl,--export-dynamic");
|
||||||
|
cfg.linkFlags.push_back("-ldl");
|
||||||
|
std::array<fs::path, 0> ifaces = {};
|
||||||
|
std::array<fs::path, 1> impls = { "RunnerClassification" };
|
||||||
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
52
tests/SshRunner/SshRunner.cpp
Normal file
52
tests/SshRunner/SshRunner.cpp
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
/*
|
||||||
|
Crafter® Build
|
||||||
|
Copyright (C) 2026 Catcrafts®
|
||||||
|
Catcrafts.net
|
||||||
|
|
||||||
|
LGPL-3.0-only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
#include "../_shared/TestUtil.h"
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using namespace TestUtil;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
try {
|
||||||
|
const char* hostEnv = std::getenv("CRAFTER_TEST_SSH_HOST");
|
||||||
|
if (!hostEnv || !*hostEnv) Skip("set CRAFTER_TEST_SSH_HOST to enable");
|
||||||
|
std::string host = hostEnv;
|
||||||
|
|
||||||
|
std::string probe = std::format("ssh -o BatchMode=yes -o ConnectTimeout=5 {} true > /dev/null 2>&1", host);
|
||||||
|
if (std::system(probe.c_str()) != 0) Skip(std::format("ssh {} not reachable", host));
|
||||||
|
|
||||||
|
std::string remoteDir = "/tmp/crafter-test-ssh-runner";
|
||||||
|
std::string spec = std::format("ssh:{}:{}", host, remoteDir);
|
||||||
|
::setenv("CRAFTER_BUILD_RUNNER_x86_64_pc_linux_gnu", spec.c_str(), 1);
|
||||||
|
|
||||||
|
auto runner = TestRunner::FromEnv("x86_64-pc-linux-gnu", TestRunner::Local());
|
||||||
|
if (runner.name != std::format("ssh:{}", host)) {
|
||||||
|
std::println(std::cerr, "FromEnv produced '{}', expected 'ssh:{}'", runner.name, host);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::path src = fs::current_path() / "tests" / "SshRunner" / "inner";
|
||||||
|
Configuration cfg = LoadFixture("SshRunner", src);
|
||||||
|
RunTestsOptions opts;
|
||||||
|
TestSummary summary = RunTests(cfg, opts);
|
||||||
|
|
||||||
|
if (summary.passed != 1 || summary.failed != 0 || summary.crashed != 0 ||
|
||||||
|
summary.timedOut != 0 || summary.skipped != 0) {
|
||||||
|
std::println(std::cerr,
|
||||||
|
"outcome counts mismatch: passed={} failed={} crashed={} timedOut={} skipped={}",
|
||||||
|
summary.passed, summary.failed, summary.crashed, summary.timedOut, summary.skipped);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
std::println(std::cerr, "test exception: {}", e.what());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
tests/SshRunner/project.cpp
Normal file
20
tests/SshRunner/project.cpp
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||||
|
Configuration cfg;
|
||||||
|
cfg.path = "tests/SshRunner/";
|
||||||
|
cfg.name = "SshRunner";
|
||||||
|
cfg.outputName = "SshRunner";
|
||||||
|
cfg.target = "x86_64-pc-linux-gnu";
|
||||||
|
cfg.type = ConfigurationType::Executable;
|
||||||
|
cfg.dependencies = { ParentLib("crafter.build-lib") };
|
||||||
|
cfg.linkFlags.push_back("-Wl,--export-dynamic");
|
||||||
|
cfg.linkFlags.push_back("-ldl");
|
||||||
|
std::array<fs::path, 0> ifaces = {};
|
||||||
|
std::array<fs::path, 1> impls = { "SshRunner" };
|
||||||
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
#pragma once
|
|
||||||
|
|
||||||
namespace TestUtil {
|
|
||||||
inline std::string ReadFile(const std::filesystem::path& p) {
|
|
||||||
std::ifstream f(p);
|
|
||||||
std::stringstream ss;
|
|
||||||
ss << f.rdbuf();
|
|
||||||
return ss.str();
|
|
||||||
}
|
|
||||||
|
|
||||||
inline std::filesystem::path CopyFixtureToTemp(std::string_view testName, const std::filesystem::path& source) {
|
|
||||||
namespace fs = std::filesystem;
|
|
||||||
fs::path tmp = fs::temp_directory_path() / std::format("crafter-test-{}", testName);
|
|
||||||
fs::remove_all(tmp);
|
|
||||||
fs::copy(source, tmp, fs::copy_options::recursive);
|
|
||||||
return tmp;
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CmdResult {
|
|
||||||
int exitCode;
|
|
||||||
std::string output;
|
|
||||||
};
|
|
||||||
|
|
||||||
inline CmdResult RunInDir(const std::filesystem::path& cwd, std::string_view command) {
|
|
||||||
namespace fs = std::filesystem;
|
|
||||||
// Log inside cwd so parallel test drivers don't trample each other.
|
|
||||||
fs::path log = cwd / ".crafter-cmd-output.log";
|
|
||||||
std::string cmd = std::format("cd '{}' && {} > '{}' 2>&1",
|
|
||||||
cwd.string(), command, log.string());
|
|
||||||
int rc = std::system(cmd.c_str());
|
|
||||||
std::string out = ReadFile(log);
|
|
||||||
std::error_code ec;
|
|
||||||
fs::remove(log, ec);
|
|
||||||
return {rc, std::move(out)};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
33
tests/UnitLib/main.cpp
Normal file
33
tests/UnitLib/main.cpp
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
// Local() is the no-op runner — empty exec template, name "local".
|
||||||
|
if (TestRunner::Local().name != "local") return 1;
|
||||||
|
|
||||||
|
// FromSpec parses each known kind and labels it consistently.
|
||||||
|
auto cmd = TestRunner::FromSpec("cmd:wine");
|
||||||
|
if (!cmd || cmd->name != "cmd:wine") return 1;
|
||||||
|
|
||||||
|
auto local = TestRunner::FromSpec("local");
|
||||||
|
if (!local || local->name != "local") return 1;
|
||||||
|
|
||||||
|
auto ssh = TestRunner::FromSpec("ssh:somehost");
|
||||||
|
if (!ssh || ssh->name != "ssh:somehost") return 1;
|
||||||
|
|
||||||
|
auto sshWithDir = TestRunner::FromSpec("ssh:somehost:/var/tmp/x");
|
||||||
|
if (!sshWithDir || sshWithDir->remoteDir != "/var/tmp/x") return 1;
|
||||||
|
|
||||||
|
auto sshWin = TestRunner::FromSpec("sshwin:winhost");
|
||||||
|
if (!sshWin || sshWin->name != "sshwin:winhost") return 1;
|
||||||
|
|
||||||
|
// Empty input returns nullopt; bogus prefix throws.
|
||||||
|
if (TestRunner::FromSpec("")) return 1;
|
||||||
|
try {
|
||||||
|
TestRunner::FromSpec("nonsense:thing");
|
||||||
|
return 1;
|
||||||
|
} catch (const std::exception&) {}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
21
tests/UnitLib/project.cpp
Normal file
21
tests/UnitLib/project.cpp
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||||
|
Configuration cfg;
|
||||||
|
cfg.path = "tests/UnitLib/";
|
||||||
|
cfg.name = "UnitLib";
|
||||||
|
cfg.outputName = "UnitLib";
|
||||||
|
cfg.target = "x86_64-pc-linux-gnu";
|
||||||
|
cfg.type = ConfigurationType::Executable;
|
||||||
|
cfg.dependencies = { ParentLib("crafter.build-lib") };
|
||||||
|
cfg.linkFlags.push_back("-Wl,--export-dynamic");
|
||||||
|
cfg.linkFlags.push_back("-ldl");
|
||||||
|
|
||||||
|
std::array<fs::path, 0> ifaces = {};
|
||||||
|
std::array<fs::path, 1> impls = { "main" };
|
||||||
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
44
tests/Wasi/Wasi.cpp
Normal file
44
tests/Wasi/Wasi.cpp
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
#include "../_shared/TestUtil.h"
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using namespace TestUtil;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
try {
|
||||||
|
if (!fs::exists("/usr/share/wasi-sysroot/share/libc++/v1/std.cppm")) {
|
||||||
|
Skip("WASI sysroot/libc++ missing — install wasi-libc, wasi-libc++, wasi-libc++abi");
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::path src = fs::current_path() / "tests" / "Wasi" / "inner";
|
||||||
|
Configuration cfg = LoadFixture("Wasi", src);
|
||||||
|
fs::path work = fs::current_path();
|
||||||
|
|
||||||
|
auto br = BuildOnce(cfg);
|
||||||
|
if (!br.result.empty()) {
|
||||||
|
std::println(std::cerr, "build failed:\n{}", br.result);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::path artifact = work / "bin" / "wasi-hello-wasm32-wasip1-native" / "wasi-hello.wasm";
|
||||||
|
if (!fs::exists(artifact)) {
|
||||||
|
std::println(std::cerr, "expected artifact missing at {}", artifact.string());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify WASM magic bytes: \0asm
|
||||||
|
std::ifstream f(artifact, std::ios::binary);
|
||||||
|
char magic[4] = {};
|
||||||
|
f.read(magic, 4);
|
||||||
|
if (magic[0] != '\0' || magic[1] != 'a' || magic[2] != 's' || magic[3] != 'm') {
|
||||||
|
std::println(std::cerr, "artifact is not a valid WASM file (bad magic bytes)");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
std::println(std::cerr, "test exception: {}", e.what());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
tests/Wasi/inner/main.cpp
Normal file
6
tests/Wasi/inner/main.cpp
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import std;
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
std::println("Hello, WASI!");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
19
tests/Wasi/inner/project.cpp
Normal file
19
tests/Wasi/inner/project.cpp
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||||
|
Configuration cfg;
|
||||||
|
cfg.path = "./";
|
||||||
|
cfg.name = "wasi-hello";
|
||||||
|
cfg.outputName = "wasi-hello";
|
||||||
|
cfg.target = "wasm32-wasip1";
|
||||||
|
cfg.type = ConfigurationType::Executable;
|
||||||
|
|
||||||
|
std::array<fs::path, 0> ifaces = {};
|
||||||
|
std::array<fs::path, 1> impls = { "main" };
|
||||||
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
20
tests/Wasi/project.cpp
Normal file
20
tests/Wasi/project.cpp
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||||
|
Configuration cfg;
|
||||||
|
cfg.path = "tests/Wasi/";
|
||||||
|
cfg.name = "Wasi";
|
||||||
|
cfg.outputName = "Wasi";
|
||||||
|
cfg.target = "x86_64-pc-linux-gnu";
|
||||||
|
cfg.type = ConfigurationType::Executable;
|
||||||
|
cfg.dependencies = { ParentLib("crafter.build-lib") };
|
||||||
|
cfg.linkFlags.push_back("-Wl,--export-dynamic");
|
||||||
|
cfg.linkFlags.push_back("-ldl");
|
||||||
|
std::array<fs::path, 0> ifaces = {};
|
||||||
|
std::array<fs::path, 1> impls = { "Wasi" };
|
||||||
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
68
tests/WindowsViaSsh/WindowsViaSsh.cpp
Normal file
68
tests/WindowsViaSsh/WindowsViaSsh.cpp
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,8 +20,6 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>)
|
||||||
std::array<fs::path, 0> ifaces = {};
|
std::array<fs::path, 0> ifaces = {};
|
||||||
std::array<fs::path, 1> impls = { "main" };
|
std::array<fs::path, 1> impls = { "main" };
|
||||||
t.config.GetInterfacesAndImplementations(ifaces, impls);
|
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);
|
t.runner = TestRunner::FromEnv(t.config.target);
|
||||||
|
|
||||||
20
tests/WindowsViaSsh/project.cpp
Normal file
20
tests/WindowsViaSsh/project.cpp
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||||
|
Configuration cfg;
|
||||||
|
cfg.path = "tests/WindowsViaSsh/";
|
||||||
|
cfg.name = "WindowsViaSsh";
|
||||||
|
cfg.outputName = "WindowsViaSsh";
|
||||||
|
cfg.target = "x86_64-pc-linux-gnu";
|
||||||
|
cfg.type = ConfigurationType::Executable;
|
||||||
|
cfg.dependencies = { ParentLib("crafter.build-lib") };
|
||||||
|
cfg.linkFlags.push_back("-Wl,--export-dynamic");
|
||||||
|
cfg.linkFlags.push_back("-ldl");
|
||||||
|
std::array<fs::path, 0> ifaces = {};
|
||||||
|
std::array<fs::path, 1> impls = { "WindowsViaSsh" };
|
||||||
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
6
tests/WithModule/interfaces/Greeter.cppm
Normal file
6
tests/WithModule/interfaces/Greeter.cppm
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export module Greeter;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
export std::string Greet(std::string_view name) {
|
||||||
|
return std::format("hello, {}!", name);
|
||||||
|
}
|
||||||
76
tests/_shared/TestUtil.h
Normal file
76
tests/_shared/TestUtil.h
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
#pragma once
|
||||||
|
#include <stdlib.h> // setenv (POSIX, not in `import std`)
|
||||||
|
|
||||||
|
namespace TestUtil {
|
||||||
|
// Exit code 77 follows the autoconf convention. crafter-build's test
|
||||||
|
// runner maps it to TestOutcome::Skipped and renders the test's stdout
|
||||||
|
// as the reason. Use this when the test discovers at runtime that its
|
||||||
|
// preconditions aren't met (tool missing, env unset, etc).
|
||||||
|
[[noreturn]] inline void Skip(std::string_view reason) {
|
||||||
|
std::print("{}", reason);
|
||||||
|
std::exit(77);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline std::string ReadFile(const std::filesystem::path& p) {
|
||||||
|
std::ifstream f(p);
|
||||||
|
std::stringstream ss;
|
||||||
|
ss << f.rdbuf();
|
||||||
|
return ss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
inline std::filesystem::path CopyFixtureToTemp(std::string_view testName, const std::filesystem::path& source) {
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
fs::path tmp = fs::temp_directory_path() / std::format("crafter-test-{}", testName);
|
||||||
|
fs::remove_all(tmp);
|
||||||
|
fs::copy(source, tmp, fs::copy_options::recursive);
|
||||||
|
return tmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CmdResult {
|
||||||
|
int exitCode;
|
||||||
|
std::string output;
|
||||||
|
};
|
||||||
|
|
||||||
|
inline CmdResult RunInDir(const std::filesystem::path& cwd, std::string_view command) {
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
// Log inside cwd so parallel test drivers don't trample each other.
|
||||||
|
fs::path log = cwd / ".crafter-cmd-output.log";
|
||||||
|
std::string cmd = std::format("cd '{}' && {} > '{}' 2>&1",
|
||||||
|
cwd.string(), command, log.string());
|
||||||
|
int rc = std::system(cmd.c_str());
|
||||||
|
std::string out = ReadFile(log);
|
||||||
|
std::error_code ec;
|
||||||
|
fs::remove(log, ec);
|
||||||
|
return {rc, std::move(out)};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build cfg with a fresh dep cache. Convenient for outer-driver tests that
|
||||||
|
// exercise the build API in-process and don't need to share the dep cache
|
||||||
|
// across multiple Build() calls.
|
||||||
|
inline Crafter::BuildResult BuildOnce(Crafter::Configuration& cfg) {
|
||||||
|
std::unordered_map<std::filesystem::path, std::shared_future<Crafter::BuildResult>> depResults;
|
||||||
|
std::mutex depMutex;
|
||||||
|
return Crafter::Build(cfg, depResults, depMutex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy a fixture into a fresh temp dir, chdir there (so cfg.path = "./"
|
||||||
|
// inside the inner project.cpp resolves to the temp dir), and load its
|
||||||
|
// project.cpp. Common prep for in-process build/test API tests.
|
||||||
|
//
|
||||||
|
// Also sets CRAFTER_BUILD_HOME to <projectRoot>/share/crafter-build before
|
||||||
|
// loading. The lib's default sourceDir is derived from /proc/self/exe, but
|
||||||
|
// test exes live in tests/<Name>/bin/... rather than next to the project's
|
||||||
|
// share/, so the default lookup misses. The test runner launches tests
|
||||||
|
// with cwd = project root, so we capture that here before the chdir below.
|
||||||
|
inline Crafter::Configuration LoadFixture(std::string_view testName,
|
||||||
|
const std::filesystem::path& source,
|
||||||
|
std::span<const std::string_view> args = {}) {
|
||||||
|
auto projectRoot = std::filesystem::current_path();
|
||||||
|
auto sharePath = projectRoot / "share" / "crafter-build";
|
||||||
|
::setenv("CRAFTER_BUILD_HOME", sharePath.string().c_str(), 1);
|
||||||
|
|
||||||
|
auto work = CopyFixtureToTemp(testName, source);
|
||||||
|
std::filesystem::current_path(work);
|
||||||
|
return Crafter::LoadProject("project.cpp", args);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue