1use super::common::{ToolCommand, get_cargo_target_dir, run_tool_command, validate_project_root};
4use crate::{BenchError, BuildProfile};
5use serde::Serialize;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9const WASM_TARGET: &str = "wasm32-unknown-unknown";
10const WEB_GLUE_NAME: &str = "mobench_web";
11const INDEX_HTML: &str = include_str!("../../templates/web/index.html");
12const RUNNER_JS: &str = include_str!("../../templates/web/runner.js");
13const WORKER_JS: &str = include_str!("../../templates/web/worker.js");
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct WebBuildConfig {
18 pub profile: BuildProfile,
20}
21
22impl Default for WebBuildConfig {
23 fn default() -> Self {
24 Self {
25 profile: BuildProfile::Debug,
26 }
27 }
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct WebBuildResult {
33 pub bundle_dir: PathBuf,
35 pub index_html: PathBuf,
37 pub runner_js: PathBuf,
39 pub bindgen_js: PathBuf,
41 pub wasm: PathBuf,
43 pub manifest: PathBuf,
45}
46
47pub struct WebBuilder {
49 project_root: PathBuf,
50 crate_name: String,
51 library_name: String,
52 crate_dir: Option<PathBuf>,
53 output_dir: PathBuf,
54 wasm_bindgen: PathBuf,
55 verbose: bool,
56 dry_run: bool,
57}
58
59impl WebBuilder {
60 pub fn new(project_root: impl Into<PathBuf>, crate_name: impl Into<String>) -> Self {
62 let project_root = project_root.into();
63 let crate_name = crate_name.into();
64 Self {
65 output_dir: project_root.join("target/mobench"),
66 library_name: crate_name.replace('-', "_"),
67 project_root,
68 crate_name,
69 crate_dir: None,
70 wasm_bindgen: PathBuf::from("wasm-bindgen"),
71 verbose: false,
72 dry_run: false,
73 }
74 }
75
76 pub fn library_name(mut self, library_name: impl Into<String>) -> Self {
78 self.library_name = library_name.into();
79 self
80 }
81
82 pub fn crate_dir(mut self, crate_dir: impl Into<PathBuf>) -> Self {
84 self.crate_dir = Some(crate_dir.into());
85 self
86 }
87
88 pub fn output_dir(mut self, output_dir: impl Into<PathBuf>) -> Self {
90 self.output_dir = output_dir.into();
91 self
92 }
93
94 pub fn wasm_bindgen(mut self, wasm_bindgen: impl Into<PathBuf>) -> Self {
96 self.wasm_bindgen = wasm_bindgen.into();
97 self
98 }
99
100 pub fn verbose(mut self, verbose: bool) -> Self {
102 self.verbose = verbose;
103 self
104 }
105
106 pub fn dry_run(mut self, dry_run: bool) -> Self {
108 self.dry_run = dry_run;
109 self
110 }
111
112 pub fn build(&self, config: &WebBuildConfig) -> Result<WebBuildResult, BenchError> {
114 validate_project_root(&self.project_root, &self.crate_name)?;
115 let crate_dir = self.resolve_crate_dir()?;
116 let bundle_dir = self.output_dir.join("web");
117 let worker_js = bundle_dir.join("worker.js");
118 let result = WebBuildResult {
119 index_html: bundle_dir.join("index.html"),
120 runner_js: bundle_dir.join("runner.js"),
121 bindgen_js: bundle_dir.join(format!("{WEB_GLUE_NAME}.js")),
122 wasm: bundle_dir.join(format!("{WEB_GLUE_NAME}_bg.wasm")),
123 manifest: bundle_dir.join("mobench-web.json"),
124 bundle_dir,
125 };
126
127 let profile = config.profile.as_str();
128 let target_dir = get_cargo_target_dir(&crate_dir)?;
129 let input_wasm = target_dir
130 .join(WASM_TARGET)
131 .join(profile)
132 .join(format!("{}.wasm", self.library_name));
133
134 if self.verbose || self.dry_run {
135 println!(
136 "{} cargo build --manifest-path {} --lib --target {WASM_TARGET}{}",
137 if self.dry_run { "[dry-run]" } else { "[web]" },
138 crate_dir.join("Cargo.toml").display(),
139 if config.profile == BuildProfile::Release {
140 " --release"
141 } else {
142 ""
143 }
144 );
145 println!(
146 "{} {} {} --target web --out-dir {} --out-name {WEB_GLUE_NAME}",
147 if self.dry_run { "[dry-run]" } else { "[web]" },
148 self.wasm_bindgen.display(),
149 input_wasm.display(),
150 result.bundle_dir.display()
151 );
152 }
153 if self.dry_run {
154 return Ok(result);
155 }
156
157 let mut cargo = ToolCommand::path_search("cargo");
158 cargo
159 .arg("build")
160 .arg("--manifest-path")
161 .arg(crate_dir.join("Cargo.toml"))
162 .arg("--lib")
163 .arg("--target")
164 .arg(WASM_TARGET);
165 if config.profile == BuildProfile::Release {
166 cargo.arg("--release");
167 }
168 run_tool_command(cargo, "web benchmark Cargo build")?;
169 if !input_wasm.is_file() {
170 return Err(BenchError::Build(format!(
171 "WebAssembly artifact was not produced at {}.\n\n\
172 Ensure the benchmark crate has:\n\
173 [lib]\n\
174 crate-type = [\"lib\", \"cdylib\"]",
175 input_wasm.display()
176 )));
177 }
178
179 fs::create_dir_all(&result.bundle_dir).map_err(|error| {
180 BenchError::Build(format!(
181 "Failed to create web bundle directory {}: {error}",
182 result.bundle_dir.display()
183 ))
184 })?;
185 let mut bindgen = ToolCommand::explicit(&self.wasm_bindgen)?;
186 bindgen
187 .arg(&input_wasm)
188 .arg("--target")
189 .arg("web")
190 .arg("--out-dir")
191 .arg(&result.bundle_dir)
192 .arg("--out-name")
193 .arg(WEB_GLUE_NAME)
194 .arg("--no-typescript");
195 run_tool_command(bindgen, "wasm-bindgen web glue generation")?;
196
197 write_bundle_file(&result.index_html, INDEX_HTML)?;
198 write_bundle_file(&result.runner_js, RUNNER_JS)?;
199 write_bundle_file(&worker_js, WORKER_JS)?;
200 let manifest = WebBundleManifest {
201 schema: "mobench.web-bundle.v1",
202 crate_name: &self.crate_name,
203 library_name: &self.library_name,
204 profile,
205 target: WASM_TARGET,
206 entrypoint: "index.html",
207 runner: "runner.js",
208 worker: "worker.js",
209 javascript: "mobench_web.js",
210 wasm: "mobench_web_bg.wasm",
211 };
212 let manifest_json = serde_json::to_string_pretty(&manifest).map_err(|error| {
213 BenchError::Build(format!("Failed to serialize web bundle manifest: {error}"))
214 })?;
215 write_bundle_file(&result.manifest, &manifest_json)?;
216
217 for required in [
218 &result.index_html,
219 &result.runner_js,
220 &worker_js,
221 &result.bindgen_js,
222 &result.wasm,
223 &result.manifest,
224 ] {
225 if !required.is_file() {
226 return Err(BenchError::Build(format!(
227 "Web bundle is incomplete; expected {}",
228 required.display()
229 )));
230 }
231 }
232
233 Ok(result)
234 }
235
236 fn resolve_crate_dir(&self) -> Result<PathBuf, BenchError> {
237 if let Some(crate_dir) = &self.crate_dir {
238 if crate_dir.join("Cargo.toml").is_file() {
239 return Ok(crate_dir.clone());
240 }
241 return Err(BenchError::Build(format!(
242 "Benchmark crate Cargo.toml not found at {}",
243 crate_dir.join("Cargo.toml").display()
244 )));
245 }
246
247 for candidate in [
248 self.project_root.clone(),
249 self.project_root.join("bench-mobile"),
250 self.project_root.join("crates").join(&self.crate_name),
251 self.project_root.join(&self.crate_name),
252 ] {
253 if candidate.join("Cargo.toml").is_file() {
254 return Ok(candidate);
255 }
256 }
257 Err(BenchError::Build(format!(
258 "Could not locate benchmark crate '{}' under {}",
259 self.crate_name,
260 self.project_root.display()
261 )))
262 }
263}
264
265#[derive(Serialize)]
266struct WebBundleManifest<'a> {
267 schema: &'static str,
268 crate_name: &'a str,
269 library_name: &'a str,
270 profile: &'a str,
271 target: &'static str,
272 entrypoint: &'static str,
273 runner: &'static str,
274 worker: &'static str,
275 javascript: &'static str,
276 wasm: &'static str,
277}
278
279fn write_bundle_file(path: &Path, contents: &str) -> Result<(), BenchError> {
280 fs::write(path, contents)
281 .map_err(|error| BenchError::Build(format!("Failed to write {}: {error}", path.display())))
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use std::fs;
288
289 #[test]
290 fn dry_run_returns_deterministic_bundle_paths() {
291 let temp = tempfile::tempdir().expect("tempdir");
292 fs::write(
293 temp.path().join("Cargo.toml"),
294 "[package]\nname = \"demo-bench\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
295 )
296 .expect("manifest");
297 fs::write(temp.path().join("lib.rs"), "").expect("library");
298 let output = temp.path().join("out");
299
300 let result = WebBuilder::new(temp.path(), "demo-bench")
301 .output_dir(&output)
302 .dry_run(true)
303 .build(&WebBuildConfig {
304 profile: BuildProfile::Release,
305 })
306 .expect("dry-run web build");
307
308 assert_eq!(result.bundle_dir, output.join("web"));
309 assert_eq!(result.index_html, output.join("web/index.html"));
310 assert_eq!(result.wasm, output.join("web/mobench_web_bg.wasm"));
311 assert!(!result.bundle_dir.exists());
312 }
313
314 #[test]
315 fn templates_expose_worker_backed_window_contract() {
316 assert!(INDEX_HTML.contains("runner.js"));
317 assert!(RUNNER_JS.contains("window.mobench"));
318 assert!(RUNNER_JS.contains("new Worker"));
319 assert!(!RUNNER_JS.contains("runBenchmarkJson("));
320 assert!(WORKER_JS.contains("runBenchmarkJson"));
321 assert!(WORKER_JS.contains("JSON.parse"));
322 }
323}