[vello_sparse_tests] Add vello_cpu scalar test coverage on WASM (#1065)

### Context

This is a test only PR - adding additional test coverage for scalar
vello_cpu tests running on the browser.

**Before this PR:**

```sh
$ wasm-pack test --firefox --headless --features=webgl
test result: ok. 85 passed; 0 failed; 165 ignored; 0 filtered out; finished in 7.49s
```

**After this PR:**

```sh
$ wasm-pack test --firefox --headless --features=webgl
test result: ok. 581 passed; 0 failed; 169 ignored; 0 filtered out; finished in 22.18s
```

This can also be seen [in
CI](https://github.com/linebender/vello/actions/runs/15842375361/job/44657293874?pr=1065#step:11:790)
of this PR.

### Changes 

- `load_image` has been moved into `utils` and has become a macro. This
is required such that WASM can embed the image assets into the compiled
WASM binary (as wasm doesn't have access to filesystem).
- Most changes are to the `vello_test` proc macro, which now generates
two additional tests, a u8 and f32 scalar wasm cpu test.

### Test plan

This whole PR is the test.
diff --git a/sparse_strips/vello_dev_macros/src/test.rs b/sparse_strips/vello_dev_macros/src/test.rs
index 79f164a..8f35d9f 100644
--- a/sparse_strips/vello_dev_macros/src/test.rs
+++ b/sparse_strips/vello_dev_macros/src/test.rs
@@ -80,6 +80,14 @@
         &format!("{}_cpu_f32_neon", input_fn_name),
         input_fn_name.span(),
     );
+    let u8_fn_name_scalar_wasm = Ident::new(
+        &format!("{}_cpu_u8_scalar_wasm", input_fn_name),
+        input_fn_name.span(),
+    );
+    let f32_fn_name_scalar_wasm: Ident = Ident::new(
+        &format!("{}_cpu_f32_scalar_wasm", input_fn_name),
+        input_fn_name.span(),
+    );
     let multithreaded_fn_name = Ident::new(
         &format!("{}_cpu_multithreaded", input_fn_name),
         input_fn_name.span(),
@@ -99,6 +107,8 @@
     let f32_fn_name_str_scalar = f32_fn_name_scalar.to_string();
     let u8_fn_name_str_neon = u8_fn_name_neon.to_string();
     let f32_fn_name_str_neon = f32_fn_name_neon.to_string();
+    let u8_fn_name_scalar_wasm_str = u8_fn_name_scalar_wasm.to_string();
+    let f32_fn_name_scalar_wasm_str = f32_fn_name_scalar_wasm.to_string();
     let multithreaded_fn_name_str = multithreaded_fn_name.to_string();
     let hybrid_fn_name_str = hybrid_fn_name.to_string();
     let webgl_fn_name_str = webgl_fn_name.to_string();
@@ -182,15 +192,30 @@
                        level: &str,
                        ignore: bool,
                        render_mode: proc_macro2::TokenStream| {
+        // Use the name to infer if the test is running in the browser.
+        let is_wasm_test = fn_name_str.contains("wasm");
+        // WASM cannot create references, so force `is_reference` to be `false` unconditionally.
+        let is_reference = if is_wasm_test { false } else { is_reference };
         let ignore_snippet = if ignore {
             ignore_snippet.clone()
         } else {
             quote! {}
         };
 
+        let (cfg_attr, test_attr) = if is_wasm_test {
+            assert_eq!(num_threads, 0, "wasm is single threaded");
+            (
+                quote! { #[cfg(target_arch = "wasm32")] },
+                quote! { #[wasm_bindgen_test::wasm_bindgen_test] },
+            )
+        } else {
+            (quote! {}, quote! { #[test] })
+        };
+
         quote! {
+            #cfg_attr
             #ignore_snippet
-            #[test]
+            #test_attr
             fn #fn_name() {
                 use crate::util::{
                     check_ref, get_ctx
@@ -232,6 +257,26 @@
         skip_cpu,
         quote! { RenderMode::OptimizeQuality },
     );
+    let u8_snippet_wasm = cpu_snippet(
+        u8_fn_name_scalar_wasm,
+        u8_fn_name_scalar_wasm_str,
+        cpu_u8_tolerance_scalar,
+        false,
+        0,
+        "fallback",
+        skip_cpu,
+        quote! { RenderMode::OptimizeSpeed },
+    );
+    let f32_snippet_wasm = cpu_snippet(
+        f32_fn_name_scalar_wasm,
+        f32_fn_name_scalar_wasm_str,
+        cpu_f32_tolerance_scalar,
+        true,
+        0,
+        "fallback",
+        skip_cpu,
+        quote! { RenderMode::OptimizeQuality },
+    );
     let multi_threaded_snippet = cpu_snippet(
         multithreaded_fn_name,
         multithreaded_fn_name_str,
@@ -274,10 +319,14 @@
 
         #neon_u8_snippet
 
+        #u8_snippet_wasm
+
         #f32_snippet
 
         #neon_f32_snippet
 
+        #f32_snippet_wasm
+
         #multi_threaded_snippet
 
         #ignore_hybrid
diff --git a/sparse_strips/vello_sparse_tests/README.md b/sparse_strips/vello_sparse_tests/README.md
index d8903dd..ca44ee9 100644
--- a/sparse_strips/vello_sparse_tests/README.md
+++ b/sparse_strips/vello_sparse_tests/README.md
@@ -44,7 +44,7 @@
 Requirements:
  - on MacOS, a minimum Clang major version of 20 is required.
 
-To run the `vello_sparse_tests` suite on WebGL headless:
+To run the `vello_sparse_tests` suite including the WebGL tests:
 
 ```sh
 wasm-pack test --headless --chrome --features webgl
diff --git a/sparse_strips/vello_sparse_tests/tests/image.rs b/sparse_strips/vello_sparse_tests/tests/image.rs
index c55ba72..8bab917 100644
--- a/sparse_strips/vello_sparse_tests/tests/image.rs
+++ b/sparse_strips/vello_sparse_tests/tests/image.rs
@@ -2,10 +2,10 @@
 // SPDX-License-Identifier: Apache-2.0 OR MIT
 
 use crate::gradient::tan_45;
+use crate::load_image;
 use crate::renderer::Renderer;
 use crate::util::crossed_line_star;
 use std::f64::consts::PI;
-use std::path::Path;
 use std::sync::Arc;
 use vello_common::kurbo::{Affine, Point, Rect};
 use vello_common::paint::Image;
@@ -13,33 +13,28 @@
 use vello_common::pixmap::Pixmap;
 use vello_dev_macros::vello_test;
 
-pub(crate) fn load_image(name: &str) -> Arc<Pixmap> {
-    let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("tests/assets/{name}.png"));
-    Arc::new(Pixmap::from_png(std::fs::File::open(path).unwrap()).unwrap())
-}
-
 fn rgb_img_10x10() -> Arc<Pixmap> {
-    load_image("rgb_image_10x10")
+    load_image!("rgb_image_10x10")
 }
 
 fn rgb_img_2x2() -> Arc<Pixmap> {
-    load_image("rgb_image_2x2")
+    load_image!("rgb_image_2x2")
 }
 
 fn rgb_img_2x3() -> Arc<Pixmap> {
-    load_image("rgb_image_2x3")
+    load_image!("rgb_image_2x3")
 }
 
 fn rgba_img_10x10() -> Arc<Pixmap> {
-    load_image("rgba_image_10x10")
+    load_image!("rgba_image_10x10")
 }
 
 fn luma_img_10x10() -> Arc<Pixmap> {
-    load_image("luma_image_10x10")
+    load_image!("luma_image_10x10")
 }
 
 fn lumaa_img_10x10() -> Arc<Pixmap> {
-    load_image("lumaa_image_10x10")
+    load_image!("lumaa_image_10x10")
 }
 
 fn repeat(ctx: &mut impl Renderer, x_extend: Extend, y_extend: Extend) {
diff --git a/sparse_strips/vello_sparse_tests/tests/mix.rs b/sparse_strips/vello_sparse_tests/tests/mix.rs
index e141a2b..9b00cc8 100644
--- a/sparse_strips/vello_sparse_tests/tests/mix.rs
+++ b/sparse_strips/vello_sparse_tests/tests/mix.rs
@@ -1,7 +1,7 @@
 // Copyright 2025 the Vello Authors
 // SPDX-License-Identifier: Apache-2.0 OR MIT
 
-use crate::image::load_image;
+use crate::load_image;
 use crate::renderer::Renderer;
 use smallvec::smallvec;
 use vello_common::color::palette::css::{BLUE, LIME, MAGENTA, RED, YELLOW};
@@ -49,7 +49,7 @@
     };
 
     let image = Image {
-        pixmap: load_image("cowboy"),
+        pixmap: load_image!("cowboy"),
         x_extend: Extend::Pad,
         y_extend: Extend::Pad,
         quality: ImageQuality::Low,
diff --git a/sparse_strips/vello_sparse_tests/tests/mod.rs b/sparse_strips/vello_sparse_tests/tests/mod.rs
index 4d9f627..6f5658e 100644
--- a/sparse_strips/vello_sparse_tests/tests/mod.rs
+++ b/sparse_strips/vello_sparse_tests/tests/mod.rs
@@ -37,4 +37,5 @@
 mod mix;
 mod opacity;
 mod renderer;
+#[macro_use]
 mod util;
diff --git a/sparse_strips/vello_sparse_tests/tests/util.rs b/sparse_strips/vello_sparse_tests/tests/util.rs
index 0d5469e..a382004 100644
--- a/sparse_strips/vello_sparse_tests/tests/util.rs
+++ b/sparse_strips/vello_sparse_tests/tests/util.rs
@@ -32,6 +32,27 @@
     PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../vello_sparse_tests/diffs")
 });
 
+/// Helper for loading png images contained within "tests/assets/**".
+#[macro_export]
+macro_rules! load_image {
+    ($name:expr) => {{
+        #[cfg(target_arch = "wasm32")]
+        {
+            let bytes = include_bytes!(concat!("../tests/assets/", $name, ".png"));
+            std::sync::Arc::new(vello_common::pixmap::Pixmap::from_png(&bytes[..]).unwrap())
+        }
+
+        #[cfg(not(target_arch = "wasm32"))]
+        {
+            let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+                .join(format!("tests/assets/{}.png", $name));
+            std::sync::Arc::new(
+                vello_common::pixmap::Pixmap::from_png(std::fs::File::open(path).unwrap()).unwrap(),
+            )
+        }
+    }};
+}
+
 pub(crate) fn get_ctx<T: Renderer>(
     width: u16,
     height: u16,