diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9a33b88 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Keep runtime-cache source inputs identical across operating systems. +/Cargo.toml text eol=lf +/Cargo.lock text eol=lf +/crates/core/build.rs text eol=lf +/crates/runtime/** text=auto eol=lf diff --git a/.github/actions/setup-runtime-cache/action.yml b/.github/actions/setup-runtime-cache/action.yml index 958a515..c428b63 100644 --- a/.github/actions/setup-runtime-cache/action.yml +++ b/.github/actions/setup-runtime-cache/action.yml @@ -1,4 +1,5 @@ name: Setup runtime wasm cache +description: Restore or build development runtime Wasm shared across runner operating systems runs: using: composite @@ -8,13 +9,14 @@ runs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: crates/core/prebuilt - key: runtime-wasm-v2-${{ hashFiles('Cargo.toml', 'Cargo.lock', 'crates/runtime/**', 'crates/core/build.rs') }} + key: runtime-wasm-v3-${{ hashFiles('Cargo.toml', 'Cargo.lock', 'crates/runtime/**', 'crates/core/build.rs') }} + enableCrossOsArchive: true - name: Build runtime wasm if: steps.cache.outputs.cache-hit != 'true' shell: bash run: | - cargo build -p componentize-qjs + cargo build --locked -p componentize-qjs mkdir -p crates/core/prebuilt for f in runtime.wasm runtime-opt-size.wasm runtime-sync.wasm runtime-opt-size-sync.wasm; do src=$(find target -path "*/out/$f" -type f | sort | tail -n 1) diff --git a/Cargo.toml b/Cargo.toml index cc2d5f9..4470b93 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,3 +88,6 @@ incremental = false debug = "line-tables-only" incremental = false opt-level = 3 + +[profile.test.package.componentize-qjs-cli] +opt-level = 1 diff --git a/tests/async_types.rs b/tests/async_types.rs index 036c58f..6f64582 100644 --- a/tests/async_types.rs +++ b/tests/async_types.rs @@ -1,4 +1,5 @@ //! Async component model tests for componentize-qjs. +//! Value conversions share compiled code; resource and metadata-order tests remain standalone. #![cfg(feature = "component-model-async")] mod common; @@ -7,28 +8,62 @@ use std::pin::Pin; use std::sync::{Arc, Mutex}; use std::task::{Context, Poll}; -use common::{TestCase, WasiCtxState}; +use common::{AsyncComponentInstance, TestCase, WasiCtxState}; use wasmtime::component::{ - Destination, FutureConsumer, FutureReader, Source, StreamConsumer, StreamProducer, + Component, Destination, FutureConsumer, FutureReader, Source, StreamConsumer, StreamProducer, StreamReader, StreamResult, Val, VecBuffer, }; use wasmtime::{AsContextMut, StoreContextMut}; +async fn component() -> AsyncComponentInstance { + static COMPONENT: tokio::sync::OnceCell = tokio::sync::OnceCell::const_new(); + let component = COMPONENT + .get_or_init(|| async { + TestCase::new() + .wit(include_str!("wit/all/all-async.wit")) + .script(include_str!("js/all-async.js")) + .compile_async() + .await + .expect("failed to compile all-async fixture") + }) + .await; + + AsyncComponentInstance::from_component(component) + .await + .expect("failed to instantiate all-async fixture") +} + +#[tokio::test] +async fn test_all_async_instances_are_isolated() { + let mut first = component().await; + let mut second = component().await; + assert_eq!( + first.call1_async("next-count", &[]).await.unwrap(), + Val::U32(1) + ); + assert_eq!( + first.call1_async("next-count", &[]).await.unwrap(), + Val::U32(2) + ); + assert_eq!( + second.call1_async("next-count", &[]).await.unwrap(), + Val::U32(1) + ); + + drop(first); + assert_eq!( + component() + .await + .call1_async("next-count", &[]) + .await + .unwrap(), + Val::U32(1) + ); +} + #[tokio::test] async fn test_async_echo_u32() { - let mut instance = TestCase::new() - .wit( - r#" - package test:async-echo; - world async-echo { - export echo-u32: async func(x: u32) -> u32; - } - "#, - ) - .script("export async function echoU32(x) { return x; }") - .build_async() - .await - .unwrap(); + let mut instance = component().await; let result = instance .call1_async("echo-u32", &[Val::U32(42)]) @@ -39,19 +74,7 @@ async fn test_async_echo_u32() { #[tokio::test] async fn test_async_echo_string() { - let mut instance = TestCase::new() - .wit( - r#" - package test:async-echo; - world async-echo { - export echo-string: async func(s: string) -> string; - } - "#, - ) - .script(r#"export async function echoString(s) { return s; }"#) - .build_async() - .await - .unwrap(); + let mut instance = component().await; let result = instance .call1_async("echo-string", &[Val::String("hello async".into())]) @@ -62,19 +85,7 @@ async fn test_async_echo_string() { #[tokio::test] async fn test_async_echo_bool() { - let mut instance = TestCase::new() - .wit( - r#" - package test:async-echo; - world async-echo { - export echo-bool: async func(b: bool) -> bool; - } - "#, - ) - .script("export async function echoBool(b) { return b; }") - .build_async() - .await - .unwrap(); + let mut instance = component().await; let result = instance .call1_async("echo-bool", &[Val::Bool(true)]) @@ -85,19 +96,7 @@ async fn test_async_echo_bool() { #[tokio::test] async fn test_async_void_function() { - let mut instance = TestCase::new() - .wit( - r#" - package test:async-void; - world async-void { - export do-nothing: async func(); - } - "#, - ) - .script("export async function doNothing() { }") - .build_async() - .await - .unwrap(); + let mut instance = component().await; let results = instance.call_async("do-nothing", &[], 0).await.unwrap(); assert!(results.is_empty()); @@ -105,27 +104,7 @@ async fn test_async_void_function() { #[tokio::test] async fn test_async_with_await() { - let mut instance = TestCase::new() - .wit( - r#" - package test:async-await; - world async-await { - export delayed-echo: async func(x: u32) -> u32; - } - "#, - ) - .script( - r#" - export async function delayedEcho(x) { - // Simulate async work with a resolved promise chain - await Promise.resolve(); - return x + 1; - } - "#, - ) - .build_async() - .await - .unwrap(); + let mut instance = component().await; let result = instance .call1_async("delayed-echo", &[Val::U32(99)]) @@ -377,29 +356,7 @@ async fn test_async_method_only_resource_interface() { #[tokio::test] async fn test_async_echo_record() { - let mut instance = TestCase::new() - .wit( - r#" - package test:async-record; - world async-record { - record point { - x: f64, - y: f64, - } - export echo-point: async func(p: point) -> point; - } - "#, - ) - .script( - r#" - export async function echoPoint(p) { - return { x: p.x * 2, y: p.y * 2 }; - } - "#, - ) - .build_async() - .await - .unwrap(); + let mut instance = component().await; let input = Val::Record(vec![ ("x".to_string(), Val::Float64(1.5)), @@ -421,25 +378,7 @@ async fn test_async_echo_record() { #[tokio::test] async fn test_async_echo_option() { - let mut instance = TestCase::new() - .wit( - r#" - package test:async-option; - world async-option { - export echo-option: async func(x: option) -> option; - } - "#, - ) - .script( - r#" - export async function echoOption(x) { - return x; - } - "#, - ) - .build_async() - .await - .unwrap(); + let mut instance = component().await; // Some case let result = instance @@ -458,28 +397,7 @@ async fn test_async_echo_option() { #[tokio::test] async fn test_async_echo_result() { - let mut instance = TestCase::new() - .wit( - r#" - package test:async-result; - world async-result { - export safe-divide: async func(a: f64, b: f64) -> result; - } - "#, - ) - .script( - r#" - export async function safeDivide(a, b) { - if (b === 0) { - throw "division by zero"; - } - return a / b; - } - "#, - ) - .build_async() - .await - .unwrap(); + let mut instance = component().await; // Ok case let result = instance @@ -501,25 +419,7 @@ async fn test_async_echo_result() { #[tokio::test] async fn test_async_echo_list() { - let mut instance = TestCase::new() - .wit( - r#" - package test:async-list; - world async-list { - export double-list: async func(xs: list) -> list; - } - "#, - ) - .script( - r#" - export async function doubleList(xs) { - return xs.map(x => x * 2); - } - "#, - ) - .build_async() - .await - .unwrap(); + let mut instance = component().await; let input = Val::List(vec![Val::U32(1), Val::U32(2), Val::U32(3)]); let result = instance.call1_async("double-list", &[input]).await.unwrap(); @@ -2036,30 +1936,7 @@ async fn test_future_build_with_input_output() { #[tokio::test] async fn test_async_multiple_awaits() { - let mut instance = TestCase::new() - .wit( - r#" - package test:multi-await; - world multi-await { - export chain: async func(x: u32) -> u32; - } - "#, - ) - .script( - r#" - export async function chain(x) { - let result = x; - // Multiple promise resolutions to test the callback loop - result = await Promise.resolve(result + 1); - result = await Promise.resolve(result + 1); - result = await Promise.resolve(result + 1); - return result; - } - "#, - ) - .build_async() - .await - .unwrap(); + let mut instance = component().await; let result = instance .call1_async("chain", &[Val::U32(10)]) @@ -2109,28 +1986,7 @@ async fn test_async_error_in_promise() { #[tokio::test] async fn test_async_result_no_error_payload() { - let mut instance = TestCase::new() - .wit( - r#" - package test:async-result-no-err; - world async-result-no-err { - export validate: async func(x: u32) -> result; - } - "#, - ) - .script( - r#" - export async function validate(x) { - if (x > 100) { - throw undefined; - } - return x * 2; - } - "#, - ) - .build_async() - .await - .unwrap(); + let mut instance = component().await; let result = instance .call1_async("validate", &[Val::U32(50)]) @@ -2147,32 +2003,7 @@ async fn test_async_result_no_error_payload() { #[tokio::test] async fn test_async_variant_mixed_payloads() { - let mut instance = TestCase::new() - .wit( - r#" - package test:async-variant; - world async-variant { - variant response { - empty, - message(string), - code(u32), - } - export process: async func(kind: u32) -> response; - } - "#, - ) - .script( - r#" - export async function process(kind) { - if (kind === 0) return { tag: "empty" }; - if (kind === 1) return { tag: "message", val: "hello" }; - return { tag: "code", val: 42 }; - } - "#, - ) - .build_async() - .await - .unwrap(); + let mut instance = component().await; let result = instance .call1_async("process", &[Val::U32(0)]) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 5fe5f2d..1683319 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,4 +1,5 @@ //! Shared test harness for componentize-qjs integration tests. +//! Shared fixtures cache compiled components, never stores or live instances. #![allow(dead_code)] use std::fs; @@ -135,37 +136,39 @@ impl TestCase { /// Build the component and return a live instance ready for calls. pub fn build(self) -> anyhow::Result { - let dir = TempDir::new()?; - - let wit_path = if let Some(ref wit_dir) = self.wit_dir { - wit_dir.clone() - } else { - let p = dir.path().join("test.wit"); - fs::write(&p, self.wit.as_deref().unwrap())?; - p - }; - - let opts = ComponentizeOpts { - wit_path: &wit_path, - js_source: self.script.as_deref().unwrap(), - js_path: None, - module_root: None, - world_name: self.world_name.as_deref(), - stub_wasi: self.stub_wasi, - disable_gc: false, - runtime: Runtime::Default, - }; + let component = self.compile()?; + ComponentInstance::from_component_with_stdin( + &component, + self.env_vars, + self.stdin, + self.expectations, + ) + } + /// Compile a reusable synchronous fixture without instantiating its snapshot. + pub fn compile(&self) -> anyhow::Result { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build()?; - let wasm = rt.block_on(componentize_qjs::componentize(&opts))?; - ComponentInstance::from_wasm_with_stdin(wasm, self.env_vars, self.stdin, self.expectations) + let wasm = rt.block_on(self.componentize())?; + Ok(Component::new(engine(), wasm)?) } /// Build the component and return an async-capable instance. pub async fn build_async(self) -> anyhow::Result { + let component = self.compile_async().await?; + AsyncComponentInstance::from_component_with_stdin(&component, self.env_vars, self.stdin) + .await + } + + /// Compile a reusable fixture for the async test engine. + pub async fn compile_async(&self) -> anyhow::Result { + let wasm = self.componentize().await?; + Ok(Component::new(async_engine(), wasm)?) + } + + async fn componentize(&self) -> anyhow::Result> { let dir = TempDir::new()?; let wit_path = if let Some(ref wit_dir) = self.wit_dir { @@ -187,9 +190,7 @@ impl TestCase { runtime: Runtime::Default, }; - let wasm = componentize_qjs::componentize(&opts).await?; - - AsyncComponentInstance::from_wasm_with_stdin(wasm, self.env_vars, self.stdin).await + componentize_qjs::componentize(&opts).await } } @@ -218,6 +219,21 @@ impl ComponentInstance { ) -> anyhow::Result { let engine = engine(); let component = Component::new(engine, &wasm)?; + Self::from_component_with_stdin(&component, env_vars, stdin, expectations) + } + + /// Instantiate shared compiled code with fresh JavaScript and WASI state. + pub fn from_component(component: &Component) -> anyhow::Result { + Self::from_component_with_stdin(component, vec![], None, vec![]) + } + + fn from_component_with_stdin( + component: &Component, + env_vars: Vec<(String, String)>, + stdin: Option, + expectations: Vec, + ) -> anyhow::Result { + let engine = component.engine(); let mut wasi_builder = WasiCtxBuilder::new(); if !env_vars.is_empty() { @@ -230,14 +246,15 @@ impl ComponentInstance { wasi_builder .stdin(MemoryInputPipe::new(stdin.unwrap_or_default())) .stdout(stdout.clone()); + let wasi = wasi_builder.build(); let table = ResourceTable::new(); - let mut store = Store::new(engine, WasiCtxState { wasi, table }); + let mut store = Store::new(engine, WasiCtxState { wasi, table }); let mut linker = Linker::new(engine); wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?; - let instance = linker.instantiate(&mut store, &component)?; + let instance = linker.instantiate(&mut store, component)?; Ok(ComponentInstance { store, @@ -247,6 +264,16 @@ impl ComponentInstance { }) } + /// Register an expected call against this instance. + pub fn expect_call(mut self, name: &str, params: Vec, expected: Val) -> Self { + self.expectations.push(Expectation { + func_name: name.to_string(), + params, + expected, + }); + self + } + /// Call an exported function with the given params and return results. pub fn call(&mut self, name: &str, params: &[Val], result_count: usize) -> Vec { let func = self @@ -312,6 +339,20 @@ impl AsyncComponentInstance { ) -> anyhow::Result { let engine = async_engine(); let component = Component::new(engine, &wasm)?; + Self::from_component_with_stdin(&component, env_vars, stdin).await + } + + /// Instantiate shared compiled code with fresh JavaScript and WASI state. + pub async fn from_component(component: &Component) -> anyhow::Result { + Self::from_component_with_stdin(component, vec![], None).await + } + + async fn from_component_with_stdin( + component: &Component, + env_vars: Vec<(String, String)>, + stdin: Option, + ) -> anyhow::Result { + let engine = component.engine(); let mut wasi_builder = WasiCtxBuilder::new(); if !env_vars.is_empty() { @@ -324,15 +365,16 @@ impl AsyncComponentInstance { wasi_builder .stdin(MemoryInputPipe::new(stdin.unwrap_or_default())) .stdout(stdout.clone()); + let wasi = wasi_builder.build(); let table = ResourceTable::new(); - let mut store = Store::new(engine, WasiCtxState { wasi, table }); + let mut store = Store::new(engine, WasiCtxState { wasi, table }); let mut linker = Linker::new(engine); wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; wasmtime_wasi::p3::add_to_linker(&mut linker)?; - let instance = linker.instantiate_async(&mut store, &component).await?; + let instance = linker.instantiate_async(&mut store, component).await?; Ok(AsyncComponentInstance { store, diff --git a/tests/js/all-async.js b/tests/js/all-async.js new file mode 100644 index 0000000..c7e69a2 --- /dev/null +++ b/tests/js/all-async.js @@ -0,0 +1,48 @@ +export async function echoU32(x) { return x; } +export async function echoString(s) { return s; } +export async function echoBool(b) { return b; } +export async function doNothing() {} + +export async function delayedEcho(x) { + await Promise.resolve(); + return x + 1; +} + +export async function echoPoint(p) { return { x: p.x * 2, y: p.y * 2 }; } +export async function echoOption(x) { return x; } + +export async function safeDivide(a, b) { + if (b === 0) { + throw "division by zero"; + } + return a / b; +} + +export async function doubleList(xs) { return xs.map(x => x * 2); } + +export async function chain(x) { + let result = x; + result = await Promise.resolve(result + 1); + result = await Promise.resolve(result + 1); + result = await Promise.resolve(result + 1); + return result; +} + +export async function validate(x) { + if (x > 100) { + throw undefined; + } + return x * 2; +} + +export async function process(kind) { + if (kind === 0) return { tag: "empty" }; + if (kind === 1) return { tag: "message", val: "hello" }; + return { tag: "code", val: 42 }; +} + +let count = 0; +export async function nextCount() { + await Promise.resolve(); + return ++count; +} diff --git a/tests/js/all.js b/tests/js/all.js new file mode 100644 index 0000000..5925e94 --- /dev/null +++ b/tests/js/all.js @@ -0,0 +1,103 @@ +export function greet() { return "Hello, World!"; } +export function add(a, b) { return a + b; } +export function addU8(a, b) { return a + b; } +export function addS8(a, b) { return a + b; } +export function addU16(a, b) { return a + b; } +export function addS16(a, b) { return a + b; } +export function addU32(a, b) { return a + b; } +export function addS32(a, b) { return a + b; } +export function addU64(a, b) { return a + b; } +export function addS64(a, b) { return a + b; } +export function addF32(a, b) { return a + b; } +export function addF64(a, b) { return a + b; } +export function negate(b) { return !b; } +export function addPoints(a, b) { return { x: a.x + b.x, y: a.y + b.y }; } +export function sumList(nums) { return nums.reduce((a, b) => a + b, 0); } + +export function scaleMap(values) { + if (!(values instanceof Map)) { + throw new TypeError("expected Map"); + } + const result = new Map(); + for (const [key, value] of values) { + result.set(key.toUpperCase(), value * 2); + } + return result; +} + +export function sumMap(values) { + const result = new Map(); + for (const [key, items] of values) { + result.set(key, items.reduce((sum, item) => sum + item, 0)); + } + return result; +} + +export function emptyMap() { return new Map(); } +export function bytes() { return new Uint8Array([0, 1, 127, 255]); } +export function empty() { return new Uint8Array(); } + +export function maybeDouble(n) { + if (n === null || n === undefined) { return null; } + return n * 2; +} + +export function safeDiv(a, b) { + if (b === 0) { throw "division by zero"; } + return Math.floor(a / b); +} + +export function takeString(s) { return s.length; } +export function returnString() { return "hello from js"; } +export function concatStrings(a, b) { return a + b; } +export function takeChar(c) { return c.codePointAt(0); } +export function returnChar() { return "A"; } + +export function identifyColor(c) { + if (c === "red") return "is red"; + if (c === "green") return "is green"; + if (c === "blue") return "is blue"; + return "unknown"; +} + +export function favoriteColor() { return "green"; } + +export function describeShape(s) { + if (s.tag === "circle") return "circle with radius " + s.val; + if (s.tag === "none") return "no shape"; + return "unknown"; +} + +export function makeCircle(r) { return { tag: "circle", val: r }; } +export function checkRead(p) { return p.read === true; } +export function readWrite() { return { read: true, write: true }; } +export function swap(a, b) { return [b, a]; } + +export function sumTen(a1, a2, a3, a4, a5, a6, a7, a8, a9, a10) { + return a1 + a2 + a3 + a4 + a5 + a6 + a7 + a8 + a9 + a10; +} + +export function getAnswer() { return 42; } +export function getMessage() { return "hello"; } +export function getFlag() { return true; } +export function flatten(nested) { return nested.reduce((acc, arr) => acc.concat(arr), []); } +export function greetPerson(p) { return "Hello " + p.name + ", age " + p.age + ", active: " + p.active; } +export function makePerson(name, age) { return { name: name, age: age, active: true }; } +export function joinStrings(parts, sep) { return parts.join(sep); } +export function countStrings(parts) { return parts.length; } +export function hello() { return "hello"; } + +export function deepFlatten(nested) { + let result = []; + for (const mid of nested) { + for (const inner of mid) { + for (const v of inner) { + result.push(v); + } + } + } + return result; +} + +let count = 0; +export function nextCount() { return ++count; } diff --git a/tests/wit/all/all-async.wit b/tests/wit/all/all-async.wit new file mode 100644 index 0000000..11527be --- /dev/null +++ b/tests/wit/all/all-async.wit @@ -0,0 +1,20 @@ +package test:all; + +world all-async { + record point { x: f64, y: f64 } + variant response { empty, message(string), code(u32) } + + export echo-u32: async func(x: u32) -> u32; + export echo-string: async func(s: string) -> string; + export echo-bool: async func(b: bool) -> bool; + export do-nothing: async func(); + export delayed-echo: async func(x: u32) -> u32; + export echo-point: async func(p: point) -> point; + export echo-option: async func(x: option) -> option; + export safe-divide: async func(a: f64, b: f64) -> result; + export double-list: async func(xs: list) -> list; + export chain: async func(x: u32) -> u32; + export validate: async func(x: u32) -> result; + export process: async func(kind: u32) -> response; + export next-count: async func() -> u32; +} diff --git a/tests/wit/all/all.wit b/tests/wit/all/all.wit new file mode 100644 index 0000000..e8cc91c --- /dev/null +++ b/tests/wit/all/all.wit @@ -0,0 +1,56 @@ +package test:all; + +world all { + record point { x: f64, y: f64 } + record person { name: string, age: u32, active: bool } + enum color { red, green, blue } + variant shape { circle(f64), none } + flags permissions { read, write, execute } + + export greet: func() -> string; + export add: func(a: u32, b: u32) -> u32; + export add-u8: func(a: u8, b: u8) -> u8; + export add-s8: func(a: s8, b: s8) -> s8; + export add-u16: func(a: u16, b: u16) -> u16; + export add-s16: func(a: s16, b: s16) -> s16; + export add-u32: func(a: u32, b: u32) -> u32; + export add-s32: func(a: s32, b: s32) -> s32; + export add-u64: func(a: u64, b: u64) -> u64; + export add-s64: func(a: s64, b: s64) -> s64; + export add-f32: func(a: f32, b: f32) -> f32; + export add-f64: func(a: f64, b: f64) -> f64; + export negate: func(b: bool) -> bool; + export add-points: func(a: point, b: point) -> point; + export sum-list: func(nums: list) -> u32; + export scale-map: func(values: map) -> map; + export sum-map: func(values: map>) -> map; + export empty-map: func() -> map; + export bytes: func() -> list; + export empty: func() -> list; + export maybe-double: func(n: option) -> option; + export safe-div: func(a: u32, b: u32) -> result; + export take-string: func(s: string) -> u32; + export return-string: func() -> string; + export concat-strings: func(a: string, b: string) -> string; + export take-char: func(c: char) -> u32; + export return-char: func() -> char; + export identify-color: func(c: color) -> string; + export favorite-color: func() -> color; + export describe-shape: func(s: shape) -> string; + export make-circle: func(r: f64) -> shape; + export check-read: func(p: permissions) -> bool; + export read-write: func() -> permissions; + export swap: func(a: u32, b: u32) -> tuple; + export sum-ten: func(a1: u32, a2: u32, a3: u32, a4: u32, a5: u32, a6: u32, a7: u32, a8: u32, a9: u32, a10: u32) -> u32; + export get-answer: func() -> u32; + export get-message: func() -> string; + export get-flag: func() -> bool; + export flatten: func(nested: list>) -> list; + export greet-person: func(p: person) -> string; + export make-person: func(name: string, age: u32) -> person; + export join-strings: func(parts: list, sep: string) -> string; + export count-strings: func(parts: list) -> u32; + export hello: func() -> string; + export deep-flatten: func(nested: list>>) -> list; + export next-count: func() -> u32; +} diff --git a/tests/wit_types.rs b/tests/wit_types.rs index e6b1b78..40e4368 100644 --- a/tests/wit_types.rs +++ b/tests/wit_types.rs @@ -1,9 +1,37 @@ //! WIT type integration tests for componentize-qjs +//! Value conversions share compiled code; world-specific regressions remain standalone. mod common; -use wasmtime::component::Val; +use std::sync::OnceLock; -use common::TestCase; +use wasmtime::component::{Component, Val}; + +use common::{ComponentInstance, TestCase}; + +fn component() -> ComponentInstance { + static COMPONENT: OnceLock = OnceLock::new(); + let component = COMPONENT.get_or_init(|| { + TestCase::new() + .wit(include_str!("wit/all/all.wit")) + .script(include_str!("js/all.js")) + .compile() + .expect("failed to compile all fixture") + }); + + ComponentInstance::from_component(component).expect("failed to instantiate all fixture") +} + +#[test] +fn test_all_instances_are_isolated() { + let mut first = component(); + let mut second = component(); + assert_eq!(first.call1("next-count", &[]), Val::U32(1)); + assert_eq!(first.call1("next-count", &[]), Val::U32(2)); + assert_eq!(second.call1("next-count", &[]), Val::U32(1)); + + drop(first); + assert_eq!(component().call1("next-count", &[]), Val::U32(1)); +} #[cfg(not(feature = "component-model-async"))] #[test] @@ -26,26 +54,9 @@ fn test_sync_runtime_does_not_require_component_model_async() { #[test] fn test_hello_world() { - TestCase::new() - .wit( - r#" - package test:hello; - world hello { - export greet: func() -> string; - export add: func(a: u32, b: u32) -> u32; - } - "#, - ) - .script( - r#" - export function greet() { return "Hello, World!"; } - export function add(a, b) { return a + b; } - "#, - ) + component() .expect_call("greet", vec![], Val::String("Hello, World!".into())) .expect_call("add", vec![Val::U32(2), Val::U32(3)], Val::U32(5)) - .build() - .unwrap() .run(); } @@ -90,26 +101,7 @@ fn test_export_only_interface_is_not_importable() { #[test] fn test_numeric_types() { - TestCase::new() - .wit( - r#" - package test:types; - world types { - export add-u32: func(a: u32, b: u32) -> u32; - export add-s32: func(a: s32, b: s32) -> s32; - export add-f64: func(a: f64, b: f64) -> f64; - export negate: func(b: bool) -> bool; - } - "#, - ) - .script( - r#" - export function addU32(a, b) { return a + b; } - export function addS32(a, b) { return a + b; } - export function addF64(a, b) { return a + b; } - export function negate(b) { return !b; } - "#, - ) + component() .expect_call("add-u32", vec![Val::U32(100), Val::U32(200)], Val::U32(300)) .expect_call("add-s32", vec![Val::S32(-10), Val::S32(5)], Val::S32(-5)) .expect_call( @@ -118,8 +110,6 @@ fn test_numeric_types() { Val::Float64(4.0), ) .expect_call("negate", vec![Val::Bool(true)], Val::Bool(false)) - .build() - .unwrap() .run(); } @@ -132,39 +122,18 @@ fn test_record_type() { ]) }; - TestCase::new() - .wit( - r#" - package test:records; - world record-test { - record point { x: f64, y: f64 } - export add-points: func(a: point, b: point) -> point; - } - "#, - ) - .script("export function addPoints(a, b) { return { x: a.x + b.x, y: a.y + b.y }; }") + component() .expect_call( "add-points", vec![point(1.0, 2.0), point(3.0, 4.0)], point(4.0, 6.0), ) - .build() - .unwrap() .run(); } #[test] fn test_list_type() { - TestCase::new() - .wit( - r#" - package test:lists; - world list-test { - export sum-list: func(nums: list) -> u32; - } - "#, - ) - .script("export function sumList(nums) { return nums.reduce((a, b) => a + b, 0); }") + component() .expect_call( "sum-list", vec![Val::List(vec![ @@ -176,51 +145,12 @@ fn test_list_type() { ])], Val::U32(15), ) - .build() - .unwrap() .run(); } #[test] fn test_map_type() { - TestCase::new() - .wit( - r#" - package test:maps; - world map-test { - export scale-map: func(values: map) -> map; - export sum-map: func(values: map>) -> map; - export empty-map: func() -> map; - } - "#, - ) - .script( - r#" - export function scaleMap(values) { - if (!(values instanceof Map)) { - throw new TypeError("expected Map"); - } - - const result = new Map(); - for (const [key, value] of values) { - result.set(key.toUpperCase(), value * 2); - } - return result; - } - - export function sumMap(values) { - const result = new Map(); - for (const [key, items] of values) { - result.set(key, items.reduce((sum, item) => sum + item, 0)); - } - return result; - } - - export function emptyMap() { - return new Map(); - } - "#, - ) + component() .expect_call( "scale-map", vec![Val::Map(vec![ @@ -247,89 +177,36 @@ fn test_map_type() { ]), ) .expect_call("empty-map", vec![], Val::Map(vec![])) - .build() - .unwrap() .run(); } #[test] fn test_typed_array_list_return() { - TestCase::new() - .wit( - r#" - package test:typed-array-list; - world typed-array-list { - export bytes: func() -> list; - export empty: func() -> list; - } - "#, - ) - .script( - r#" - export function bytes() { return new Uint8Array([0, 1, 127, 255]); } - export function empty() { return new Uint8Array(); } - "#, - ) + component() .expect_call( "bytes", vec![], Val::List(vec![Val::U8(0), Val::U8(1), Val::U8(127), Val::U8(255)]), ) .expect_call("empty", vec![], Val::List(vec![])) - .build() - .unwrap() .run(); } #[test] fn test_option_type() { - TestCase::new() - .wit( - r#" - package test:options; - world option-test { - export maybe-double: func(n: option) -> option; - } - "#, - ) - .script( - r#" - export function maybeDouble(n) { - if (n === null || n === undefined) { return null; } - return n * 2; - } - "#, - ) + component() .expect_call( "maybe-double", vec![Val::Option(Some(Box::new(Val::U32(5))))], Val::Option(Some(Box::new(Val::U32(10)))), ) .expect_call("maybe-double", vec![Val::Option(None)], Val::Option(None)) - .build() - .unwrap() .run(); } #[test] fn test_result_type() { - TestCase::new() - .wit( - r#" - package test:results; - world result-test { - export safe-div: func(a: u32, b: u32) -> result; - } - "#, - ) - .script( - r#" - export function safeDiv(a, b) { - if (b === 0) { throw "division by zero"; } - return Math.floor(a / b); - } - "#, - ) + component() .expect_call( "safe-div", vec![Val::U32(10), Val::U32(2)], @@ -340,8 +217,6 @@ fn test_result_type() { vec![Val::U32(10), Val::U32(0)], Val::Result(Err(Some(Box::new(Val::String("division by zero".into()))))), ) - .build() - .unwrap() .run(); } @@ -377,30 +252,7 @@ fn test_stub_wasi() { #[test] fn test_all_integer_types() { - TestCase::new() - .wit( - r#" - package test:integers; - world integers { - export add-u8: func(a: u8, b: u8) -> u8; - export add-s8: func(a: s8, b: s8) -> s8; - export add-u16: func(a: u16, b: u16) -> u16; - export add-s16: func(a: s16, b: s16) -> s16; - export add-u64: func(a: u64, b: u64) -> u64; - export add-s64: func(a: s64, b: s64) -> s64; - } - "#, - ) - .script( - r#" - export function addU8(a, b) { return a + b; } - export function addS8(a, b) { return a + b; } - export function addU16(a, b) { return a + b; } - export function addS16(a, b) { return a + b; } - export function addU64(a, b) { return a + b; } - export function addS64(a, b) { return a + b; } - "#, - ) + component() .expect_call("add-u8", vec![Val::U8(200), Val::U8(55)], Val::U8(255)) .expect_call("add-s8", vec![Val::S8(-100), Val::S8(50)], Val::S8(-50)) .expect_call( @@ -423,24 +275,12 @@ fn test_all_integer_types() { vec![Val::S64(-1_000_000_000), Val::S64(500_000_000)], Val::S64(-500_000_000), ) - .build() - .unwrap() .run(); } #[test] fn test_float_types() { - TestCase::new() - .wit( - r#" - package test:floats; - world floats { - export add-f32: func(a: f32, b: f32) -> f32; - export add-f64: func(a: f64, b: f64) -> f64; - } - "#, - ) - .script("export function addF32(a, b) { return a + b; }\nexport function addF64(a, b) { return a + b; }") + component() .expect_call( "add-f32", vec![Val::Float32(1.5), Val::Float32(2.5)], @@ -451,31 +291,12 @@ fn test_float_types() { vec![Val::Float64(1.5), Val::Float64(2.5)], Val::Float64(4.0), ) - .build() - .unwrap() .run(); } #[test] fn test_string_operations() { - TestCase::new() - .wit( - r#" - package test:strings; - world strings { - export take-string: func(s: string) -> u32; - export return-string: func() -> string; - export concat-strings: func(a: string, b: string) -> string; - } - "#, - ) - .script( - r#" - export function takeString(s) { return s.length; } - export function returnString() { return "hello from js"; } - export function concatStrings(a, b) { return a + b; } - "#, - ) + component() .expect_call( "take-string", vec![Val::String("hello".into())], @@ -487,33 +308,14 @@ fn test_string_operations() { vec![Val::String("foo".into()), Val::String("bar".into())], Val::String("foobar".into()), ) - .build() - .unwrap() .run(); } #[test] fn test_char_type() { - TestCase::new() - .wit( - r#" - package test:chars; - world chars { - export take-char: func(c: char) -> u32; - export return-char: func() -> char; - } - "#, - ) - .script( - r#" - export function takeChar(c) { return c.codePointAt(0); } - export function returnChar() { return "A"; } - "#, - ) + component() .expect_call("take-char", vec![Val::Char('A')], Val::U32(65)) .expect_call("return-char", vec![], Val::Char('A')) - .build() - .unwrap() .run(); } @@ -582,28 +384,7 @@ fn test_result_throw_error_compatibility() { #[test] fn test_enum_type() { // Enums are represented as their case-name strings in JS - TestCase::new() - .wit( - r#" - package test:enums; - world enums { - enum color { red, green, blue } - export identify-color: func(c: color) -> string; - export favorite-color: func() -> color; - } - "#, - ) - .script( - r#" - export function identifyColor(c) { - if (c === "red") return "is red"; - if (c === "green") return "is green"; - if (c === "blue") return "is blue"; - return "unknown"; - } - export function favoriteColor() { return "green"; } - "#, - ) + component() .expect_call( "identify-color", vec![Val::Enum("red".into())], @@ -615,35 +396,13 @@ fn test_enum_type() { Val::String("is blue".into()), ) .expect_call("favorite-color", vec![], Val::Enum("green".into())) - .build() - .unwrap() .run(); } #[test] fn test_variant_type() { // Variants are { tag: case-name, val } objects in JS - TestCase::new() - .wit( - r#" - package test:variants; - world variants { - variant shape { circle(f64), none } - export describe-shape: func(s: shape) -> string; - export make-circle: func(r: f64) -> shape; - } - "#, - ) - .script( - r#" - export function describeShape(s) { - if (s.tag === "circle") return "circle with radius " + s.val; - if (s.tag === "none") return "no shape"; - return "unknown"; - } - export function makeCircle(r) { return { tag: "circle", val: r }; } - "#, - ) + component() .expect_call( "describe-shape", vec![Val::Variant( @@ -662,28 +421,13 @@ fn test_variant_type() { vec![Val::Float64(2.0)], Val::Variant("circle".into(), Some(Box::new(Val::Float64(2.0)))), ) - .build() - .unwrap() .run(); } #[test] fn test_flag_type() { // Flags are represented as { name: boolean } objects in JS - TestCase::new() - .wit( - r#" - package test:flagtest; - world flag-test { - flags permissions { read, write, execute } - export check-read: func(p: permissions) -> bool; - export read-write: func() -> permissions; - } - "#, - ) - .script( - "export function checkRead(p) { return p.read === true; }\nexport function readWrite() { return { read: true, write: true }; }", - ) + component() .expect_call( "check-read", vec![Val::Flags(vec!["read".into(), "write".into()])], @@ -699,30 +443,17 @@ fn test_flag_type() { vec![], Val::Flags(vec!["read".into(), "write".into()]), ) - .build() - .unwrap() .run(); } #[test] fn test_tuple_return() { - TestCase::new() - .wit( - r#" - package test:tuples; - world tuples { - export swap: func(a: u32, b: u32) -> tuple; - } - "#, - ) - .script("export function swap(a, b) { return [b, a]; }") + component() .expect_call( "swap", vec![Val::U32(1), Val::U32(2)], Val::Tuple(vec![Val::U32(2), Val::U32(1)]), ) - .build() - .unwrap() .run(); } @@ -730,48 +461,17 @@ fn test_tuple_return() { fn test_many_arguments() { let params: Vec = (1..=10).map(Val::U32).collect(); - TestCase::new() - .wit(r#" - package test:manyargs; - world many-args { - export sum-ten: func(a1: u32, a2: u32, a3: u32, a4: u32, a5: u32, a6: u32, a7: u32, a8: u32, a9: u32, a10: u32) -> u32; - } - "#) - .script(r#" - export function sumTen(a1, a2, a3, a4, a5, a6, a7, a8, a9, a10) { - return a1 + a2 + a3 + a4 + a5 + a6 + a7 + a8 + a9 + a10; - } - "#) + component() .expect_call("sum-ten", params, Val::U32(55)) - .build().unwrap() .run(); } #[test] fn test_no_arg_functions() { - TestCase::new() - .wit( - r#" - package test:noargs; - world noargs { - export get-answer: func() -> u32; - export get-message: func() -> string; - export get-flag: func() -> bool; - } - "#, - ) - .script( - r#" - export function getAnswer() { return 42; } - export function getMessage() { return "hello"; } - export function getFlag() { return true; } - "#, - ) + component() .expect_call("get-answer", vec![], Val::U32(42)) .expect_call("get-message", vec![], Val::String("hello".into())) .expect_call("get-flag", vec![], Val::Bool(true)) - .build() - .unwrap() .run(); } @@ -790,21 +490,8 @@ fn test_nested_lists() { Val::U32(5), ]); - TestCase::new() - .wit( - r#" - package test:nested; - world nested-lists { - export flatten: func(nested: list>) -> list; - } - "#, - ) - .script( - "export function flatten(nested) { return nested.reduce((acc, arr) => acc.concat(arr), []); }", - ) + component() .expect_call("flatten", vec![nested], expected) - .build() - .unwrap() .run(); } @@ -821,43 +508,23 @@ fn test_complex_record() { ("active".into(), Val::Bool(true)), ]); - TestCase::new() - .wit(r#" - package test:complex; - world complex-record { - record person { name: string, age: u32, active: bool } - export greet-person: func(p: person) -> string; - export make-person: func(name: string, age: u32) -> person; - } - "#) - .script(r#" - export function greetPerson(p) { return "Hello " + p.name + ", age " + p.age + ", active: " + p.active; } - export function makePerson(name, age) { return { name: name, age: age, active: true }; } - "#) - .expect_call("greet-person", vec![alice], Val::String("Hello Alice, age 30, active: true".into())) - .expect_call("make-person", vec![Val::String("Bob".into()), Val::U32(25)], bob) - .build().unwrap() + component() + .expect_call( + "greet-person", + vec![alice], + Val::String("Hello Alice, age 30, active: true".into()), + ) + .expect_call( + "make-person", + vec![Val::String("Bob".into()), Val::U32(25)], + bob, + ) .run(); } #[test] fn test_list_of_strings() { - TestCase::new() - .wit( - r#" - package test:stringlists; - world string-lists { - export join-strings: func(parts: list, sep: string) -> string; - export count-strings: func(parts: list) -> u32; - } - "#, - ) - .script( - r#" - export function joinStrings(parts, sep) { return parts.join(sep); } - export function countStrings(parts) { return parts.length; } - "#, - ) + component() .expect_call( "join-strings", vec![ @@ -879,8 +546,6 @@ fn test_list_of_strings() { ])], Val::U32(3), ) - .build() - .unwrap() .run(); } @@ -954,18 +619,7 @@ fn test_naming_conventions() { #[test] fn test_repeated_calls() { - let mut inst = TestCase::new() - .wit( - r#" - package test:repeated; - world repeated { - export hello: func() -> string; - } - "#, - ) - .script(r#"export function hello() { return "hello"; }"#) - .build() - .unwrap(); + let mut inst = component(); for _ in 0..5 { assert_eq!(inst.call1("hello", &[]), Val::String("hello".into())); @@ -983,30 +637,7 @@ fn test_deeply_nested_lists() { Val::List(vec![Val::List(vec![Val::U32(4), Val::U32(5), Val::U32(6)])]), ]); - TestCase::new() - .wit( - r#" - package test:deep-nesting; - world deep-nesting { - export deep-flatten: func(nested: list>>) -> list; - } - "#, - ) - .script( - r#" - export function deepFlatten(nested) { - let result = []; - for (const mid of nested) { - for (const inner of mid) { - for (const v of inner) { - result.push(v); - } - } - } - return result; - } - "#, - ) + component() .expect_call( "deep-flatten", vec![input], @@ -1019,8 +650,6 @@ fn test_deeply_nested_lists() { Val::U32(6), ]), ) - .build() - .unwrap() .run(); }