crates/gunnar-build-thing/tests/wasm_plugin.rs
//! **One source, two link targets, one answer.** The same [`GunnarBuildThing`]
//! is called natively and — compiled to `wasm32-unknown-unknown` and loaded
//! through edda's generic plugin host, with NO gunnar dependency on the host
//! side, which is the forge's situation — over the ABI, and the two must
//! answer IDENTICALLY. If they did not, the ⚒ pane (which reads the wasm) and
//! the CLI (which links natively through the build scripts) could disagree
//! about the same variant. Template: edda
//! `crates/nornir-buildspec-holger/tests/tree_native_equals_wasm.rs`.
//!
//! # How the module is built here, and the road that is refused
//!
//! The module is built by HOST cargo from inside the test, exactly as the
//! template does it. The container road — dwarves `build_thing::code`
//! (`f5e0854`), the recipe crate to wasm32 INSIDE the declared
//! `nordisk-cargo-toolchain` — is the one every forge build takes; it is
//! refused by name for gunnar today because the declared image
//! (`[build.toolchain]`, 1.97.1) declares and carries no
//! `wasm32-unknown-unknown` target, and the `dwarves` binary on this box
//! predates the code road. When the image grows the target this test still
//! proves the same identity; what changes is who compiles the module.
//!
//! # Two tiers
//!
//! * always: `manifest`, `variants`, `menu` — the 0.1.9 ABI;
//! * `options` / `try_toggle` / `actions` over
//! every node of every tree (ops 11–13, 0.1.10 — see `tests/tree_laws.rs`
//! for why that is gated).
use std::path::{Path, PathBuf};
use std::process::Command;
use gunnar_build_thing::GunnarBuildThing;
use nornir_build_thing::plugin::BuildThing;
use nornir_build_thing::plugin_host::{HostFacts, Limits, PluginHandle};
/// Build this crate's own wasm32 module and hand back its path, or `None`
/// with the reason printed — a box without the target skips LOUDLY, never
/// green by omission.
fn build_the_plugin() -> Option<PathBuf> {
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let workspace = manifest.parent()?.parent()?.to_path_buf();
// ASK CARGO where its target directory is, and then BUILD INTO THAT ANSWER,
// so the place the module is written and the place it is looked for cannot
// be two different opinions. See [`target_dir`].
let target_dir = target_dir(&workspace);
let out = Command::new(env!("CARGO"))
.current_dir(&workspace)
.args([
"build",
"-p",
"gunnar-build-thing",
"--lib",
"--release",
"--target",
"wasm32-unknown-unknown",
"--target-dir",
])
.arg(&target_dir)
.output()
.ok()?;
if !out.status.success() {
eprintln!(
"SKIP wasm_plugin: could not build the wasm32 module (is the target installed? \
`rustup target add wasm32-unknown-unknown`)\n{}",
String::from_utf8_lossy(&out.stderr)
);
return None;
}
let wasm = target_dir.join("wasm32-unknown-unknown/release/gunnar_build_thing.wasm");
assert!(
wasm.is_file(),
"the wasm32 build reported success but produced no module at {} — is the crate \
still a cdylib?",
wasm.display()
);
Some(wasm)
}
/// **The cargo target directory — ASKED OF CARGO, never re-derived.**
///
/// RED on this box, 2026-09-10, and it cost a whole workspace test run: this
/// read `CARGO_TARGET_DIR` and fell back to `<workspace>/target`, and both were
/// wrong. The target directory here is set in `~/.cargo/config.toml`:
///
/// ```toml
/// [build]
/// target-dir = "/home/alice/cargo-target"
/// ```
///
/// A `config.toml` setting does not appear in the process environment, so
/// `CARGO_TARGET_DIR` was unset, the fallback pointed at `<workspace>/target`,
/// and the module — really written to
/// `/home/alice/cargo-target/wasm32-unknown-unknown/release/` — was declared
/// missing:
///
/// ```text
/// the wasm32 build reported success but produced no module at
/// /home/alice/git/gunnar/target/wasm32-unknown-unknown/release/gunnar_build_thing.wasm
/// — is the crate still a cdylib?
/// ```
///
/// The message even named the wrong suspect: the crate was still a `cdylib` and
/// the build had genuinely succeeded. `cargo test --workspace` stops at the
/// first failing target, so these two failures also hid every test after them.
///
/// `cargo metadata` is the only thing that knows the answer, because it applies
/// the whole precedence chain — `--target-dir`, then `CARGO_TARGET_DIR`, then
/// `build.target-dir` from every `config.toml` on the walk up, then the default.
/// Re-implementing that chain here would be a fifth opinion about a fact cargo
/// will simply state. The env var and `<workspace>/target` remain as fallbacks
/// for a box where `cargo metadata` cannot run at all.
///
/// The parse is deliberately not a JSON dependency: `target_directory` is one
/// string in a document this crate reads for one field, and `dev-dependencies`
/// here is one line on purpose (see `Cargo.toml` — this crate's unit count is
/// load-bearing). A path containing `"` or `\` would defeat it; on the
/// platforms gunnar builds for, a target directory is an ordinary path.
fn target_dir(workspace: &std::path::Path) -> PathBuf {
let asked = Command::new(env!("CARGO"))
.current_dir(workspace)
.args(["metadata", "--format-version", "1", "--no-deps"])
.output()
.ok()
.filter(|out| out.status.success())
.and_then(|out| {
let text = String::from_utf8_lossy(&out.stdout).into_owned();
let key = "\"target_directory\":\"";
let start = text.find(key)? + key.len();
let rest = &text[start..];
let end = rest.find('"')?;
Some(PathBuf::from(&rest[..end]))
});
asked
.or_else(|| std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from))
.unwrap_or_else(|| workspace.join("target"))
}
fn load(wasm: &Path) -> PluginHandle {
PluginHandle::load_file_with(wasm, Limits::default(), HostFacts::at_rev("test", false))
.expect("the module loads through the generic host")
}
/// The 0.1.9 ABI: who this is, what it declares, what the pane draws.
#[test]
fn manifest_variants_and_menu_answer_the_same_natively_and_over_wasm() {
let Some(wasm) = build_the_plugin() else {
return;
};
let native = GunnarBuildThing;
let mut plugin = load(&wasm);
assert_eq!(
plugin.manifest(),
&native.manifest(),
"manifest differs native vs wasm"
);
let over_wasm = plugin.variants().expect("variants over the ABI");
let natively = native.variants();
assert_eq!(over_wasm, natively, "variants() differs native vs wasm");
assert!(
natively.variants.len() >= 8,
"gunnar declares eight rows; a vacuous identity over none proves nothing"
);
// `BuildMenu` wears no `PartialEq` at 0.1.9, so the menu is compared
// through what it is made of: the set it folds back to and the variant
// names in the order the pane draws them.
let menu_wasm = plugin.menu().expect("menu over the ABI");
let menu_native = native.menu();
assert_eq!(
menu_wasm.set(),
menu_native.set(),
"menu().set() differs native vs wasm"
);
assert_eq!(
menu_wasm.menu_variants(),
menu_native.menu_variants(),
"menu_variants() differs native vs wasm"
);
eprintln!(
"native == wasm over {} rows, {} containers, {} isos, {} installers ({})",
natively.variants.len(),
natively.containers.len(),
natively.isos.len(),
natively.installers.len(),
wasm.display()
);
}
/// The tree ops — ops 11–13, 0.1.10.
#[test]
fn options_try_toggle_and_actions_answer_the_same_natively_and_over_wasm() {
use nornir_build_thing::tree::{SettingsTree, ToggleRequest};
use nornir_build_thing::variant::ArtifactKind;
let Some(wasm) = build_the_plugin() else {
return;
};
let native = GunnarBuildThing;
let mut plugin = load(&wasm);
let set = native.variants();
let forest = SettingsTree::forest(&set);
assert!(
!forest.is_empty(),
"gunnar declares variants, so it has trees"
);
let mut compared = 0usize;
for tree in &forest {
let over_wasm = plugin
.options(&tree.variant, "")
.expect("options over the ABI");
assert_eq!(
over_wasm.as_ref(),
Some(tree),
"options({}) differs native vs wasm",
tree.variant
);
assert_eq!(native.options(&tree.variant, ""), over_wasm);
for node in tree.walk() {
let sub_native = native.options(&tree.variant, &node.path);
let sub_wasm = plugin.options(&tree.variant, &node.path).unwrap();
assert_eq!(
sub_native, sub_wasm,
"options({}, {}) differs",
tree.variant, node.path
);
for on in [true, false] {
let req = ToggleRequest::new(&tree.variant, &node.path, on);
let d_native = native.try_toggle(&req);
let d_wasm = plugin.try_toggle(&req).unwrap();
assert_eq!(
d_native, d_wasm,
"try_toggle({}, {}, {on}) differs",
tree.variant, node.path
);
compared += 1;
}
}
}
assert!(
compared > 0,
"nothing was compared — a vacuous identity proves nothing"
);
assert_eq!(
native.options("ghost", ""),
plugin.options("ghost", "").unwrap()
);
let req = ToggleRequest::new("ghost", "x", false);
assert_eq!(native.try_toggle(&req), plugin.try_toggle(&req).unwrap());
for kind in [
ArtifactKind::Exe,
ArtifactKind::Lib,
ArtifactKind::Wasm,
ArtifactKind::Container,
ArtifactKind::Vm,
ArtifactKind::Iso,
ArtifactKind::Installer,
ArtifactKind::Service,
ArtifactKind::Kernel,
] {
assert_eq!(
native.actions(kind),
plugin.actions(kind).unwrap(),
"actions({kind:?}) differs"
);
}
eprintln!(
"native == wasm over {} trees, {compared} toggles, 9 kinds",
forest.len()
);
}