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::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
34/// The class-shaped `$Error`'s struct field holding its object-fields payload,
35/// whose `NAME_SLOT` holds `name` — see `runtime/prelude/error.rs`.
36const 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
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        |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                    // A genuine trap (not a thrown `Error`) propagates uncaught.
174                    Err(err) => Err(err),
175                }
176            })
177        },
178    )?;
179
180    Ok(())
181}
182
183/// Pull the `$Error` payload out of the store's pending exception — the throw
184/// the closure just raised.
185fn 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
195/// Read the caught error's `name` from the object-fields payload.
196fn 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    /// Compile `source` with `submilli:test` importable, run `main`, and return
218    /// the dispatch result alongside the recorded segment labels.
219    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        // A failed `assert` throws a catchable Error, so `expectException`
282        // reports it as the thrown error.
283        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}