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