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::error::NAME_SLOT;
29use crate::runtime::prelude::iterator::{as_struct, void_closure_type};
30use crate::{PackageDeclaration, Param, Span, Type, ValueKind, ValueSymbol};
31
32pub const MODULE_NAME: &str = "submilli:test";
33
34const PAYLOAD_FIELD: usize = 2;
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 |caller, params, _results| {
113 let description = read_string_arg(
114 &mut *caller,
115 abi_arg(params, 0)?,
116 "test.label (description)",
117 )?;
118 caller
119 .data()
120 .test_labels
121 .try_borrow_mut()
122 .map_err(|_| invariant_trap("test: labels already borrowed"))?
123 .push(description);
124 Ok(())
125 },
126 )?;
127
128 let (_, closure_struct) = void_closure_type(&engine, &intr)?;
129 let closure_param = ValType::Ref(RefType::new(
130 false,
131 HeapType::ConcreteStruct(closure_struct),
132 ));
133 let error_result = ValType::Ref(RefType::new(
134 false,
135 HeapType::ConcreteStruct(intr.error.clone()),
136 ));
137 register_host_fn_async(
138 linker,
139 MODULE_NAME,
140 crate::mangle::package_symbol(MODULE_NAME, "expectException"),
141 FuncType::new(&engine, [closure_param, string], [error_result]),
142 |caller, params, results| {
143 Box::pin(async move {
144 let expected = read_string_arg(
145 &mut *caller,
146 abi_arg(params, 1)?,
147 "expectException (errorType)",
148 )?;
149 let closure = closure::read(caller, abi_arg(params, 0)?, "expectException")?;
150 match closure.call_void_args(caller, &[]).await {
151 Ok(()) => {
152 let detail = if expected.is_empty() {
153 String::new()
154 } else {
155 format!(" a {expected}")
156 };
157 Err(wasmtime::Error::msg(format!(
158 "expectException: expected the closure to throw{detail}, but it returned normally"
159 )))
160 }
161 Err(err) if err.is::<wasmtime::ThrownException>() => {
162 let error = take_thrown_error(caller)?;
163 let actual = error_name(caller, &error)?;
164 if expected.is_empty() || actual == expected {
165 *abi_result(results, 0)? = error;
166 Ok(())
167 } else {
168 Err(wasmtime::Error::msg(format!(
169 "expectException: expected a {expected} error, but caught {actual}"
170 )))
171 }
172 }
173 Err(err) => Err(err),
175 }
176 })
177 },
178 )?;
179
180 Ok(())
181}
182
183fn take_thrown_error(caller: &mut Caller<'_, StoreData>) -> wasmtime::Result<Val> {
186 let exn = caller
187 .as_context_mut()
188 .take_pending_exception()
189 .ok_or_else(|| {
190 invariant_trap("expectException: throw completed without a pending exception")
191 })?;
192 exn.field(&mut *caller, 0)
193}
194
195fn error_name(caller: &mut Caller<'_, StoreData>, error: &Val) -> wasmtime::Result<String> {
197 let st = as_struct(caller, error, "expectException (caught error)")?;
198 let fields = match st.field(&mut *caller, PAYLOAD_FIELD)? {
199 Val::AnyRef(Some(any)) => any.unwrap_array(&mut *caller)?,
200 other => {
201 return Err(wasmtime::Error::msg(format!(
202 "expectException: malformed error payload {other:?}"
203 )));
204 }
205 };
206 let name = fields.get(&mut *caller, NAME_SLOT)?;
207 read_string_arg(caller, &name, "expectException (error name)")
208}
209
210#[cfg(test)]
211mod tests {
212 use crate::runtime::{
213 RuntimeConfig, StoreData, Vfs, dispatch_main_async, install_runtime_async,
214 };
215 use crate::{Severity, compile_script};
216
217 async fn run(source: &str) -> (wasmtime::Result<Option<String>>, Vec<String>) {
220 let compiled = compile_script(
221 source,
222 "test.subm",
223 crate::FileId(0),
224 &[&super::package_declaration()],
225 &[],
226 )
227 .expect("compile clean");
228 let cfg = RuntimeConfig::default();
229 let engine = cfg.engine().expect("engine");
230 let mut store = cfg
231 .store_async(
232 &engine,
233 StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir")),
234 )
235 .expect("store");
236 let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
237 let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
238 install_runtime_async(&mut linker, &mut store)
239 .await
240 .expect("install runtime");
241 super::install(&mut linker).expect("install test host fns");
242 let inst = linker
243 .instantiate_async(&mut store, &module)
244 .await
245 .expect("instantiate");
246 let result = dispatch_main_async(&mut store, &inst).await;
247 let labels = store.data().test_labels.borrow().clone();
248 (result, labels)
249 }
250
251 #[tokio::test]
252 async fn label_records_segments_in_order() {
253 let (result, labels) = run(r#"
254 import { label } from "submilli:test";
255 function main(): void {
256 label("first segment");
257 label("second segment");
258 }
259 "#)
260 .await;
261 result.expect("main runs clean");
262 assert_eq!(labels, vec!["first segment", "second segment"]);
263 }
264
265 #[tokio::test]
266 async fn expect_exception_returns_caught_error() {
267 let (result, _labels) = run(r#"
268 import { expectException } from "submilli:test";
269 function main(): void {
270 const e = expectException(() => { throw new Error("boom"); });
271 assert(e.message === "boom", "returns the caught error");
272 assert(e.name === "Error", "name is readable");
273 }
274 "#)
275 .await;
276 result.expect("main runs clean");
277 }
278
279 #[tokio::test]
280 async fn expect_exception_catches_failed_assert() {
281 let (result, _labels) = run(r#"
284 import { expectException } from "submilli:test";
285 function main(): void {
286 const e = expectException(() => { assert(false, "nope"); });
287 assert(e.message === "nope", "assert message round-trips");
288 }
289 "#)
290 .await;
291 result.expect("main runs clean");
292 }
293
294 #[tokio::test]
295 async fn expect_exception_matching_name_passes() {
296 let (result, _labels) = run(r#"
297 import { expectException } from "submilli:test";
298 function main(): void {
299 expectException(() => { throw new Error("x"); }, "Error");
300 }
301 "#)
302 .await;
303 result.expect("main runs clean");
304 }
305
306 #[tokio::test]
307 async fn expect_exception_without_throw_fails() {
308 let (result, _labels) = run(r#"
309 import { expectException } from "submilli:test";
310 function main(): void {
311 expectException(() => { const _ = 1 + 1; });
312 }
313 "#)
314 .await;
315 let err = result.expect_err("a closure that doesn't throw should fail the test");
316 assert!(
317 format!("{err}").contains("returned normally"),
318 "unexpected error: {err}"
319 );
320 }
321
322 #[tokio::test]
323 async fn expect_exception_name_mismatch_fails() {
324 let (result, _labels) = run(r#"
325 import { expectException } from "submilli:test";
326 function main(): void {
327 expectException(() => { throw new Error("x"); }, "TypeError");
328 }
329 "#)
330 .await;
331 let err = result.expect_err("a name mismatch should fail the test");
332 let msg = format!("{err}");
333 assert!(
334 msg.contains("expected a TypeError error, but caught Error"),
335 "unexpected error: {msg}"
336 );
337 }
338
339 #[test]
340 fn submilli_test_not_importable_without_declaration() {
341 let source = r#"
342 import { label } from "submilli:test";
343 function main(): void {}
344 "#;
345 let diags = compile_script(source, "t.subm", crate::FileId(0), &[], &[])
346 .expect_err("submilli:test must not be importable on a plain run");
347 let not_found = diags
348 .iter()
349 .filter(|d| d.severity == Severity::Error)
350 .find(|d| d.message.contains("submilli:test") && d.message.contains("not found"))
351 .unwrap_or_else(|| {
352 panic!(
353 "expected not-found diagnostic, got: {:?}",
354 diags.iter().map(|d| &d.message).collect::<Vec<_>>()
355 )
356 });
357 assert!(
358 not_found
359 .help
360 .iter()
361 .any(|h| h.contains("submilli build test")),
362 "expected help pointing at `submilli build test`, got: {:?}",
363 not_found.help
364 );
365 }
366}