mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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:
co-authored by
Claude Opus 4.6
parent
6a2a6cd050
commit
46218ec794
@@ -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(®istry_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(®istry_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")
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user