From bc5dbc4699bffcb7603cbe7be818932637e88ee1 Mon Sep 17 00:00:00 2001 From: Andrew Steurer <94206073+asteurer@users.noreply.github.com> Date: Wed, 18 Feb 2026 23:45:20 -0600 Subject: [PATCH] feat: add unit tests Signed-off-by: Andrew Steurer <94206073+asteurer@users.noreply.github.com> --- .gitignore | 1 - Cargo.lock | 1 + README.md | 4 + examples/wasip2/Makefile | 20 +- examples/wasip2/README.md | 7 + .../wasip2/unit_tests_should_fail/err_test.go | 30 +++ .../wasip2/unit_tests_should_pass/ok_test.go | 30 +++ src/{bindings.rs => cmd_bindings.rs} | 2 +- src/cmd_build.rs | 93 +++++++ src/cmd_test.rs | 137 ++++++++++ src/command.rs | 143 ++++++++--- src/common.rs | 50 ---- src/lib.rs | 7 +- src/{componentize.rs => utils.rs} | 199 +++++++-------- tests/Cargo.toml | 1 + tests/src/lib.rs | 239 ++++++++++++++---- 16 files changed, 719 insertions(+), 245 deletions(-) create mode 100644 examples/wasip2/unit_tests_should_fail/err_test.go create mode 100644 examples/wasip2/unit_tests_should_pass/ok_test.go rename src/{bindings.rs => cmd_bindings.rs} (97%) create mode 100644 src/cmd_build.rs create mode 100644 src/cmd_test.rs delete mode 100644 src/common.rs rename src/{componentize.rs => utils.rs} (55%) diff --git a/.gitignore b/.gitignore index a989550..2efee59 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,3 @@ examples/*/go.mod examples/*/go.sum examples/*/wasi_* examples/*/wit_* -examples/*/export_wasi* diff --git a/Cargo.lock b/Cargo.lock index aaeba5b..bc111ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1389,6 +1389,7 @@ version = "0.2.0" dependencies = [ "anyhow", "bzip2", + "componentize-go", "once_cell", "reqwest", "tar", diff --git a/README.md b/README.md index 853f367..c8667b3 100644 --- a/README.md +++ b/README.md @@ -37,3 +37,7 @@ You can download a specific release from the [release page](https://github.com/b ```sh cargo install --git https://github.com/bytecodealliance/componentize-go ``` + +## Usage + +Please reference the `README.md` and `Makefile` files in each of the directories in [examples](./examples/). diff --git a/examples/wasip2/Makefile b/examples/wasip2/Makefile index df3c133..e6f4e65 100644 --- a/examples/wasip2/Makefile +++ b/examples/wasip2/Makefile @@ -1,10 +1,22 @@ generate-bindings: - componentize-go --world wasip2-example bindings --format - go mod tidy + @componentize-go --world wasip2-example bindings --format + @go mod tidy build-component: generate-bindings - componentize-go --world wasip2-example componentize + @componentize-go --world wasip2-example build .PHONY: run run: build-component - wasmtime serve -Sp3,cli main.wasm + @wasmtime serve -Sp3,cli main.wasm + +build-tests: generate-bindings + @componentize-go test --wasip1 --pkg ./unit_tests_should_pass --pkg ./unit_tests_should_fail + +run-tests: build-tests + @echo "===== Running tests that will pass =====" + @wasmtime run test_unit_tests_should_pass.wasm + @echo "" + + @echo "===== Running tests that will fail =====" + @wasmtime run test_unit_tests_should_fail.wasm || true + @echo "" diff --git a/examples/wasip2/README.md b/examples/wasip2/README.md index e8d7bf4..fa1bf11 100644 --- a/examples/wasip2/README.md +++ b/examples/wasip2/README.md @@ -1,6 +1,9 @@ # `wasip2` Example + ## Usage + ### Prerequisites + - [**componentize-go**](https://github.com/bytecodealliance/componentize-go) - Latest version - [**go**](https://go.dev/dl/) - v1.25+ - [**wasmtime**](https://github.com/bytecodealliance/wasmtime) - v40.0.2 @@ -14,3 +17,7 @@ make run curl localhost:8080 ``` +### Run unit tests +```sh +make run-tests +``` diff --git a/examples/wasip2/unit_tests_should_fail/err_test.go b/examples/wasip2/unit_tests_should_fail/err_test.go new file mode 100644 index 0000000..d6aa8a3 --- /dev/null +++ b/examples/wasip2/unit_tests_should_fail/err_test.go @@ -0,0 +1,30 @@ +package unit_tests_should_fail + +import ( + "testing" +) + +func test_sum(a, b int) int { + return a + b +} + +func TestSum(t *testing.T) { + tests := []struct { + name string + a, b int + expected int + }{ + {"positive numbers", 2, 3, -1}, + {"negative numbers", -1, -2, 10}, + {"zeros", 0, 0, 5}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := test_sum(tt.a, tt.b) + if got != tt.expected { + t.Errorf("test_sum(%d, %d) = %d, expected %d", tt.a, tt.b, got, tt.expected) + } + }) + } +} diff --git a/examples/wasip2/unit_tests_should_pass/ok_test.go b/examples/wasip2/unit_tests_should_pass/ok_test.go new file mode 100644 index 0000000..bbc7acf --- /dev/null +++ b/examples/wasip2/unit_tests_should_pass/ok_test.go @@ -0,0 +1,30 @@ +package unit_tests_should_pass + +import ( + "testing" +) + +func test_sum(a, b int) int { + return a + b +} + +func TestSum(t *testing.T) { + tests := []struct { + name string + a, b int + expected int + }{ + {"positive numbers", 2, 3, 5}, + {"negative numbers", -1, -2, -3}, + {"zeros", 0, 0, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := test_sum(tt.a, tt.b) + if got != tt.expected { + t.Errorf("test_sum(%d, %d) = %d, expected %d", tt.a, tt.b, got, tt.expected) + } + }) + } +} diff --git a/src/bindings.rs b/src/cmd_bindings.rs similarity index 97% rename from src/bindings.rs rename to src/cmd_bindings.rs index 3cc37bd..8fa6b54 100644 --- a/src/bindings.rs +++ b/src/cmd_bindings.rs @@ -1,4 +1,4 @@ -use crate::common::{make_path_absolute, parse_wit}; +use crate::utils::{make_path_absolute, parse_wit}; use anyhow::Result; use std::path::{Path, PathBuf}; diff --git a/src/cmd_build.rs b/src/cmd_build.rs new file mode 100644 index 0000000..5b0d275 --- /dev/null +++ b/src/cmd_build.rs @@ -0,0 +1,93 @@ +use crate::utils::{check_go_version, make_path_absolute}; +use anyhow::{Result, anyhow}; +use std::{path::PathBuf, process::Command}; + +/// Compiles a Go application to a wasm module with `go build`. +/// +/// If the module is not going to be adapted to the component model, +/// set the `only_wasip1` arg to true. +pub fn build_module( + go_module: Option<&PathBuf>, + out: Option<&PathBuf>, + go_path: Option<&PathBuf>, + only_wasip1: bool, +) -> Result { + let go = match &go_path { + Some(p) => make_path_absolute(p)?, + None => PathBuf::from("go"), + }; + + check_go_version(&go)?; + + let out_path_buf = match &out { + Some(p) => make_path_absolute(p)?, + None => std::env::current_dir()?.join("main.wasm"), + }; + + // Ensuring the newly compiled wasm file overwrites any previously-existing wasm file + if out_path_buf.exists() { + std::fs::remove_file(&out_path_buf)?; + } + + let out_path = out_path_buf + .to_str() + .ok_or_else(|| anyhow!("Output path is not valid unicode"))?; + + let module_path = match &go_module { + Some(p) => { + if !p.is_dir() { + return Err(anyhow!("Module path '{}' is not a directory", p.display())); + } + p.to_str() + .ok_or_else(|| anyhow!("Module path is not valid unicode"))? + } + None => ".", + }; + + // TODO: for when/if we decide to allow users to build wasm modules without componentizing them + #[allow(unused_variables)] + let module_args = [ + "build", + "-C", + module_path, + "-buildmode=c-shared", + "-o", + out_path, + ]; + + let component_args = [ + "build", + "-C", + module_path, + "-buildmode=c-shared", + "-ldflags=-checklinkname=0", + "-o", + out_path, + ]; + + let output = if only_wasip1 { + unimplemented!(); + // TODO: for when/if we decide to allow users to build wasm modules without componentizing them + #[allow(unreachable_code)] + Command::new(&go) + .args(module_args) + .env("GOOS", "wasip1") + .env("GOARCH", "wasm") + .output()? + } else { + Command::new(&go) + .args(component_args) + .env("GOOS", "wasip1") + .env("GOARCH", "wasm") + .output()? + }; + + if !output.status.success() { + return Err(anyhow!( + "'go build' command failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + + Ok(PathBuf::from(out_path)) +} diff --git a/src/cmd_test.rs b/src/cmd_test.rs new file mode 100644 index 0000000..02c88a2 --- /dev/null +++ b/src/cmd_test.rs @@ -0,0 +1,137 @@ +use crate::utils::{check_go_version, make_path_absolute}; +use anyhow::{Result, anyhow}; +use std::{ + path::{Path, PathBuf}, + process::Command, +}; + +/// Compiles a Go application to a wasm module with `go test -c`. +/// +/// If the module is not going to be adapted to the component model, +/// set the `only_wasip1` arg to true. +pub fn build_test_module( + path: &Path, + output_dir: Option<&PathBuf>, + go_path: Option<&PathBuf>, + only_wasip1: bool, +) -> Result { + let go = match &go_path { + Some(p) => make_path_absolute(p)?, + None => PathBuf::from("go"), + }; + + check_go_version(&go)?; + + let test_wasm_path = { + // The directory in which the test component will be placed + let test_dir = match output_dir { + Some(p) => make_path_absolute(p)?, + None => std::env::current_dir()?, + }; + + test_dir.join(get_test_filename(path)) + }; + + // Ensuring the newly compiled wasm file overwrites any previously-existing wasm file + if test_wasm_path.exists() { + std::fs::remove_file(&test_wasm_path)?; + } + + if let Some(dir) = output_dir { + std::fs::create_dir_all(dir)?; + } + + // The -buildmode flag mutes the unit test output, so it is ommitted + let module_args = [ + "test", + "-c", + "-ldflags=-checklinkname=0", + "-o", + test_wasm_path + .to_str() + .expect("the combined paths of 'output-dir' and 'pkg' are not valid unicode"), + path.to_str().expect("pkg path is not valid unicode"), + ]; + + // TODO: for when we figure out how wasip2 tests are to be run + #[allow(unused_variables)] + let component_args = [ + "test", + "-c", + "-buildmode=c-shared", + "-ldflags=-checklinkname=0", + "-o", + test_wasm_path + .to_str() + .expect("the combined paths of 'output-dir' and 'pkg' are not valid unicode"), + path.to_str().expect("pkg path is not valid unicode"), + ]; + + let output = if only_wasip1 { + Command::new(&go) + .args(module_args) + .env("GOOS", "wasip1") + .env("GOARCH", "wasm") + .output()? + } else { + unimplemented!(); + + // TODO: for when we figure out how wasip2 tests are to be run + #[allow(unreachable_code)] + Command::new(&go) + .args(component_args) + .env("GOOS", "wasip1") + .env("GOARCH", "wasm") + .output()? + }; + + if !output.status.success() { + return Err(anyhow!( + "'go test -c' command failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + + Ok(test_wasm_path) +} + +// Format the test filename based on the package path (see unit tests for more details). +pub fn get_test_filename(path: &Path) -> String { + let components: Vec<&str> = path + .components() + .filter_map(|c| match c { + // Filter out the `/` and `.` + std::path::Component::Normal(s) => s.to_str(), + _ => None, + }) + .collect(); + + let tail = if components.len() >= 2 { + &components[components.len() - 2..] + } else { + &components[..] + }; + + format!("test_{}.wasm", tail.join("_")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_test_filename() { + let tests = [ + ("./foo/bar/baz", "test_bar_baz.wasm"), + ("./foo/bar", "test_foo_bar.wasm"), + ("./bar", "test_bar.wasm"), + ("/usr/bin/foo/bar/baz", "test_bar_baz.wasm"), + ]; + + for (input, expected) in tests.iter() { + let input_string = input.to_string(); + let actual = get_test_filename(&PathBuf::from(input_string)); + assert_eq!(actual, expected.to_string()); + } + } +} diff --git a/src/command.rs b/src/command.rs index ff23bf1..79866b6 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1,5 +1,10 @@ -use crate::{bindings::generate_bindings, componentize}; -use anyhow::Result; +use crate::{ + cmd_bindings::generate_bindings, + cmd_build::build_module, + cmd_test::build_test_module, + utils::{embed_wit, module_to_component}, +}; +use anyhow::{Result, anyhow}; use clap::{Parser, Subcommand}; use std::{ffi::OsString, path::PathBuf}; @@ -8,14 +13,14 @@ use std::{ffi::OsString, path::PathBuf}; #[command(version, about, long_about = None)] pub struct Options { #[command(flatten)] - pub common: Common, + pub wit_opts: WitOpts, #[command(subcommand)] pub command: Command, } #[derive(clap::Args, Clone, Debug)] -pub struct Common { +pub struct WitOpts { /// The location of the WIT document(s). /// /// This may be specified more than once, for example: @@ -46,18 +51,23 @@ pub struct Common { #[derive(Subcommand)] pub enum Command { - /// Build a Go WebAssembly component. - Componentize(Componentize), + /// Build a Go WebAssembly binary. + Build(Build), - /// Generate Go bindings for the world. + /// Build Go test WebAssembly binary. + Test(Test), + + /// Generate Go bindings for a WIT world. Bindings(Bindings), } #[derive(Parser)] -pub struct Componentize { - /// The path to the Go binary (or look for binary in PATH if `None`). +pub struct Build { + /// Whether or not to build a WebAssembly module. + /// + /// If ommitted, this will build a component. #[arg(long)] - pub go: Option, + pub wasip1: bool, /// Final output path for the component (or `./main.wasm` if `None`). #[arg(long, short = 'o')] @@ -66,6 +76,39 @@ pub struct Componentize { /// The directory containing the "go.mod" file (or current directory if `None`). #[arg(long = "mod")] pub mod_path: Option, + + /// The path to the Go binary (or look for binary in PATH if `None`). + #[arg(long)] + pub go: Option, +} + +#[derive(Parser)] +pub struct Test { + /// Whether or not to build a WebAssembly module. + /// + /// If ommitted, this will build a component. + #[arg(long)] + pub wasip1: bool, + + /// A package containing Go test files. + /// + /// This may be specified more than once, for example: + /// `--pkg ./cmd/foo --pkg ./cmd/bar`. + /// + /// The test components will be named using the last segment of the provided path, for example: + /// `--pkg ./foo/bar/baz` will result in a file named `test_bar_baz.wasm` + #[arg(long)] + pub pkg: Vec, + + /// Output directory for test components (or current directory if `None`). + /// + /// This will be created if it does not already exist. + #[arg(long, short = 'o')] + pub output: Option, + + /// The path to the Go binary (or look for binary in PATH if `None`). + #[arg(long)] + pub go: Option, } #[derive(Parser)] @@ -80,7 +123,7 @@ pub struct Bindings { #[arg(long)] pub generate_stubs: bool, - /// Whether or not `gofmt` should be used (if present) to format generated code. + /// Whether or not `gofmt` should be used (if present in PATH) to format generated code. #[arg(long)] pub format: bool, @@ -93,39 +136,71 @@ pub struct Bindings { pub fn run + Clone, I: IntoIterator>(args: I) -> Result<()> { let options = Options::parse_from(args); match options.command { - Command::Componentize(opts) => componentize(options.common, opts), - Command::Bindings(opts) => bindings(options.common, opts), + Command::Build(opts) => build(options.wit_opts, opts), + Command::Bindings(opts) => bindings(options.wit_opts, opts), + Command::Test(opts) => test(options.wit_opts, opts), } } -fn componentize(common: Common, componentize: Componentize) -> Result<()> { - // Step 1: Build a WebAssembly core module using Go. - let core_module = componentize::build_wasm_core_module( - componentize.mod_path, - componentize.output, - componentize.go, +fn build(wit_opts: WitOpts, build: Build) -> Result<()> { + // Build a wasm module using `go build`. + let module = build_module( + build.mod_path.as_ref(), + build.output.as_ref(), + build.go.as_ref(), + build.wasip1, )?; - // Step 2: Embed the WIT documents in the core module. - componentize::embed_wit( - &core_module, - &common.wit_path, - common.world.as_deref(), - &common.features, - common.all_features, - )?; + if !build.wasip1 { + // Embed the WIT documents in the wasip1 component. + embed_wit( + &module, + &wit_opts.wit_path, + wit_opts.world.as_deref(), + &wit_opts.features, + wit_opts.all_features, + )?; + + // Update the wasm module to use the current component model ABI. + module_to_component(&module)?; + } + + Ok(()) +} + +fn test(wit_opts: WitOpts, test: Test) -> Result<()> { + if test.pkg.is_empty() { + return Err(anyhow!("Path to a package containing Go tests is required")); + } + + for pkg in test.pkg.iter() { + // Build a wasm module using `go test -c`. + let module = build_test_module(pkg, test.output.as_ref(), test.go.as_ref(), test.wasip1)?; + + if !test.wasip1 { + // Embed the WIT documents in the wasm module. + embed_wit( + &module, + &wit_opts.wit_path, + wit_opts.world.as_deref(), + &wit_opts.features, + wit_opts.all_features, + )?; + + // Update the wasm module to use the current component model ABI. + module_to_component(&module)?; + } + } - // Step 3: Update the core module to use the component model ABI. - componentize::core_module_to_component(&core_module)?; Ok(()) } -fn bindings(common: Common, bindings: Bindings) -> Result<()> { +fn bindings(wit_opts: WitOpts, bindings: Bindings) -> Result<()> { generate_bindings( - common.wit_path.as_ref(), - common.world.as_deref(), - &common.features, - common.all_features, + wit_opts.wit_path.as_ref(), + wit_opts.world.as_deref(), + &wit_opts.features, + wit_opts.all_features, bindings.generate_stubs, bindings.format, bindings.output.as_deref(), diff --git a/src/common.rs b/src/common.rs deleted file mode 100644 index 449dbda..0000000 --- a/src/common.rs +++ /dev/null @@ -1,50 +0,0 @@ -use anyhow::Result; -use std::path::{Path, PathBuf}; -use wit_parser::{PackageId, Resolve, WorldId}; - -pub fn parse_wit( - paths: &[impl AsRef], - world: Option<&str>, - features: &[String], - all_features: bool, -) -> Result<(Resolve, WorldId)> { - // If no WIT directory was provided as a parameter and none were referenced - // by Go packages, use ./wit by default. - if paths.is_empty() { - let paths = &[Path::new("wit")]; - return parse_wit(paths, world, features, all_features); - } - debug_assert!(!paths.is_empty(), "The paths should not be empty"); - - let mut resolve = Resolve { - all_features, - ..Default::default() - }; - for features in features { - for feature in features - .split(',') - .flat_map(|s| s.split_whitespace()) - .filter(|f| !f.is_empty()) - { - resolve.features.insert(feature.to_string()); - } - } - - let mut main_packages: Vec = vec![]; - for path in paths.iter().map(AsRef::as_ref) { - let (pkg, _files) = resolve.push_path(path)?; - main_packages.push(pkg); - } - - let world = resolve.select_world(&main_packages, world)?; - Ok((resolve, world)) -} - -// Converts a relative path to an absolute path. -pub fn make_path_absolute(p: &PathBuf) -> Result { - if p.is_relative() { - Ok(std::env::current_dir()?.join(p)) - } else { - Ok(p.to_owned()) - } -} diff --git a/src/lib.rs b/src/lib.rs index 4a61c79..f8a6297 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ -pub mod bindings; +pub mod cmd_bindings; +pub mod cmd_build; +pub mod cmd_test; pub mod command; -pub mod common; -pub mod componentize; +pub mod utils; diff --git a/src/componentize.rs b/src/utils.rs similarity index 55% rename from src/componentize.rs rename to src/utils.rs index 8b3b15d..085efad 100644 --- a/src/componentize.rs +++ b/src/utils.rs @@ -1,67 +1,55 @@ -use crate::common::{make_path_absolute, parse_wit}; use anyhow::{Context, Result, anyhow}; -use std::{path::PathBuf, process::Command}; - -/// Ensure that the Go version is compatible with the embedded Wasm tooling. -fn check_go_version(go_path: &PathBuf) -> Result<()> { - let output = Command::new(go_path).arg("version").output()?; - - if !output.status.success() { - return Err(anyhow!( - "'go version' command failed: {}", - String::from_utf8_lossy(&output.stderr) - )); +use std::{ + path::{Path, PathBuf}, + process::Command, +}; +use wit_parser::{PackageId, Resolve, WorldId}; + +pub fn parse_wit( + paths: &[impl AsRef], + world: Option<&str>, + features: &[String], + all_features: bool, +) -> Result<(Resolve, WorldId)> { + // If no WIT directory was provided as a parameter and none were referenced + // by Go packages, use ./wit by default. + if paths.is_empty() { + let paths = &[Path::new("wit")]; + return parse_wit(paths, world, features, all_features); } + debug_assert!(!paths.is_empty(), "The paths should not be empty"); - let version_string = String::from_utf8(output.stdout)?; - let version_regex = regex::Regex::new(r"go(\d+)\.(\d+)\.(\d+)").unwrap(); - let semver = version_regex.captures(&version_string).map(|caps| { - ( - caps[1].parse::().unwrap(), // Major - caps[2].parse::().unwrap(), // Minor - caps[3].parse::().unwrap(), // Patch - ) - }); - - if let Some((major, minor, patch)) = semver { - // TODO: there might be a patch number correlated with wasip3. - if major == 1 && minor >= 25 { - Ok(()) - } else { - Err(anyhow!( - "Go version is not valid. Expected '^1.25.0', found '{}.{}.{}'", - major, - minor, - patch - )) + let mut resolve = Resolve { + all_features, + ..Default::default() + }; + for features in features { + for feature in features + .split(',') + .flat_map(|s| s.split_whitespace()) + .filter(|f| !f.is_empty()) + { + resolve.features.insert(feature.to_string()); } - } else { - Err(anyhow!( - "Failed to parse Go version from: {}", - version_string - )) } -} - -/// Update the WebAssembly core module to use the component model ABI. -pub fn core_module_to_component(wasm_file: &PathBuf) -> Result<()> { - // In the rare case the snapshot needs to be updated, the latest version - // can be found here: https://github.com/bytecodealliance/wasmtime/releases - const WASIP1_SNAPSHOT: &[u8] = include_bytes!("wasi_snapshot_preview1.reactor.wasm"); - let wasm: Vec = wat::Parser::new().parse_file(wasm_file)?; - - let mut encoder = wit_component::ComponentEncoder::default().validate(true); - encoder = encoder.module(&wasm)?; - encoder = encoder.adapter("wasi_snapshot_preview1", WASIP1_SNAPSHOT)?; - let bytes = encoder - .encode() - .context("failed to encode component from module")?; + let mut main_packages: Vec = vec![]; + for path in paths.iter().map(AsRef::as_ref) { + let (pkg, _files) = resolve.push_path(path)?; + main_packages.push(pkg); + } - std::fs::write(wasm_file, bytes) - .context(format!("failed to write `{}`", wasm_file.display()))?; + let world = resolve.select_world(&main_packages, world)?; + Ok((resolve, world)) +} - Ok(()) +// Converts a relative path to an absolute path. +pub fn make_path_absolute(p: &PathBuf) -> Result { + if p.is_relative() { + Ok(std::env::current_dir()?.join(p)) + } else { + Ok(p.to_owned()) + } } pub fn embed_wit( @@ -80,69 +68,68 @@ pub fn embed_wit( wit_component::StringEncoding::UTF8, )?; std::fs::write(wasm_file, wasm) - .context(format!("failed to write `{}`", wasm_file.display()))?; + .context(format!("failed to write '{}'", wasm_file.display()))?; Ok(()) } -/// Compiles a Go application to WebAssembly core. -pub fn build_wasm_core_module( - go_module: Option, - out: Option, - go_path: Option, -) -> Result { - let go = match &go_path { - Some(p) => make_path_absolute(p)?, - None => PathBuf::from("go"), - }; - - check_go_version(&go)?; +/// Update the wasm module to use the current component model ABI. +pub fn module_to_component(wasm_file: &PathBuf) -> Result<()> { + // In the rare case the snapshot needs to be updated, the latest version + // can be found here: https://github.com/bytecodealliance/wasmtime/releases + const WASIP1_SNAPSHOT: &[u8] = include_bytes!("wasi_snapshot_preview1.reactor.wasm"); + let wasm: Vec = wat::Parser::new().parse_file(wasm_file)?; - let out_path_buf = match &out { - Some(p) => make_path_absolute(p)?, - None => std::env::current_dir()?.join("main.wasm"), - }; + let mut encoder = wit_component::ComponentEncoder::default().validate(true); + encoder = encoder.module(&wasm)?; + encoder = encoder.adapter("wasi_snapshot_preview1", WASIP1_SNAPSHOT)?; - // The `go build` command doesn't overwrite the output file, which causes - // issues if the `componentize-go componentize` command is run multiple times. - if out_path_buf.exists() { - std::fs::remove_file(&out_path_buf)?; - } + let bytes = encoder + .encode() + .context("failed to encode component from module")?; - let out_path = out_path_buf - .to_str() - .ok_or_else(|| anyhow!("Output path is not valid unicode"))?; + std::fs::write(wasm_file, bytes) + .context(format!("failed to write `{}`", wasm_file.display()))?; - let module_path = match &go_module { - Some(p) => { - if !p.is_dir() { - return Err(anyhow!("Module path '{}' is not a directory", p.display())); - } - p.to_str() - .ok_or_else(|| anyhow!("Module path is not valid unicode"))? - } - None => ".", - }; + Ok(()) +} - let output = Command::new(&go) - .args([ - "build", - "-C", - module_path, - "-buildmode=c-shared", - "-ldflags=-checklinkname=0", - "-o", - out_path, - ]) - .env("GOOS", "wasip1") - .env("GOARCH", "wasm") - .output()?; +/// Ensure that the Go version is compatible with the embedded Wasm tooling. +pub fn check_go_version(go_path: &PathBuf) -> Result<()> { + let output = Command::new(go_path).arg("version").output()?; if !output.status.success() { return Err(anyhow!( - "'go build' command failed: {}", + "'go version' command failed: {}", String::from_utf8_lossy(&output.stderr) )); } - Ok(PathBuf::from(out_path)) + let version_string = String::from_utf8(output.stdout)?; + let version_regex = regex::Regex::new(r"go(\d+)\.(\d+)\.(\d+)").unwrap(); + let semver = version_regex.captures(&version_string).map(|caps| { + ( + caps[1].parse::().unwrap(), // Major + caps[2].parse::().unwrap(), // Minor + caps[3].parse::().unwrap(), // Patch + ) + }); + + if let Some((major, minor, patch)) = semver { + // TODO: there might be a patch number correlated with wasip3. + if major == 1 && minor >= 25 { + Ok(()) + } else { + Err(anyhow!( + "Go version is not valid. Expected '^1.25.0', found '{}.{}.{}'", + major, + minor, + patch + )) + } + } else { + Err(anyhow!( + "Failed to parse Go version from: {}", + version_string + )) + } } diff --git a/tests/Cargo.toml b/tests/Cargo.toml index fd274ef..e25713e 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -7,6 +7,7 @@ edition = { workspace = true } workspace = true [dependencies] +componentize-go = { path = ".." } anyhow = { workspace = true } bzip2 = "0.6.1" once_cell = "1.21.3" diff --git a/tests/src/lib.rs b/tests/src/lib.rs index 8d2a75b..ab74a89 100644 --- a/tests/src/lib.rs +++ b/tests/src/lib.rs @@ -93,38 +93,192 @@ mod tests { } struct App<'a> { - path: &'a str, - world: &'a str, - wasm_path: PathBuf, + /// The path to the example application + path: String, + /// The WIT world to target + world: String, + /// The output path of the wasm file + wasm_path: String, + /// The path to the directory containing the WIT files + wit_path: String, + /// The child process ID of a running wasm app process: Option, + /// Any tests that need to be compiled and run as such + tests: Option<&'a [Test<'a>]>, + } + + struct Test<'a> { + should_fail: bool, + pkg_path: &'a str, } impl<'a> App<'a> { /// Create a new app runner. - fn new(path: &'a str, world: &'a str) -> Self { + fn new(path: &'a str, world: &'a str, tests: Option<&'a [Test<'a>]>) -> Self { + let path = componentize_go::utils::make_path_absolute(&PathBuf::from(path)) + .expect("failed to make app path absolute"); + App { - path, - world, - wasm_path: PathBuf::from(path).join("main.wasm"), + path: path + .clone() + .to_str() + .expect("app path is not valid unicode") + .to_string(), + world: world.to_string(), + wasm_path: path + .join("main.wasm") + .to_str() + .expect("wasm_path is not valid unicode") + .to_string(), + wit_path: path + .join("wit") + .to_str() + .expect("wit_path is not valid unicode") + .to_string(), process: None, + tests, } } + // Build unit tests with componentize-go + fn build_tests(&self, go: Option<&PathBuf>) -> Result<()> { + let test_pkgs = if let Some(pkgs) = self.tests { + pkgs + } else { + return Err(anyhow!( + "Please include the test_pkg_paths when creating App::new()" + )); + }; + + self.generate_bindings(go)?; + + let mut test_cmd = Command::new(COMPONENTIZE_GO_PATH.as_path()); + test_cmd + .args(["-w", &self.world]) + .args(["-d", &self.wit_path]) + .arg("test") + .arg("--wasip1"); + + // Add all the paths to the packages that have unit tests to compile + for test in test_pkgs.iter() { + test_cmd.args(["--pkg", test.pkg_path]); + } + + // `go test -c` needs to be in the same path as the go.mod file. + test_cmd.current_dir(&self.path); + + let test_output = test_cmd.output().expect(&format!( + "failed to execute componentize-go for \"{}\"", + self.path + )); + + if !test_output.status.success() { + return Err(anyhow!( + "failed to build application \"{}\": {}", + self.path, + String::from_utf8_lossy(&test_output.stderr) + )); + } + + Ok(()) + } + + fn run_tests(&self) -> Result<()> { + let example_dir = PathBuf::from(&self.path); + if let Some(tests) = self.tests { + let mut test_errors: Vec = vec![]; + for test in tests.iter() { + let wasm_file = example_dir.join(componentize_go::cmd_test::get_test_filename( + &PathBuf::from(test.pkg_path), + )); + match Command::new("wasmtime") + .args(["run", wasm_file.to_str().unwrap()]) + .output() + { + Ok(output) => { + let succeeded = output.status.success(); + if test.should_fail && succeeded { + test_errors.push(format!( + "The '{}' tests should have failed", + test.pkg_path + )); + } else if !test.should_fail && !succeeded { + test_errors.push(format!("The '{}' tests should have passed, but failed with the following output:\n\n{}", test.pkg_path, String::from_utf8_lossy(&output.stdout))); + } + } + Err(e) => { + test_errors.push(format!( + "Failed to run wasmtime for '{}': {}", + test.pkg_path, e + )); + } + } + } + + if !test_errors.is_empty() { + let err_msg = format!( + "{}{}{}", + "\n====================\n", + &test_errors.join("\n\n====================\n"), + "\n\n====================\n" + ); + return Err(anyhow!(err_msg)); + } + } else { + return Err(anyhow!( + "Please include the test_pkg_paths when creating App::new()" + )); + } + + Ok(()) + } + /// Build the app with componentize-go. - fn build(&self, go: Option) -> Result<()> { - let app_path = PathBuf::from(self.path); - let wit_path = app_path.join("wit"); + fn build(&self, go: Option<&PathBuf>) -> Result<()> { + self.generate_bindings(go)?; + + // Build component + let mut build_cmd = Command::new(COMPONENTIZE_GO_PATH.as_path()); + build_cmd + .args(["-w", &self.world]) + .args(["-d", &self.wit_path]) + .arg("build") + .args(["-o", &self.wasm_path]); + + if let Some(go_path) = go.as_ref() { + build_cmd.args(["--go", go_path.to_str().unwrap()]); + } + + // Run `go build` in the same directory as the go.mod file. + build_cmd.current_dir(&self.path); + + let build_output = build_cmd.output().expect(&format!( + "failed to execute componentize-go for \"{}\"", + self.path + )); - // Generate bindings + if !build_output.status.success() { + return Err(anyhow!( + "failed to build application \"{}\": {}", + self.path, + String::from_utf8_lossy(&build_output.stderr) + )); + } + + Ok(()) + } + + fn generate_bindings(&self, go: Option<&PathBuf>) -> Result<()> { let bindings_output = Command::new(COMPONENTIZE_GO_PATH.as_path()) - .args(["-w", self.world]) - .args(["-d", wit_path.to_str().unwrap()]) + .args(["-w", &self.world]) + .args(["-d", &self.wit_path]) .arg("bindings") - .args(["-o", self.path]) + .args(["-o", &self.path]) + .current_dir(&self.path) .output() .expect(&format!( "failed to generate bindings for application \"{}\"", - self.path + &self.path )); if !bindings_output.status.success() { return Err(anyhow!( @@ -142,39 +296,13 @@ mod tests { }) .arg("mod") .arg("tidy") - .current_dir(self.path) + .current_dir(&self.path) .output() .expect("failed to tidy Go mod"); if !tidy_output.status.success() { return Err(anyhow!("{}", String::from_utf8_lossy(&tidy_output.stderr))); } - // Build component - let mut build_cmd = Command::new(COMPONENTIZE_GO_PATH.as_path()); - build_cmd - .args(["-w", self.world]) - .args(["-d", wit_path.to_str().unwrap()]) - .arg("componentize") - .args(["--mod", self.path]) - .args(["-o", self.wasm_path.to_str().unwrap()]); - - if let Some(go_path) = go.as_ref() { - build_cmd.args(["--go", go_path.to_str().unwrap()]); - } - - let build_output = build_cmd.output().expect(&format!( - "failed to execute componentize-go for \"{}\"", - self.path - )); - - if !build_output.status.success() { - return Err(anyhow!( - "failed to build application \"{}\": {}", - self.path, - String::from_utf8_lossy(&build_output.stderr) - )); - } - Ok(()) } @@ -190,7 +318,7 @@ mod tests { .args(["--addr", &format!("0.0.0.0:{port}")]) .arg("-Sp3,cli") .arg("-Wcomponent-model-async") - .arg(self.wasm_path.to_str().unwrap()) + .arg(&self.wasm_path) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() @@ -227,17 +355,36 @@ mod tests { #[tokio::test] async fn example_wasip2() { - let mut app = App::new("../examples/wasip2", "wasip2-example"); + let unit_tests = [ + Test { + should_fail: false, + pkg_path: "./unit_tests_should_pass", + }, + Test { + should_fail: true, + pkg_path: "./unit_tests_should_fail", + }, + ]; + + let mut app = App::new("../examples/wasip2", "wasip2-example", Some(&unit_tests)); + app.build(None).expect("failed to build app"); + app.run("/", "Hello, world!") .await .expect("app failed to run"); + + app.build_tests(None) + .expect("failed to build app unit tests"); + + app.run_tests() + .expect("tests succeeded/failed when they should not have"); } #[tokio::test] async fn example_wasip3() { - let mut app = App::new("../examples/wasip3", "wasip3-example"); - app.build(Some(patched_go_path().await)) + let mut app = App::new("../examples/wasip3", "wasip3-example", None); + app.build(Some(&patched_go_path().await)) .expect("failed to build app"); app.run("/hello", "Hello, world!") .await