Skip to main content

tokmd_wasm/
lib.rs

1//! wasm-bindgen bindings for tokmd.
2//!
3//! This crate intentionally stays thin: it reuses `tokmd_core::ffi::run_json`
4//! plus the shared envelope helpers so the browser surface matches the other
5//! binding products instead of reimplementing parsing and validation.
6
7#![forbid(unsafe_code)]
8
9use js_sys::{Error as JsError, JSON};
10use wasm_bindgen::prelude::*;
11
12#[cfg(test)]
13use serde_json::Value;
14#[cfg(feature = "analysis")]
15use tokmd_core::CORE_ANALYSIS_SCHEMA_VERSION;
16use tokmd_core::error::{ResponseEnvelope, TokmdError};
17
18fn to_js_error(message: impl Into<String>) -> JsValue {
19    JsError::new(&message.into()).into()
20}
21
22#[cfg(test)]
23fn serialize_args(args: &Value) -> Result<String, String> {
24    serde_json::to_string(args).map_err(|err| format!("JSON encode error: {err}"))
25}
26
27fn extract_mode_data_json(mode: &str, args_json: &str) -> Result<String, String> {
28    validate_mode_args_json(mode, args_json).map_err(|err| err.to_string())?;
29    let result_json = tokmd_core::ffi::run_json(mode, args_json);
30    tokmd_envelope::ffi::extract_data_json(&result_json).map_err(|err| err.to_string())
31}
32
33#[cfg(test)]
34fn run_mode_value(mode: &str, args: &Value) -> Result<Value, String> {
35    let args_json = serialize_args(args)?;
36    let data_json = extract_mode_data_json(mode, &args_json)?;
37    serde_json::from_str(&data_json).map_err(|err| format!("JSON decode error: {err}"))
38}
39
40fn js_args_to_json(args: JsValue) -> Result<String, JsValue> {
41    if args.is_null() || args.is_undefined() {
42        return Ok("{}".to_string());
43    }
44
45    JSON::stringify(&args)
46        .map_err(|_| to_js_error("failed to serialize JS arguments"))?
47        .as_string()
48        .ok_or_else(|| to_js_error("failed to serialize JS arguments"))
49}
50
51fn run_mode_js(mode: &str, args: JsValue) -> Result<JsValue, JsValue> {
52    let args_json = js_args_to_json(args)?;
53    let data_json = extract_mode_data_json(mode, &args_json).map_err(to_js_error)?;
54    JSON::parse(&data_json).map_err(|_| to_js_error("failed to parse tokmd result JSON"))
55}
56
57#[cfg(feature = "analysis")]
58fn validate_analyze_args_json(args_json: &str) -> Result<(), TokmdError> {
59    let args: serde_json::Value =
60        serde_json::from_str(args_json).map_err(TokmdError::invalid_json)?;
61    let obj = args.get("analyze").unwrap_or(&args);
62
63    match obj.get("preset").and_then(serde_json::Value::as_str) {
64        Some(preset) if tokmd_core::supports_rootless_in_memory_analyze_preset(preset) => Ok(()),
65        Some(preset) => Err(TokmdError::not_implemented(format!(
66            "tokmd-wasm currently supports analyze only with preset=\"receipt\" or preset=\"estimate\" for in-memory inputs; got {preset:?}"
67        ))),
68        None => Ok(()),
69    }
70}
71
72fn validate_mode_args_json(mode: &str, args_json: &str) -> Result<(), TokmdError> {
73    #[cfg(feature = "analysis")]
74    if mode == "analyze" {
75        return validate_analyze_args_json(args_json);
76    }
77
78    let _ = (mode, args_json);
79    Ok(())
80}
81
82#[cfg(feature = "analysis")]
83fn run_analyze_js(args: JsValue) -> Result<JsValue, JsValue> {
84    let args_json = js_args_to_json(args)?;
85    validate_analyze_args_json(&args_json).map_err(|err| to_js_error(err.to_string()))?;
86    let data_json = extract_mode_data_json("analyze", &args_json).map_err(to_js_error)?;
87    JSON::parse(&data_json).map_err(|_| to_js_error("failed to parse tokmd result JSON"))
88}
89
90/// Return the tokmd package version.
91#[wasm_bindgen]
92pub fn version() -> String {
93    tokmd_core::ffi::version().to_string()
94}
95
96/// Return the current core receipt schema version for `lang`, `module`, and `export`.
97#[wasm_bindgen(js_name = schemaVersion)]
98pub fn schema_version() -> u32 {
99    tokmd_core::ffi::schema_version()
100}
101
102/// Return the current analysis receipt schema version for `runAnalyze`.
103#[cfg(feature = "analysis")]
104#[wasm_bindgen(js_name = analysisSchemaVersion)]
105pub fn analysis_schema_version() -> u32 {
106    CORE_ANALYSIS_SCHEMA_VERSION
107}
108
109/// Run a tokmd mode and return the raw JSON response envelope.
110#[wasm_bindgen(js_name = runJson)]
111pub fn run_json(mode: &str, args_json: &str) -> String {
112    if let Err(err) = validate_mode_args_json(mode, args_json) {
113        return ResponseEnvelope::error(&err).to_json();
114    }
115    tokmd_core::ffi::run_json(mode, args_json)
116}
117
118/// Run a tokmd mode with a plain JavaScript object and return the extracted data payload.
119#[wasm_bindgen(js_name = run)]
120pub fn run(mode: &str, args: JsValue) -> Result<JsValue, JsValue> {
121    run_mode_js(mode, args)
122}
123
124/// Run the `lang` workflow on in-memory inputs.
125#[wasm_bindgen(js_name = runLang)]
126pub fn run_lang(args: JsValue) -> Result<JsValue, JsValue> {
127    run_mode_js("lang", args)
128}
129
130/// Run the `module` workflow on in-memory inputs.
131#[wasm_bindgen(js_name = runModule)]
132pub fn run_module(args: JsValue) -> Result<JsValue, JsValue> {
133    run_mode_js("module", args)
134}
135
136/// Run the `export` workflow on in-memory inputs.
137#[wasm_bindgen(js_name = runExport)]
138pub fn run_export(args: JsValue) -> Result<JsValue, JsValue> {
139    run_mode_js("export", args)
140}
141
142/// Run the `analyze` workflow on in-memory inputs.
143///
144/// `tokmd-wasm` currently supports only `preset: "receipt"` and
145/// `preset: "estimate"` because the richer analysis presets still depend on
146/// filesystem-backed content scans. Omitting `preset` defaults to `receipt`,
147/// matching `tokmd-core`.
148#[cfg(feature = "analysis")]
149#[wasm_bindgen(js_name = runAnalyze)]
150pub fn run_analyze(args: JsValue) -> Result<JsValue, JsValue> {
151    run_analyze_js(args)
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use serde_json::json;
158
159    fn fixture_inputs() -> Value {
160        json!([
161            {
162                "path": "crates/app/src/lib.rs",
163                "text": "pub fn alpha() -> usize { 1 }\n"
164            },
165            {
166                "path": "src/main.rs",
167                "text": "fn main() {}\n"
168            },
169            {
170                "path": "tests/basic.py",
171                "text": "# TODO: keep smoke\nprint('ok')\n"
172            }
173        ])
174    }
175
176    #[test]
177    fn run_json_returns_valid_envelope() {
178        let result = run_json("version", "{}");
179        let envelope = tokmd_envelope::ffi::parse_envelope(&result).expect("valid JSON envelope");
180
181        assert_eq!(envelope["ok"], true);
182        assert_eq!(envelope["data"]["version"], env!("CARGO_PKG_VERSION"));
183    }
184
185    #[test]
186    fn run_mode_value_lang_supports_in_memory_inputs() {
187        let data = run_mode_value(
188            "lang",
189            &json!({
190                "inputs": fixture_inputs(),
191                "files": true
192            }),
193        )
194        .expect("lang data");
195
196        assert_eq!(data["mode"], "lang");
197        assert_eq!(data["scan"]["paths"][0], "crates/app/src/lib.rs");
198        assert_eq!(data["total"]["files"], 3);
199    }
200
201    #[test]
202    fn run_mode_value_export_preserves_logical_paths() {
203        let data = run_mode_value(
204            "export",
205            &json!({
206                "inputs": fixture_inputs()
207            }),
208        )
209        .expect("export data");
210        let paths: Vec<&str> = data["rows"]
211            .as_array()
212            .expect("rows array")
213            .iter()
214            .map(|row| row["path"].as_str().expect("row path"))
215            .collect();
216
217        assert_eq!(data["mode"], "export");
218        assert!(paths.contains(&"crates/app/src/lib.rs"));
219        assert!(paths.contains(&"tests/basic.py"));
220    }
221
222    #[cfg(feature = "analysis")]
223    #[test]
224    fn run_mode_value_analyze_estimate_returns_effort_payload() {
225        let data = run_mode_value(
226            "analyze",
227            &json!({
228                "inputs": fixture_inputs(),
229                "preset": "estimate"
230            }),
231        )
232        .expect("analysis data");
233
234        assert_eq!(data["mode"], "analysis");
235        assert_eq!(data["source"]["inputs"][1], "src/main.rs");
236        assert_eq!(data["effort"]["model"], "cocomo81-basic");
237        assert_eq!(data["effort"]["size_basis"]["total_lines"], 3);
238        assert!(
239            data["effort"]["results"]["effort_pm_p50"]
240                .as_f64()
241                .expect("effort p50")
242                > 0.0
243        );
244    }
245
246    #[cfg(feature = "analysis")]
247    #[test]
248    fn run_mode_value_analyze_receipt_returns_rootless_receipt_payload() {
249        let data = run_mode_value(
250            "analyze",
251            &json!({
252                "inputs": fixture_inputs(),
253                "preset": "receipt"
254            }),
255        )
256        .expect("analysis data");
257
258        assert_eq!(data["mode"], "analysis");
259        assert_eq!(data["source"]["inputs"][2], "tests/basic.py");
260        assert_eq!(data["derived"]["totals"]["files"], 3);
261        assert_eq!(data["effort"], Value::Null);
262        assert_eq!(data["git"], Value::Null);
263        assert!(
264            data["warnings"]
265                .as_array()
266                .expect("warnings array")
267                .iter()
268                .filter_map(Value::as_str)
269                .any(|warning| warning.contains("no host root") && warning.contains("file-backed"))
270        );
271        assert!(
272            data["warnings"]
273                .as_array()
274                .expect("warnings array")
275                .iter()
276                .filter_map(Value::as_str)
277                .any(|warning| warning.contains("no host root") && warning.contains("git"))
278        );
279    }
280
281    #[cfg(feature = "analysis")]
282    #[test]
283    fn run_mode_value_analyze_without_preset_defaults_to_receipt_payload() {
284        let data = run_mode_value(
285            "analyze",
286            &json!({
287                "inputs": fixture_inputs()
288            }),
289        )
290        .expect("analysis data");
291
292        assert_eq!(data["mode"], "analysis");
293        assert_eq!(data["source"]["inputs"][0], "crates/app/src/lib.rs");
294        assert_eq!(data["derived"]["totals"]["files"], 3);
295        assert_eq!(data["effort"], Value::Null);
296    }
297
298    #[cfg(feature = "analysis")]
299    #[test]
300    fn validate_analyze_args_accepts_rootless_receipt_and_estimate() {
301        validate_analyze_args_json(
302            r#"{
303                "inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }]
304            }"#,
305        )
306        .expect("missing preset should default to receipt");
307
308        validate_analyze_args_json(
309            r#"{
310                "inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }],
311                "analyze": { "preset": "Receipt" }
312            }"#,
313        )
314        .expect("nested mixed-case receipt should be allowed");
315
316        validate_analyze_args_json(
317            r#"{
318                "inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }],
319                "preset": "estimate"
320            }"#,
321        )
322        .expect("estimate should be allowed");
323
324        validate_analyze_args_json(
325            r#"{
326                "inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }],
327                "analyze": { "preset": "Estimate" }
328            }"#,
329        )
330        .expect("nested mixed-case estimate should be allowed");
331
332        let err = validate_analyze_args_json(
333            r#"{
334                "inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }],
335                "preset": "health"
336            }"#,
337        )
338        .expect_err("unsupported preset should be rejected");
339
340        assert!(err.message.contains("preset=\"receipt\""));
341        assert!(err.message.contains("preset=\"estimate\""));
342    }
343
344    #[cfg(feature = "analysis")]
345    #[test]
346    fn run_json_analyze_rejects_unsupported_presets() {
347        let result = run_json(
348            "analyze",
349            r#"{
350                "inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }],
351                "preset": "health"
352            }"#,
353        );
354        let envelope = tokmd_envelope::ffi::parse_envelope(&result).expect("valid JSON envelope");
355
356        assert_eq!(envelope["ok"], false);
357        assert_eq!(envelope["error"]["code"], "not_implemented");
358        assert!(
359            envelope["error"]["message"]
360                .as_str()
361                .expect("error message")
362                .contains("preset=\"receipt\"")
363        );
364        assert!(
365            envelope["error"]["message"]
366                .as_str()
367                .expect("error message")
368                .contains("preset=\"estimate\"")
369        );
370    }
371
372    #[cfg(feature = "analysis")]
373    #[test]
374    fn run_mode_value_analyze_accepts_nested_case_insensitive_estimate() {
375        let data = run_mode_value(
376            "analyze",
377            &json!({
378                "inputs": fixture_inputs(),
379                "analyze": { "preset": "Estimate" }
380            }),
381        )
382        .expect("analysis data");
383
384        assert_eq!(data["mode"], "analysis");
385        assert_eq!(data["source"]["inputs"][0], "crates/app/src/lib.rs");
386        assert_eq!(data["effort"]["model"], "cocomo81-basic");
387    }
388
389    #[test]
390    fn run_mode_value_surfaces_upstream_errors() {
391        let err = run_mode_value(
392            "lang",
393            &json!({
394                "inputs": fixture_inputs(),
395                "paths": ["src"]
396            }),
397        )
398        .expect_err("paths + inputs should error");
399
400        assert!(err.contains("[invalid_settings]"));
401        assert!(err.contains("cannot be combined with in-memory inputs"));
402    }
403
404    #[test]
405    fn schema_version_matches_core_receipts() {
406        assert_eq!(schema_version(), tokmd_types::SCHEMA_VERSION);
407    }
408
409    #[cfg(feature = "analysis")]
410    #[test]
411    fn analysis_schema_version_matches_analysis_receipts() {
412        assert_eq!(analysis_schema_version(), CORE_ANALYSIS_SCHEMA_VERSION);
413    }
414}
415
416#[cfg(all(test, target_arch = "wasm32"))]
417mod wasm_tests {
418    use super::*;
419    use serde_json::Value;
420    use wasm_bindgen::JsCast;
421    use wasm_bindgen_test::*;
422
423    fn parse_js_args(json: &str) -> JsValue {
424        JSON::parse(json).expect("valid JS object")
425    }
426
427    fn js_value_to_json(value: &JsValue) -> Value {
428        let json = JSON::stringify(value)
429            .expect("serializable JS value")
430            .as_string()
431            .expect("JSON string");
432        serde_json::from_str(&json).expect("valid JSON value")
433    }
434
435    fn core_mode_value(mode: &str, args_json: &str) -> Value {
436        let envelope_json = tokmd_core::ffi::run_json(mode, args_json);
437        let data_json =
438            tokmd_envelope::ffi::extract_data_json(&envelope_json).expect("core data payload");
439        serde_json::from_str(&data_json).expect("valid core JSON value")
440    }
441
442    fn assert_generated_at_ms_nonzero(label: &str, value: &Value) {
443        let timestamp = value
444            .get("generated_at_ms")
445            .and_then(Value::as_u64)
446            .unwrap_or_else(|| panic!("{label} missing numeric generated_at_ms"));
447        assert!(timestamp > 0, "{label} generated_at_ms must not be 0");
448    }
449
450    fn normalize_volatile_timestamps(value: &mut Value) {
451        match value {
452            Value::Array(items) => {
453                for item in items {
454                    normalize_volatile_timestamps(item);
455                }
456            }
457            Value::Object(object) => {
458                for (key, value) in object {
459                    if key == "generated_at_ms" || key == "export_generated_at_ms" {
460                        if !value.is_null() {
461                            *value = Value::from(1);
462                        }
463                    } else {
464                        normalize_volatile_timestamps(value);
465                    }
466                }
467            }
468            _ => {}
469        }
470    }
471
472    fn values_match_js_boundary(actual: &Value, expected: &Value) -> bool {
473        match (actual, expected) {
474            (Value::Null, Value::Null)
475            | (Value::Bool(_), Value::Bool(_))
476            | (Value::String(_), Value::String(_)) => actual == expected,
477            (Value::Number(actual), Value::Number(expected)) => {
478                numbers_match_js_boundary(actual, expected)
479            }
480            (Value::Array(actual), Value::Array(expected)) => {
481                actual.len() == expected.len()
482                    && actual
483                        .iter()
484                        .zip(expected.iter())
485                        .all(|(actual, expected)| values_match_js_boundary(actual, expected))
486            }
487            (Value::Object(actual), Value::Object(expected)) => {
488                actual.len() == expected.len()
489                    && actual.iter().all(|(key, actual_value)| {
490                        expected.get(key).is_some_and(|expected_value| {
491                            values_match_js_boundary(actual_value, expected_value)
492                        })
493                    })
494            }
495            _ => false,
496        }
497    }
498
499    fn numbers_match_js_boundary(
500        actual: &serde_json::Number,
501        expected: &serde_json::Number,
502    ) -> bool {
503        const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
504
505        if actual == expected {
506            return true;
507        }
508
509        if let (Some(actual), Some(expected)) = (actual.as_i64(), expected.as_i64()) {
510            return actual == expected;
511        }
512
513        if let (Some(actual), Some(expected)) = (actual.as_u64(), expected.as_u64()) {
514            return actual == expected;
515        }
516
517        let (Some(actual), Some(expected)) = (actual.as_f64(), expected.as_f64()) else {
518            return false;
519        };
520
521        if actual != expected {
522            return false;
523        }
524
525        let both_integral = actual.fract() == 0.0 && expected.fract() == 0.0;
526        if both_integral && (actual.abs() > MAX_SAFE_INTEGER || expected.abs() > MAX_SAFE_INTEGER) {
527            return false;
528        }
529
530        true
531    }
532
533    #[wasm_bindgen_test]
534    fn run_lang_exercises_js_value_boundary() {
535        let args_json = r#"{
536            "inputs": [
537                { "path": "src/lib.rs", "text": "pub fn alpha() {}\n" },
538                { "path": "tests/basic.py", "text": "print('ok')\n" }
539            ],
540            "files": true
541        }"#;
542        let data = run_lang(parse_js_args(args_json)).expect("lang data");
543        let mut parsed = js_value_to_json(&data);
544        let mut expected = core_mode_value("lang", args_json);
545
546        assert_eq!(parsed["mode"], "lang");
547        assert_eq!(parsed["scan"]["paths"][0], "src/lib.rs");
548        assert_eq!(parsed["total"]["files"], 2);
549        assert_generated_at_ms_nonzero("lang wasm payload", &parsed);
550        assert_generated_at_ms_nonzero("lang core payload", &expected);
551        normalize_volatile_timestamps(&mut parsed);
552        normalize_volatile_timestamps(&mut expected);
553        assert!(
554            values_match_js_boundary(&parsed, &expected),
555            "wasm payload diverged from core payload\nactual: {parsed}\nexpected: {expected}"
556        );
557    }
558
559    #[wasm_bindgen_test]
560    fn run_module_exercises_js_value_boundary() {
561        let args_json = r#"{
562            "inputs": [
563                { "path": "src/lib.rs", "text": "pub fn alpha() {}\n" },
564                { "path": "tests/basic.py", "text": "print('ok')\n" }
565            ]
566        }"#;
567        let data = run_module(parse_js_args(args_json)).expect("module data");
568        let mut parsed = js_value_to_json(&data);
569        let mut expected = core_mode_value("module", args_json);
570
571        assert_eq!(parsed["mode"], "module");
572        assert_eq!(parsed["scan"]["paths"][0], "src/lib.rs");
573        assert!(parsed["rows"].as_array().is_some());
574        assert_generated_at_ms_nonzero("module wasm payload", &parsed);
575        assert_generated_at_ms_nonzero("module core payload", &expected);
576        normalize_volatile_timestamps(&mut parsed);
577        normalize_volatile_timestamps(&mut expected);
578        assert!(
579            values_match_js_boundary(&parsed, &expected),
580            "wasm payload diverged from core payload\nactual: {parsed}\nexpected: {expected}"
581        );
582    }
583
584    #[wasm_bindgen_test]
585    fn run_export_exercises_js_value_boundary() {
586        let args_json = r#"{
587            "inputs": [
588                { "path": "src/lib.rs", "text": "pub fn alpha() {}\n" },
589                { "path": "tests/basic.py", "text": "print('ok')\n" }
590            ]
591        }"#;
592        let data = run_export(parse_js_args(args_json)).expect("export data");
593        let mut parsed = js_value_to_json(&data);
594        let mut expected = core_mode_value("export", args_json);
595
596        assert_eq!(parsed["mode"], "export");
597        assert_eq!(parsed["scan"]["paths"][0], "src/lib.rs");
598        assert_eq!(parsed["rows"][0]["path"], "src/lib.rs");
599        assert_generated_at_ms_nonzero("export wasm payload", &parsed);
600        assert_generated_at_ms_nonzero("export core payload", &expected);
601        normalize_volatile_timestamps(&mut parsed);
602        normalize_volatile_timestamps(&mut expected);
603        assert!(
604            values_match_js_boundary(&parsed, &expected),
605            "wasm payload diverged from core payload\nactual: {parsed}\nexpected: {expected}"
606        );
607    }
608
609    #[wasm_bindgen_test]
610    fn run_surfaces_js_facing_errors() {
611        let err = run(
612            "lang",
613            parse_js_args(
614                r#"{
615                    "inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }],
616                    "paths": ["src"]
617                }"#,
618            ),
619        )
620        .expect_err("conflicting inputs should error")
621        .dyn_into::<JsError>()
622        .expect("js error");
623
624        let message = err.message().as_string().expect("js string message");
625        assert!(message.contains("[invalid_settings]"));
626    }
627
628    #[cfg(feature = "analysis")]
629    #[wasm_bindgen_test]
630    fn run_analyze_estimate_reports_analysis_schema_and_matches_core_payload() {
631        let args_json = r#"{
632            "inputs": [
633                { "path": "crates/app/src/lib.rs", "text": "pub fn alpha() -> usize { 1 }\n" },
634                { "path": "src/main.rs", "text": "fn main() {}\n" }
635            ],
636            "preset": "estimate"
637        }"#;
638        let data = run_analyze(parse_js_args(args_json)).expect("analysis data");
639        let mut parsed = js_value_to_json(&data);
640        let mut expected = core_mode_value("analyze", args_json);
641
642        assert_eq!(analysis_schema_version(), CORE_ANALYSIS_SCHEMA_VERSION);
643        assert_eq!(parsed["mode"], "analysis");
644        assert_eq!(parsed["source"]["inputs"][0], "crates/app/src/lib.rs");
645        assert_eq!(parsed["effort"]["model"], "cocomo81-basic");
646        assert_generated_at_ms_nonzero("analysis estimate wasm payload", &parsed);
647        assert_generated_at_ms_nonzero("analysis estimate core payload", &expected);
648        normalize_volatile_timestamps(&mut parsed);
649        normalize_volatile_timestamps(&mut expected);
650        assert!(
651            values_match_js_boundary(&parsed, &expected),
652            "wasm payload diverged from core payload\nactual: {parsed}\nexpected: {expected}"
653        );
654    }
655
656    #[cfg(feature = "analysis")]
657    #[wasm_bindgen_test]
658    fn run_analyze_receipt_matches_core_payload() {
659        let args_json = r#"{
660            "inputs": [
661                { "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }
662            ],
663            "preset": "receipt"
664        }"#;
665        let data = run_analyze(parse_js_args(args_json)).expect("analysis data");
666        let mut parsed = js_value_to_json(&data);
667        let mut expected = core_mode_value("analyze", args_json);
668
669        assert_eq!(parsed["mode"], "analysis");
670        assert_eq!(parsed["source"]["inputs"][0], "src/lib.rs");
671        assert_eq!(parsed["derived"]["totals"]["files"], 1);
672        assert_eq!(parsed["effort"], Value::Null);
673        assert_generated_at_ms_nonzero("analysis receipt wasm payload", &parsed);
674        assert_generated_at_ms_nonzero("analysis receipt core payload", &expected);
675        normalize_volatile_timestamps(&mut parsed);
676        normalize_volatile_timestamps(&mut expected);
677        assert!(
678            values_match_js_boundary(&parsed, &expected),
679            "wasm payload diverged from core payload\nactual: {parsed}\nexpected: {expected}"
680        );
681    }
682
683    #[cfg(feature = "analysis")]
684    #[wasm_bindgen_test]
685    fn run_analyze_without_preset_defaults_to_receipt() {
686        let data = run_analyze(parse_js_args(
687            r#"{
688                "inputs": [
689                    { "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }
690                ]
691            }"#,
692        ))
693        .expect("analysis data");
694        let parsed = js_value_to_json(&data);
695
696        assert_eq!(parsed["mode"], "analysis");
697        assert_eq!(parsed["source"]["inputs"][0], "src/lib.rs");
698        assert_eq!(parsed["derived"]["totals"]["files"], 1);
699        assert_eq!(parsed["effort"], Value::Null);
700    }
701
702    #[cfg(feature = "analysis")]
703    #[wasm_bindgen_test]
704    fn run_analyze_rejects_unsupported_presets() {
705        let err = run_analyze(parse_js_args(
706            r#"{
707                "inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }],
708                "preset": "health"
709            }"#,
710        ))
711        .expect_err("non-estimate preset should be rejected")
712        .dyn_into::<JsError>()
713        .expect("js error");
714
715        let message = err.message().as_string().expect("js string message");
716        assert!(message.contains("preset=\"receipt\""));
717        assert!(message.contains("preset=\"estimate\""));
718    }
719
720    #[cfg(feature = "analysis")]
721    #[wasm_bindgen_test]
722    fn run_accepts_nested_case_insensitive_analyze_preset() {
723        let data = run(
724            "analyze",
725            parse_js_args(
726                r#"{
727                    "inputs": [
728                        { "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }
729                    ],
730                    "analyze": { "preset": "Estimate" }
731                }"#,
732            ),
733        )
734        .expect("analysis data");
735        let parsed = js_value_to_json(&data);
736
737        assert_eq!(parsed["mode"], "analysis");
738        assert_eq!(parsed["effort"]["model"], "cocomo81-basic");
739    }
740
741    #[cfg(feature = "analysis")]
742    #[wasm_bindgen_test]
743    fn run_rejects_unsupported_analyze_presets() {
744        let err = run(
745            "analyze",
746            parse_js_args(
747                r#"{
748                    "inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }],
749                    "preset": "health"
750                }"#,
751            ),
752        )
753        .expect_err("non-estimate preset should be rejected")
754        .dyn_into::<JsError>()
755        .expect("js error");
756
757        let message = err.message().as_string().expect("js string message");
758        assert!(message.contains("preset=\"receipt\""));
759        assert!(message.contains("preset=\"estimate\""));
760    }
761}