unlit_wgpu_test_util/test_catalog.rs
1//! The catalog that lets one test body run under `libtest-mimic` natively and
2//! in a browser through wasm.
3//!
4//! [`gpu_tests!`](crate::gpu_tests) collects a file's tests into the catalog
5//! the harness runs, and [`gpu_test_main!`](crate::gpu_test_main) supplies the
6//! `main` that drives it. One source file serves both runners, because a body
7//! is an ordinary `async fn` and both runners reach it through the same
8//! `all_tests()`.
9//!
10//! A body is `async` even when it awaits nothing in particular: the device it
11//! asks [`Ctx::headless`](crate::Ctx::headless) for comes from wgpu's
12//! asynchronous adapter and device requests, which a browser cannot satisfy
13//! without yielding.
14
15use core::future::Future;
16use core::pin::Pin;
17
18/// A test's future, boxed so bodies of different types share one catalog.
19pub type TestBody = Pin<Box<dyn Future<Output = ()>>>;
20
21/// Builds one test's future. Called once per run.
22pub type TestFn = fn() -> TestBody;
23
24/// One test the harness knows how to run.
25#[derive(Clone, Copy)]
26pub struct TestEntry {
27 /// The module the test was declared in, as `module_path!` reports it.
28 ///
29 /// Only for diagnostics: the two runners name a test by [`Self::name`],
30 /// because a browser is handed one binary that reuses several test files
31 /// and would otherwise qualify the same test differently.
32 pub module: &'static str,
33 /// The test's name, which is unique across the whole suite.
34 pub name: &'static str,
35 /// Builds the future the harness drives.
36 pub body: TestFn,
37 /// The message a panic is expected to carry, for a `#[should_panic]` test.
38 ///
39 /// Both runners compare a caught panic against this. The browser needs it
40 /// before the test runs: a panic aborts a wasm instance instead of
41 /// unwinding, so the panic hook has to decide the verdict itself.
42 pub expected_panic: Option<&'static str>,
43 /// Whether only the host can run the test, never a browser.
44 ///
45 /// The wasm suite drives the host build of a test binary but runs each
46 /// trial in a browser, and a browser has neither a filesystem to read a
47 /// baseline from nor a device to play a mix through. Such a test is
48 /// compiled out of the wasm build and the proxy skips it, so both sides
49 /// list the same trials.
50 pub host_only: bool,
51}
52
53/// Declares a file's GPU tests, and the catalog the harness runs them from.
54///
55/// The bodies are ordinary `async fn`s declared in the file; this macro lists
56/// them so the harness can reach them by name. A name that has no body, or one
57/// that is not an `async fn`, is a compile error, so the list cannot drift out
58/// of step with the file.
59///
60/// A test that must panic carries `#[should_panic(expected = "...")]` in the
61/// list rather than on its body, because the attribute is what the runner
62/// compares a caught panic against and the body is not itself a test item.
63///
64/// A test only the host can run carries `#[host_only]` in the list instead of
65/// a target `cfg` around a second invocation of this macro, so one list stays
66/// the whole truth about what the file declares.
67///
68/// # Example
69///
70/// ```rust
71/// use unlit_wgpu_test_util::gpu_tests;
72///
73/// async fn draws_a_triangle() {
74/// assert_eq!(1 + 1, 2);
75/// }
76///
77/// async fn rejects_an_empty_mesh() {
78/// panic!("rejected: a mesh needs at least one vertex");
79/// }
80///
81/// gpu_tests! {
82/// draws_a_triangle,
83/// #[should_panic(expected = "rejected:")]
84/// rejects_an_empty_mesh,
85/// }
86///
87/// assert_eq!(all_tests().len(), 2);
88/// assert_eq!(all_tests()[1].expected_panic, Some("rejected:"));
89/// ```
90#[macro_export]
91macro_rules! gpu_tests {
92 // The terminator is matched before the test arms, because `@acc [...]` with
93 // nothing left to consume is more specific than either of them.
94 (@acc [$($acc:tt)*]) => {
95 /// Every test this file declares.
96 pub fn all_tests() -> ::std::vec::Vec<$crate::TestEntry> {
97 ::std::vec![$($acc)*]
98 }
99 };
100
101 (@acc [$($acc:tt)*]
102 #[host_only]
103 $name:ident,
104 $($rest:tt)*) => {
105 $crate::gpu_tests!(@acc
106 [$($acc)*
107 #[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
108 $crate::TestEntry {
109 module: module_path!(),
110 name: stringify!($name),
111 body: (|| ::std::boxed::Box::pin($name())) as $crate::TestFn,
112 expected_panic: ::core::option::Option::None,
113 host_only: true,
114 },]
115 $($rest)*);
116 };
117
118 (@acc [$($acc:tt)*]
119 #[should_panic(expected = $msg:literal)]
120 $name:ident,
121 $($rest:tt)*) => {
122 $crate::gpu_tests!(@acc
123 [$($acc)* $crate::TestEntry {
124 module: module_path!(),
125 name: stringify!($name),
126 body: (|| ::std::boxed::Box::pin($name())) as $crate::TestFn,
127 expected_panic: ::core::option::Option::Some($msg),
128 host_only: false,
129 },]
130 $($rest)*);
131 };
132
133 (@acc [$($acc:tt)*] $name:ident, $($rest:tt)*) => {
134 $crate::gpu_tests!(@acc
135 [$($acc)* $crate::TestEntry {
136 module: module_path!(),
137 name: stringify!($name),
138 body: (|| ::std::boxed::Box::pin($name())) as $crate::TestFn,
139 expected_panic: ::core::option::Option::None,
140 host_only: false,
141 },]
142 $($rest)*);
143 };
144
145 // The catch-all entry arm has to come last: it matches anything, including
146 // the `@acc` invocations the arms above make, which would recurse forever.
147 ($($rest:tt)*) => { $crate::gpu_tests!(@acc [] $($rest)*); };
148}
149
150/// Provides the entry point a test binary drives its catalog through.
151///
152/// `$tests` is an expression evaluating to `Vec<TestEntry>`. The same file
153/// therefore serves both runners: natively this is the test binary's `main`,
154/// and in a wasm build it is the export the page calls.
155///
156/// A native run with [`WASM_TEST_ENV`](crate::WASM_TEST_ENV) set becomes the
157/// browser's proxy rather than a runner, so a test binary compiled for the host
158/// still lists and reports every test while the bodies run in a page.
159///
160/// The caller needs `wasm-bindgen` in scope, because the attribute below
161/// expands to paths naming that crate. It is a wasm-only dependency of the
162/// test crates, so nothing pays for it on a platform that has no wasm.
163#[macro_export]
164macro_rules! gpu_test_main {
165 ($tests:expr) => {
166 /// Run the registered test named `name`, as the test page asks.
167 ///
168 /// This is the wasm build's entry point: a browser has no test binary
169 /// to start, so the page calls this instead and reads the verdict back
170 /// out of `sessionStorage`.
171 #[cfg(target_arch = "wasm32")]
172 #[wasm_bindgen::prelude::wasm_bindgen]
173 pub fn run_test(name: String) {
174 $crate::browser::run($tests, name);
175 }
176
177 /// Publish the browser's device for the runner to print, as
178 /// `cargo xtask test-wasm` asks it to before running anything.
179 #[cfg(target_arch = "wasm32")]
180 #[wasm_bindgen::prelude::wasm_bindgen]
181 pub fn report_gpu_info() {
182 $crate::browser::report_gpu_info();
183 }
184
185 // A test target is a binary on every target, so wasm needs a `main`
186 // too. The catalog is reached through the export above instead.
187 #[cfg(target_arch = "wasm32")]
188 fn main() {
189 let _ = $tests;
190 }
191
192 #[cfg(not(target_arch = "wasm32"))]
193 fn main() {
194 // Set by `cargo xtask test-wasm` for the host build, which then
195 // drives the browser instead of running the tests in-process. The
196 // module name is a compile-time constant rather than part of that
197 // variable, since each test binary loads its own module and one
198 // value shared by the whole `cargo nextest run` could not say which.
199 if ::std::env::var_os($crate::WASM_TEST_ENV).is_some() {
200 $crate::run_wasm($tests, env!("CARGO_CRATE_NAME"));
201 } else {
202 $crate::run($tests);
203 }
204 }
205 };
206}