test: add WIT compatibility tests for WASM extensions (#586)

* test: add WIT compatibility tests for all WASM tools and channels

Adds CI and integration tests to catch WIT interface breakage across
all 14 WASM extensions (10 tools + 4 channels). Previously, changing
wit/tool.wit or wit/channel.wit could silently break guest-side tools
that weren't rebuilt until release time.

Three new pieces:

1. scripts/build-wasm-extensions.sh — builds all WASM extensions from
   source by reading registry manifests. Used by CI and locally.

2. tests/wit_compat.rs — integration tests that compile and instantiate
   each .wasm binary against the current wasmtime host linker with
   stubbed host functions. Catches added/removed/renamed WIT functions,
   signature mismatches, and missing exports. Skips gracefully when
   artifacts aren't built so `cargo test` still passes standalone.

3. .github/workflows/test.yml — new wasm-wit-compat CI job that builds
   all extensions then runs instantiation tests on every PR. Added to
   the branch protection roll-up.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: fix rustfmt formatting in wit_compat tests

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review feedback on WIT compat tests

- Switch build script from python3 to jq for JSON parsing, consistent
  with release.yml and avoids python3 dependency (#1, #7)
- Use dirs::home_dir() instead of HOME env var for portability (#2)
- Filter extensions by manifest "kind" field instead of path (#3)
- Replace .flatten() with explicit error handling in dir iteration (#4, #5)
- Split stub_tool_host_functions into stub_shared_host_functions +
  tool-only tool-invoke stub, since tool-invoke is not in channel WIT (#6)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-03-06 02:36:59 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 6a2a6cd050
commit 46218ec794
3 changed files with 576 additions and 2 deletions
+479
View File
@@ -0,0 +1,479 @@
//! WIT compatibility tests for WASM tools and channels.
//!
//! These tests verify that pre-built WASM components can be compiled and
//! instantiated against the current host linker. If the WIT interface
//! changes, these tests catch any breakage in existing tools/channels.
//!
//! Prerequisites: build WASM extensions first with:
//! ./scripts/build-wasm-extensions.sh
//!
//! The tests are skipped (not failed) when no WASM artifacts are found,
//! so `cargo test` still passes without building extensions first.
//! CI runs the build script before these tests.
use std::path::{Path, PathBuf};
use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiView};
/// Minimal store data that satisfies WasiView for component instantiation.
struct TestStoreData {
wasi: WasiCtx,
table: ResourceTable,
}
impl TestStoreData {
fn new() -> Self {
Self {
wasi: WasiCtxBuilder::new().build(),
table: ResourceTable::new(),
}
}
}
impl WasiView for TestStoreData {
fn ctx(&mut self) -> &mut WasiCtx {
&mut self.wasi
}
fn table(&mut self) -> &mut ResourceTable {
&mut self.table
}
}
/// Extension kind from the registry manifest.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ExtensionKind {
Tool,
Channel,
}
/// A discovered WASM extension from the registry.
struct DiscoveredExtension {
name: String,
source_dir: PathBuf,
crate_name: String,
kind: ExtensionKind,
}
/// Search paths for WASM artifacts produced by cargo-component.
fn find_wasm_artifact(source_dir: &Path, crate_name: &str) -> Option<PathBuf> {
let artifact_name = crate_name.replace('-', "_");
// Crate-local target dir (CI, default cargo)
for target_triple in &["wasm32-wasip2", "wasm32-wasip1", "wasm32-wasi"] {
let candidate = source_dir
.join("target")
.join(target_triple)
.join("release")
.join(format!("{artifact_name}.wasm"));
if candidate.exists() {
return Some(candidate);
}
}
// Shared target dir (CARGO_TARGET_DIR env)
if let Ok(shared) = std::env::var("CARGO_TARGET_DIR") {
for target_triple in &["wasm32-wasip2", "wasm32-wasip1", "wasm32-wasi"] {
let candidate = Path::new(&shared)
.join(target_triple)
.join("release")
.join(format!("{artifact_name}.wasm"));
if candidate.exists() {
return Some(candidate);
}
}
}
// Common shared target location (~/.cargo/shared-target)
if let Some(home) = dirs::home_dir() {
let shared = home.join(".cargo/shared-target");
if shared.exists() {
for target_triple in &["wasm32-wasip2", "wasm32-wasip1", "wasm32-wasi"] {
let candidate = shared
.join(target_triple)
.join("release")
.join(format!("{artifact_name}.wasm"));
if candidate.exists() {
return Some(candidate);
}
}
}
}
None
}
/// Parse registry manifests to discover all WASM extensions.
fn discover_extensions() -> Vec<DiscoveredExtension> {
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let mut extensions = Vec::new();
for dir in &["registry/tools", "registry/channels"] {
let registry_dir = repo_root.join(dir);
if !registry_dir.exists() {
continue;
}
for entry in std::fs::read_dir(&registry_dir).expect("failed to read registry dir") {
let entry = entry.expect("failed to read directory entry");
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let content = std::fs::read_to_string(&path).expect("failed to read manifest");
let manifest: serde_json::Value =
serde_json::from_str(&content).expect("failed to parse manifest");
let name = manifest["name"].as_str().unwrap_or("unknown").to_string();
let kind = match manifest["kind"].as_str() {
Some("tool") => ExtensionKind::Tool,
Some("channel") => ExtensionKind::Channel,
_ => continue,
};
let source_dir = manifest["source"]["dir"]
.as_str()
.map(|d| repo_root.join(d));
let crate_name = manifest["source"]["crate_name"]
.as_str()
.map(|s| s.to_string());
if let (Some(source_dir), Some(crate_name)) = (source_dir, crate_name)
&& source_dir.exists()
{
extensions.push(DiscoveredExtension {
name,
source_dir,
crate_name,
kind,
});
}
}
}
extensions
}
fn compile_component(
engine: &wasmtime::Engine,
wasm_bytes: &[u8],
) -> Result<wasmtime::component::Component, String> {
wasmtime::component::Component::new(engine, wasm_bytes)
.map_err(|e| format!("compilation failed: {e}"))
}
/// Stub host functions shared between tool and channel interfaces:
/// log, now-millis, workspace-read, http-request, secret-exists.
fn stub_shared_host_functions(
host: &mut wasmtime::component::LinkerInstance<'_, TestStoreData>,
) -> Result<(), String> {
host.func_new("log", |_ctx, _args, _results| Ok(()))
.map_err(|e| format!("stub 'log': {e}"))?;
host.func_new("now-millis", |_ctx, _args, results| {
results[0] = wasmtime::component::Val::U64(0);
Ok(())
})
.map_err(|e| format!("stub 'now-millis': {e}"))?;
host.func_new("workspace-read", |_ctx, _args, results| {
results[0] = wasmtime::component::Val::Option(None);
Ok(())
})
.map_err(|e| format!("stub 'workspace-read': {e}"))?;
host.func_new("http-request", |_ctx, _args, results| {
results[0] = wasmtime::component::Val::Result(Err(Some(Box::new(
wasmtime::component::Val::String("stub".into()),
))));
Ok(())
})
.map_err(|e| format!("stub 'http-request': {e}"))?;
host.func_new("secret-exists", |_ctx, _args, results| {
results[0] = wasmtime::component::Val::Bool(false);
Ok(())
})
.map_err(|e| format!("stub 'secret-exists': {e}"))?;
Ok(())
}
/// Instantiate a tool component (world: sandboxed-tool, imports: near:agent/host).
fn instantiate_tool_component(
engine: &wasmtime::Engine,
component: &wasmtime::component::Component,
) -> Result<(), String> {
use wasmtime::Store;
use wasmtime::component::Linker;
let mut linker: Linker<TestStoreData> = Linker::new(engine);
wasmtime_wasi::add_to_linker_sync(&mut linker)
.map_err(|e| format!("WASI linker failed: {e}"))?;
// If the WIT added/removed/renamed a function, stub registration
// or instantiation will fail.
{
let mut root = linker.root();
let mut host = root
.instance("near:agent/host")
.map_err(|e| format!("failed to create host instance: {e}"))?;
stub_shared_host_functions(&mut host)?;
// tool-invoke is only in the tool host interface, not channel-host
host.func_new("tool-invoke", |_ctx, _args, results| {
results[0] = wasmtime::component::Val::Result(Err(Some(Box::new(
wasmtime::component::Val::String("stub".into()),
))));
Ok(())
})
.map_err(|e| format!("stub 'tool-invoke': {e}"))?;
}
let mut store = Store::new(engine, TestStoreData::new());
linker
.instantiate(&mut store, component)
.map_err(|e| format!("instantiation failed: {e}"))?;
Ok(())
}
/// Instantiate a channel component (world: sandboxed-channel, imports: near:agent/channel-host).
fn instantiate_channel_component(
engine: &wasmtime::Engine,
component: &wasmtime::component::Component,
) -> Result<(), String> {
use wasmtime::Store;
use wasmtime::component::Linker;
let mut linker: Linker<TestStoreData> = Linker::new(engine);
wasmtime_wasi::add_to_linker_sync(&mut linker)
.map_err(|e| format!("WASI linker failed: {e}"))?;
{
let mut root = linker.root();
let mut host = root
.instance("near:agent/channel-host")
.map_err(|e| format!("failed to create channel-host instance: {e}"))?;
stub_shared_host_functions(&mut host)?;
// Channel-specific host functions
host.func_new("emit-message", |_ctx, _args, _results| Ok(()))
.map_err(|e| format!("stub 'emit-message': {e}"))?;
host.func_new("workspace-write", |_ctx, _args, results| {
results[0] = wasmtime::component::Val::Result(Ok(None));
Ok(())
})
.map_err(|e| format!("stub 'workspace-write': {e}"))?;
host.func_new("pairing-upsert-request", |_ctx, _args, results| {
results[0] = wasmtime::component::Val::Result(Err(Some(Box::new(
wasmtime::component::Val::String("stub".into()),
))));
Ok(())
})
.map_err(|e| format!("stub 'pairing-upsert-request': {e}"))?;
host.func_new("pairing-is-allowed", |_ctx, _args, results| {
results[0] = wasmtime::component::Val::Result(Err(Some(Box::new(
wasmtime::component::Val::String("stub".into()),
))));
Ok(())
})
.map_err(|e| format!("stub 'pairing-is-allowed': {e}"))?;
host.func_new("pairing-read-allow-from", |_ctx, _args, results| {
results[0] = wasmtime::component::Val::Result(Err(Some(Box::new(
wasmtime::component::Val::String("stub".into()),
))));
Ok(())
})
.map_err(|e| format!("stub 'pairing-read-allow-from': {e}"))?;
}
let mut store = Store::new(engine, TestStoreData::new());
linker
.instantiate(&mut store, component)
.map_err(|e| format!("instantiation failed: {e}"))?;
Ok(())
}
fn create_engine() -> wasmtime::Engine {
let mut config = wasmtime::Config::new();
config.wasm_component_model(true);
config.wasm_threads(false);
wasmtime::Engine::new(&config).expect("failed to create wasmtime engine")
}
#[test]
fn wit_compat_tool_components_compile_and_instantiate() {
let extensions = discover_extensions();
let engine = create_engine();
let tool_extensions: Vec<_> = extensions
.iter()
.filter(|ext| ext.kind == ExtensionKind::Tool)
.collect();
if tool_extensions.is_empty() {
eprintln!("SKIP: no tool extensions found in registry");
return;
}
let mut found_any = false;
let mut failures: Vec<String> = Vec::new();
for ext in &tool_extensions {
let wasm_path = match find_wasm_artifact(&ext.source_dir, &ext.crate_name) {
Some(p) => p,
None => {
eprintln!(
" SKIP {}: no built WASM artifact (run ./scripts/build-wasm-extensions.sh)",
ext.name
);
continue;
}
};
found_any = true;
eprintln!(" TEST {}: {}", ext.name, wasm_path.display());
let wasm_bytes = std::fs::read(&wasm_path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", wasm_path.display()));
let component = match compile_component(&engine, &wasm_bytes) {
Ok(c) => c,
Err(e) => {
failures.push(format!("{}: {e}", ext.name));
continue;
}
};
if let Err(e) = instantiate_tool_component(&engine, &component) {
failures.push(format!("{}: {e}", ext.name));
}
}
if !found_any {
eprintln!("SKIP: no WASM artifacts found (build extensions first)");
return;
}
assert!(
failures.is_empty(),
"WIT compatibility failures for tools:\n{}",
failures.join("\n")
);
}
#[test]
fn wit_compat_channel_components_compile_and_instantiate() {
let extensions = discover_extensions();
let engine = create_engine();
let channel_extensions: Vec<_> = extensions
.iter()
.filter(|ext| ext.kind == ExtensionKind::Channel)
.collect();
if channel_extensions.is_empty() {
eprintln!("SKIP: no channel extensions found in registry");
return;
}
let mut found_any = false;
let mut failures: Vec<String> = Vec::new();
for ext in &channel_extensions {
let wasm_path = match find_wasm_artifact(&ext.source_dir, &ext.crate_name) {
Some(p) => p,
None => {
eprintln!(
" SKIP {}: no built WASM artifact (run ./scripts/build-wasm-extensions.sh)",
ext.name
);
continue;
}
};
found_any = true;
eprintln!(" TEST {}: {}", ext.name, wasm_path.display());
let wasm_bytes = std::fs::read(&wasm_path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", wasm_path.display()));
let component = match compile_component(&engine, &wasm_bytes) {
Ok(c) => c,
Err(e) => {
failures.push(format!("{}: {e}", ext.name));
continue;
}
};
if let Err(e) = instantiate_channel_component(&engine, &component) {
failures.push(format!("{}: {e}", ext.name));
}
}
if !found_any {
eprintln!("SKIP: no WASM artifacts found (build extensions first)");
return;
}
assert!(
failures.is_empty(),
"WIT compatibility failures for channels:\n{}",
failures.join("\n")
);
}
#[test]
fn wit_compat_all_registry_extensions_have_source() {
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let mut missing = Vec::new();
for dir in &["registry/tools", "registry/channels"] {
let registry_dir = repo_root.join(dir);
if !registry_dir.exists() {
continue;
}
for entry in std::fs::read_dir(&registry_dir).expect("failed to read registry dir") {
let entry = entry.expect("failed to read directory entry");
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let content = std::fs::read_to_string(&path).unwrap();
let manifest: serde_json::Value = serde_json::from_str(&content).unwrap();
let name = manifest["name"].as_str().unwrap_or("unknown");
let source_dir = manifest["source"]["dir"].as_str();
let crate_name = manifest["source"]["crate_name"].as_str();
match (source_dir, crate_name) {
(Some(d), Some(_)) => {
if !repo_root.join(d).exists() {
missing.push(format!("{name}: source dir '{d}' does not exist"));
}
}
_ => {
missing.push(format!("{name}: missing source.dir or source.crate_name"));
}
}
}
}
assert!(
missing.is_empty(),
"Registry entries with missing sources:\n{}",
missing.join("\n")
);
}