Skip to main content

submilli_engine/stdlib/
test.rs

1//! `submilli:test` — the test-authoring package test files import.
2//!
3//! Unlike every other `submilli:*` package this one is **not** in the global
4//! stdlib registry (see [`crate::stdlib::stdlib_package_declarations`]). It is
5//! only made importable by the `submilli build test` runner, which passes
6//! [`package_declaration`] into the compile and calls [`install`] itself. An
7//! ordinary `submilli run` never offers it, so importing it there fails with a
8//! `package ... not found` diagnostic.
9//!
10//! `label(description)` records a segment boundary in
11//! [`StoreData::test_labels`](crate::runtime::StoreData). `expectException`
12//! re-enters the guest to run the supplied closure and catches its thrown
13//! `Error` host-side (via the store's pending-exception slot): no throw or a
14//! `name` mismatch fails the test with an `Err` the runtime turns into a
15//! catchable guest `Error` — the same machinery a failed `assert` uses.
16//!
17//! None of these are gated (no `check_security`): they only manipulate the
18//! in-store test ledger.
19
20use 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
33/// `name` sits at object-fields payload slot 1 of the class-shaped `$Error`
34/// (`message` at slot 0) — see `runtime/prelude/error.rs`.
35const 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
97/// Register the host fns. Called by the `submilli build test` runner after the
98/// runtime install; `submilli run` deliberately never calls this.
99pub 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        /* deterministic = */ 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        /* deterministic = */ 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                    // A genuine trap (not a thrown `Error`) propagates uncaught.
176                    Err(err) => Err(err),
177                }
178            })
179        },
180    )?;
181
182    Ok(())
183}
184
185/// Pull the `$Error` payload out of the store's pending exception — the throw
186/// the closure just raised.
187fn 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
197/// Read the caught error's `name` from the object-fields payload.
198fn 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    /// Compile `source` with `submilli:test` importable, run `main`, and return
220    /// the dispatch result alongside the recorded segment labels.
221    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        // A failed `assert` throws a catchable Error, so `expectException`
284        // reports it as the thrown error.
285        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}