1use crate::runtime::host::invariant_trap;
21use crate::runtime::host::{abi_arg, abi_result};
22use wasmtime::{AsContextMut, Caller, FuncType, HeapType, Linker, RefType, Val, ValType};
23
24use crate::runtime::StoreData;
25use crate::runtime::host::{read_string_arg, register_host_fn, register_host_fn_async};
26use crate::runtime::intrinsic_types::build_intrinsic_types;
27use crate::runtime::prelude::closure;
28use crate::runtime::prelude::iterator::{as_struct, void_closure_type};
29use crate::{PackageDeclaration, Param, Span, Type, ValueKind, ValueSymbol};
30
31pub const MODULE_NAME: &str = "submilli:test";
32
33const ERROR_FIELDS: usize = 2;
36const NAME_SLOT: u32 = 1;
37
38pub fn package_declaration() -> PackageDeclaration {
39 let mut defs = PackageDeclaration::with_package(MODULE_NAME);
40 defs.values.insert(
41 "label".to_string(),
42 ValueSymbol {
43 name: "label".to_string(),
44 mangled_name: crate::mangle::package_symbol(MODULE_NAME, "label"),
45 declaration_span: Span::at(crate::FileId::TEST),
46 kind: ValueKind::Function {
47 generics: Vec::new(),
48 params: vec![Param::new("description", Type::String)],
49 ret: Type::Void,
50 type_predicate: None,
51 doc: crate::doc(
52 crate::FileId::TEST,
53 "/**\n * Start a new named test segment. Everything between this call and the next `label` (or the end of `main`) is reported under `description`; the segment passes if execution reaches the next boundary without throwing. A test file with no `label` calls is reported as a single anonymous test.\n * @param description Human-readable name for the segment.\n */",
54 ),
55 },
56 },
57 );
58 defs.values.insert(
59 "expectException".to_string(),
60 ValueSymbol {
61 name: "expectException".to_string(),
62 mangled_name: crate::mangle::package_symbol(MODULE_NAME, "expectException"),
63 declaration_span: Span::at(crate::FileId::TEST),
64 kind: ValueKind::Function {
65 generics: Vec::new(),
66 params: vec![
67 Param::new(
68 "fn",
69 Type::Function {
70 optional: 0,
71 params: Vec::new(),
72 ret: Box::new(Type::Void),
73 predicate: None,
74 has_rest: false,
75 },
76 ),
77 Param {
78 optional: false,
79 name: "errorType".to_string(),
80 ty: Type::String,
81 default: Some(crate::DefaultValue::String(String::new())),
82 rest: false,
83 },
84 ],
85 ret: Type::prelude_error_class(),
86 type_predicate: None,
87 doc: crate::doc(
88 crate::FileId::TEST,
89 "/**\n * Run `fn` and assert that it throws an `Error`, returning the caught error for inspection. Fails the test if `fn` returns without throwing. When `errorType` is given, also asserts the caught error's `name` equals it.\n * @param fn The closure expected to throw.\n * @param errorType Optional expected value of the caught error's `name`.\n */",
90 ),
91 },
92 },
93 );
94 defs
95}
96
97pub fn install(linker: &mut Linker<StoreData>) -> wasmtime::Result<()> {
100 let engine = linker.engine().clone();
101 let intr = build_intrinsic_types(&engine)?;
102 let string = ValType::Ref(RefType::new(
103 false,
104 HeapType::ConcreteStruct(intr.string.clone()),
105 ));
106
107 register_host_fn(
108 linker,
109 MODULE_NAME,
110 crate::mangle::package_symbol(MODULE_NAME, "label"),
111 FuncType::new(&engine, [string.clone()], []),
112 true,
113 |caller, params, _results| {
114 let description = read_string_arg(
115 &mut *caller,
116 abi_arg(params, 0)?,
117 "test.label (description)",
118 )?;
119 caller
120 .data()
121 .test_labels
122 .try_borrow_mut()
123 .map_err(|_| invariant_trap("test: labels already borrowed"))?
124 .push(description);
125 Ok(())
126 },
127 )?;
128
129 let (_, closure_struct) = void_closure_type(&engine, &intr)?;
130 let closure_param = ValType::Ref(RefType::new(
131 false,
132 HeapType::ConcreteStruct(closure_struct),
133 ));
134 let error_result = ValType::Ref(RefType::new(
135 false,
136 HeapType::ConcreteStruct(intr.error.clone()),
137 ));
138 register_host_fn_async(
139 linker,
140 MODULE_NAME,
141 crate::mangle::package_symbol(MODULE_NAME, "expectException"),
142 FuncType::new(&engine, [closure_param, string], [error_result]),
143 true,
144 |caller, params, results| {
145 Box::pin(async move {
146 let expected = read_string_arg(
147 &mut *caller,
148 abi_arg(params, 1)?,
149 "expectException (errorType)",
150 )?;
151 let closure = closure::read(caller, abi_arg(params, 0)?, "expectException")?;
152 match closure.call_void_args(caller, &[]).await {
153 Ok(()) => {
154 let detail = if expected.is_empty() {
155 String::new()
156 } else {
157 format!(" a {expected}")
158 };
159 Err(wasmtime::Error::msg(format!(
160 "expectException: expected the closure to throw{detail}, but it returned normally"
161 )))
162 }
163 Err(err) if err.is::<wasmtime::ThrownException>() => {
164 let error = take_thrown_error(caller)?;
165 let actual = error_name(caller, &error)?;
166 if expected.is_empty() || actual == expected {
167 *abi_result(results, 0)? = error;
168 Ok(())
169 } else {
170 Err(wasmtime::Error::msg(format!(
171 "expectException: expected a {expected} error, but caught {actual}"
172 )))
173 }
174 }
175 Err(err) => Err(err),
177 }
178 })
179 },
180 )?;
181
182 Ok(())
183}
184
185fn take_thrown_error(caller: &mut Caller<'_, StoreData>) -> wasmtime::Result<Val> {
188 let exn = caller
189 .as_context_mut()
190 .take_pending_exception()
191 .ok_or_else(|| {
192 invariant_trap("expectException: throw completed without a pending exception")
193 })?;
194 exn.field(&mut *caller, 0)
195}
196
197fn error_name(caller: &mut Caller<'_, StoreData>, error: &Val) -> wasmtime::Result<String> {
199 let st = as_struct(caller, error, "expectException (caught error)")?;
200 let fields = match st.field(&mut *caller, ERROR_FIELDS)? {
201 Val::AnyRef(Some(any)) => any.unwrap_array(&mut *caller)?,
202 other => {
203 return Err(wasmtime::Error::msg(format!(
204 "expectException: malformed error payload {other:?}"
205 )));
206 }
207 };
208 let name = fields.get(&mut *caller, NAME_SLOT)?;
209 read_string_arg(caller, &name, "expectException (error name)")
210}
211
212#[cfg(test)]
213mod tests {
214 use crate::runtime::{
215 RuntimeConfig, StoreData, Vfs, dispatch_main_async, install_runtime_async,
216 };
217 use crate::{Severity, compile_script};
218
219 async fn run(source: &str) -> (wasmtime::Result<Option<String>>, Vec<String>) {
222 let compiled = compile_script(
223 source,
224 "test.subm",
225 crate::FileId(0),
226 &[&super::package_declaration()],
227 &[],
228 )
229 .expect("compile clean");
230 let cfg = RuntimeConfig::default();
231 let engine = cfg.engine().expect("engine");
232 let mut store = cfg
233 .store_async(
234 &engine,
235 StoreData::with_vfs(Vfs::tempdir().expect("tempdir")),
236 )
237 .expect("store");
238 let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
239 let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
240 install_runtime_async(&mut linker, &mut store)
241 .await
242 .expect("install runtime");
243 super::install(&mut linker).expect("install test host fns");
244 let inst = linker
245 .instantiate_async(&mut store, &module)
246 .await
247 .expect("instantiate");
248 let result = dispatch_main_async(&mut store, &inst).await;
249 let labels = store.data().test_labels.borrow().clone();
250 (result, labels)
251 }
252
253 #[tokio::test]
254 async fn label_records_segments_in_order() {
255 let (result, labels) = run(r#"
256 import { label } from "submilli:test";
257 function main(): void {
258 label("first segment");
259 label("second segment");
260 }
261 "#)
262 .await;
263 result.expect("main runs clean");
264 assert_eq!(labels, vec!["first segment", "second segment"]);
265 }
266
267 #[tokio::test]
268 async fn expect_exception_returns_caught_error() {
269 let (result, _labels) = run(r#"
270 import { expectException } from "submilli:test";
271 function main(): void {
272 const e = expectException(() => { throw new Error("boom"); });
273 assert(e.message === "boom", "returns the caught error");
274 assert(e.name === "Error", "name is readable");
275 }
276 "#)
277 .await;
278 result.expect("main runs clean");
279 }
280
281 #[tokio::test]
282 async fn expect_exception_catches_failed_assert() {
283 let (result, _labels) = run(r#"
286 import { expectException } from "submilli:test";
287 function main(): void {
288 const e = expectException(() => { assert(false, "nope"); });
289 assert(e.message === "nope", "assert message round-trips");
290 }
291 "#)
292 .await;
293 result.expect("main runs clean");
294 }
295
296 #[tokio::test]
297 async fn expect_exception_matching_name_passes() {
298 let (result, _labels) = run(r#"
299 import { expectException } from "submilli:test";
300 function main(): void {
301 expectException(() => { throw new Error("x"); }, "Error");
302 }
303 "#)
304 .await;
305 result.expect("main runs clean");
306 }
307
308 #[tokio::test]
309 async fn expect_exception_without_throw_fails() {
310 let (result, _labels) = run(r#"
311 import { expectException } from "submilli:test";
312 function main(): void {
313 expectException(() => { const _ = 1 + 1; });
314 }
315 "#)
316 .await;
317 let err = result.expect_err("a closure that doesn't throw should fail the test");
318 assert!(
319 format!("{err}").contains("returned normally"),
320 "unexpected error: {err}"
321 );
322 }
323
324 #[tokio::test]
325 async fn expect_exception_name_mismatch_fails() {
326 let (result, _labels) = run(r#"
327 import { expectException } from "submilli:test";
328 function main(): void {
329 expectException(() => { throw new Error("x"); }, "TypeError");
330 }
331 "#)
332 .await;
333 let err = result.expect_err("a name mismatch should fail the test");
334 let msg = format!("{err}");
335 assert!(
336 msg.contains("expected a TypeError error, but caught Error"),
337 "unexpected error: {msg}"
338 );
339 }
340
341 #[test]
342 fn submilli_test_not_importable_without_declaration() {
343 let source = r#"
344 import { label } from "submilli:test";
345 function main(): void {}
346 "#;
347 let diags = compile_script(source, "t.subm", crate::FileId(0), &[], &[])
348 .expect_err("submilli:test must not be importable on a plain run");
349 let not_found = diags
350 .iter()
351 .filter(|d| d.severity == Severity::Error)
352 .find(|d| d.message.contains("submilli:test") && d.message.contains("not found"))
353 .unwrap_or_else(|| {
354 panic!(
355 "expected not-found diagnostic, got: {:?}",
356 diags.iter().map(|d| &d.message).collect::<Vec<_>>()
357 )
358 });
359 assert!(
360 not_found
361 .help
362 .iter()
363 .any(|h| h.contains("submilli build test")),
364 "expected help pointing at `submilli build test`, got: {:?}",
365 not_found.help
366 );
367 }
368}