1use crate::config::E2eConfig;
7use crate::escape::{escape_js, sanitize_filename, sanitize_ident};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, Fixture, FixtureGroup};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::AlefConfig;
12use anyhow::Result;
13use heck::{ToLowerCamelCase, ToUpperCamelCase};
14use std::collections::HashMap;
15use std::fmt::Write as FmtWrite;
16use std::path::PathBuf;
17
18use super::E2eCodegen;
19
20pub struct WasmCodegen;
22
23impl E2eCodegen for WasmCodegen {
24 fn generate(
25 &self,
26 groups: &[FixtureGroup],
27 e2e_config: &E2eConfig,
28 _alef_config: &AlefConfig,
29 ) -> Result<Vec<GeneratedFile>> {
30 let lang = self.language_name();
31 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
32 let tests_base = output_base.join("tests");
33
34 let mut files = Vec::new();
35
36 let call = &e2e_config.call;
38 let overrides = call.overrides.get(lang);
39 let module_path = overrides
40 .and_then(|o| o.module.as_ref())
41 .cloned()
42 .unwrap_or_else(|| call.module.clone());
43 let function_name = overrides
44 .and_then(|o| o.function.as_ref())
45 .cloned()
46 .unwrap_or_else(|| call.function.clone());
47 let options_type = overrides.and_then(|o| o.options_type.clone());
48 let empty_enum_fields = HashMap::new();
49 let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
50 let result_var = &call.result_var;
51 let is_async = call.r#async;
52
53 let wasm_pkg = e2e_config.resolve_package("wasm");
55 let pkg_path = wasm_pkg
56 .as_ref()
57 .and_then(|p| p.path.as_ref())
58 .cloned()
59 .unwrap_or_else(|| "../../crates/html-to-markdown-wasm/pkg".to_string());
60 let pkg_name = wasm_pkg
61 .as_ref()
62 .and_then(|p| p.name.as_ref())
63 .cloned()
64 .unwrap_or_else(|| module_path.clone());
65 let pkg_version = wasm_pkg
66 .as_ref()
67 .and_then(|p| p.version.as_ref())
68 .cloned()
69 .unwrap_or_else(|| "0.1.0".to_string());
70
71 files.push(GeneratedFile {
73 path: output_base.join("package.json"),
74 content: render_package_json(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
75 generated_header: false,
76 });
77
78 files.push(GeneratedFile {
80 path: output_base.join("vitest.config.ts"),
81 content: render_vitest_config(),
82 generated_header: true,
83 });
84
85 for group in groups {
87 let active: Vec<&Fixture> = group
88 .fixtures
89 .iter()
90 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
91 .collect();
92
93 if active.is_empty() {
94 continue;
95 }
96
97 let filename = format!("{}.test.ts", sanitize_filename(&group.category));
98 let field_resolver = FieldResolver::new(
99 &e2e_config.fields,
100 &e2e_config.fields_optional,
101 &e2e_config.result_fields,
102 &e2e_config.fields_array,
103 );
104 let content = render_test_file(
105 &group.category,
106 &active,
107 &pkg_name,
108 &function_name,
109 result_var,
110 is_async,
111 &e2e_config.call.args,
112 &field_resolver,
113 options_type.as_deref(),
114 enum_fields,
115 );
116 files.push(GeneratedFile {
117 path: tests_base.join(filename),
118 content,
119 generated_header: true,
120 });
121 }
122
123 Ok(files)
124 }
125
126 fn language_name(&self) -> &'static str {
127 "wasm"
128 }
129}
130
131fn render_package_json(
132 pkg_name: &str,
133 pkg_path: &str,
134 pkg_version: &str,
135 dep_mode: crate::config::DependencyMode,
136) -> String {
137 let dep_value = match dep_mode {
138 crate::config::DependencyMode::Registry => pkg_version.to_string(),
139 crate::config::DependencyMode::Local => format!("file:{pkg_path}"),
140 };
141 format!(
142 r#"{{
143 "name": "{pkg_name}-e2e-wasm",
144 "version": "0.1.0",
145 "private": true,
146 "type": "module",
147 "scripts": {{
148 "test": "vitest run"
149 }},
150 "devDependencies": {{
151 "{pkg_name}": "{dep_value}",
152 "vite-plugin-top-level-await": "^1.4.0",
153 "vite-plugin-wasm": "^3.4.0",
154 "vitest": "^3.0.0"
155 }}
156}}
157"#
158 )
159}
160
161fn render_vitest_config() -> String {
162 r#"// This file is auto-generated by alef. DO NOT EDIT.
163import { defineConfig } from 'vitest/config';
164import wasm from 'vite-plugin-wasm';
165import topLevelAwait from 'vite-plugin-top-level-await';
166
167export default defineConfig({
168 plugins: [wasm(), topLevelAwait()],
169 test: {
170 include: ['tests/**/*.test.ts'],
171 },
172});
173"#
174 .to_string()
175}
176
177#[allow(clippy::too_many_arguments)]
178fn render_test_file(
179 category: &str,
180 fixtures: &[&Fixture],
181 pkg_name: &str,
182 function_name: &str,
183 result_var: &str,
184 is_async: bool,
185 args: &[crate::config::ArgMapping],
186 field_resolver: &FieldResolver,
187 options_type: Option<&str>,
188 enum_fields: &HashMap<String, String>,
189) -> String {
190 let mut out = String::new();
191 let _ = writeln!(out, "// This file is auto-generated by alef. DO NOT EDIT.");
192 let _ = writeln!(out, "import {{ describe, it, expect }} from 'vitest';");
193
194 let needs_options_import = options_type.is_some()
196 && fixtures.iter().any(|f| {
197 args.iter()
198 .any(|arg| arg.arg_type == "json_object" && f.input.get(&arg.field).is_some_and(|v| !v.is_null()))
199 });
200
201 let mut enum_imports: std::collections::BTreeSet<&String> = std::collections::BTreeSet::new();
203 if needs_options_import {
204 for fixture in fixtures {
205 for arg in args {
206 if arg.arg_type == "json_object" {
207 if let Some(val) = fixture.input.get(&arg.field) {
208 if let Some(obj) = val.as_object() {
209 for k in obj.keys() {
210 if let Some(enum_type) = enum_fields.get(k) {
211 enum_imports.insert(enum_type);
212 }
213 }
214 }
215 }
216 }
217 }
218 }
219 }
220
221 if let (true, Some(opts_type)) = (needs_options_import, options_type) {
222 let mut imports = vec![function_name.to_string(), opts_type.to_string()];
223 imports.extend(enum_imports.iter().map(|s| s.to_string()));
224 let _ = writeln!(out, "import {{ {} }} from '{pkg_name}';", imports.join(", "));
225 } else {
226 let _ = writeln!(out, "import {{ {function_name} }} from '{pkg_name}';");
227 }
228 let _ = writeln!(out);
229 let _ = writeln!(out, "describe('{category}', () => {{");
230
231 for (i, fixture) in fixtures.iter().enumerate() {
232 render_test_case(
233 &mut out,
234 fixture,
235 function_name,
236 result_var,
237 is_async,
238 args,
239 field_resolver,
240 options_type,
241 enum_fields,
242 );
243 if i + 1 < fixtures.len() {
244 let _ = writeln!(out);
245 }
246 }
247
248 let _ = writeln!(out, "}});");
249 out
250}
251
252#[allow(clippy::too_many_arguments)]
253fn render_test_case(
254 out: &mut String,
255 fixture: &Fixture,
256 function_name: &str,
257 result_var: &str,
258 is_async: bool,
259 args: &[crate::config::ArgMapping],
260 field_resolver: &FieldResolver,
261 options_type: Option<&str>,
262 enum_fields: &HashMap<String, String>,
263) {
264 let test_name = sanitize_ident(&fixture.id);
265 let description = fixture.description.replace('\'', "\\'");
266 let async_kw = if is_async { "async " } else { "" };
267 let await_kw = if is_async { "await " } else { "" };
268
269 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
270
271 if expects_error {
272 let _ = writeln!(out, " it('{test_name}: {description}', {async_kw}() => {{");
273 let args_str = build_args_string(&fixture.input, args, options_type, enum_fields);
274 if is_async {
275 let _ = writeln!(
276 out,
277 " await expect({async_kw}() => {await_kw}{function_name}({args_str})).rejects.toThrow();"
278 );
279 } else {
280 let _ = writeln!(out, " expect(() => {function_name}({args_str})).toThrow();");
281 }
282 let _ = writeln!(out, " }});");
283 return;
284 }
285
286 let _ = writeln!(out, " it('{test_name}: {description}', {async_kw}() => {{");
287
288 let has_options_setup = options_type.is_some()
290 && args
291 .iter()
292 .any(|arg| arg.arg_type == "json_object" && fixture.input.get(&arg.field).is_some_and(|v| !v.is_null()));
293
294 if has_options_setup {
295 if let Some(opts_type) = options_type {
297 for arg in args {
298 if arg.arg_type == "json_object" {
299 if let Some(val) = fixture.input.get(&arg.field) {
300 if !val.is_null() {
301 if let Some(obj) = val.as_object() {
302 let _ = writeln!(out, " const options = {opts_type}.default();");
303 for (k, v) in obj {
304 let camel_key = k.to_lower_camel_case();
305 let js_val = if let Some(enum_type) = enum_fields.get(k) {
307 if let Some(s) = v.as_str() {
309 let pascal_val = s.to_upper_camel_case();
310 format!("{enum_type}.{pascal_val}")
311 } else {
312 json_to_js(v)
313 }
314 } else {
315 json_to_js(v)
316 };
317 let _ = writeln!(out, " options.{camel_key} = {js_val};");
318 }
319 }
320 }
321 }
322 }
323 }
324 }
325 let call_args: Vec<String> = args
327 .iter()
328 .filter_map(|arg| {
329 let val = fixture.input.get(&arg.field)?;
330 if val.is_null() && arg.optional {
331 return None;
332 }
333 if arg.arg_type == "json_object" && !val.is_null() && options_type.is_some() {
334 return Some("options".to_string());
335 }
336 Some(json_to_js(val))
337 })
338 .collect();
339 let args_str = call_args.join(", ");
340 let _ = writeln!(out, " const {result_var} = {await_kw}{function_name}({args_str});");
341 } else {
342 let args_str = build_args_string(&fixture.input, args, options_type, enum_fields);
343 let _ = writeln!(out, " const {result_var} = {await_kw}{function_name}({args_str});");
344 }
345
346 for assertion in &fixture.assertions {
347 render_assertion(out, assertion, result_var, field_resolver);
348 }
349
350 let _ = writeln!(out, " }});");
351}
352
353fn build_args_string(
354 input: &serde_json::Value,
355 args: &[crate::config::ArgMapping],
356 _options_type: Option<&str>,
357 _enum_fields: &HashMap<String, String>,
358) -> String {
359 if args.is_empty() {
360 return json_to_js(input);
361 }
362
363 let parts: Vec<String> = args
364 .iter()
365 .filter_map(|arg| {
366 let val = input.get(&arg.field)?;
367 if val.is_null() && arg.optional {
368 return None;
369 }
370 Some(json_to_js(val))
371 })
372 .collect();
373
374 parts.join(", ")
375}
376
377fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
378 if let Some(f) = &assertion.field {
380 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
381 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
382 return;
383 }
384 }
385
386 let field_expr = match &assertion.field {
387 Some(f) if !f.is_empty() => field_resolver.accessor(f, "wasm", result_var),
388 _ => result_var.to_string(),
389 };
390
391 match assertion.assertion_type.as_str() {
392 "equals" => {
393 if let Some(expected) = &assertion.value {
394 let js_val = json_to_js(expected);
395 if expected.is_string() {
396 let _ = writeln!(out, " expect({field_expr}.trim()).toBe({js_val});");
397 } else {
398 let _ = writeln!(out, " expect({field_expr}).toBe({js_val});");
399 }
400 }
401 }
402 "contains" => {
403 if let Some(expected) = &assertion.value {
404 let js_val = json_to_js(expected);
405 let _ = writeln!(out, " expect({field_expr}).toContain({js_val});");
406 }
407 }
408 "contains_all" => {
409 if let Some(values) = &assertion.values {
410 for val in values {
411 let js_val = json_to_js(val);
412 let _ = writeln!(out, " expect({field_expr}).toContain({js_val});");
413 }
414 }
415 }
416 "not_contains" => {
417 if let Some(expected) = &assertion.value {
418 let js_val = json_to_js(expected);
419 let _ = writeln!(out, " expect({field_expr}).not.toContain({js_val});");
420 }
421 }
422 "not_empty" => {
423 let _ = writeln!(out, " expect({field_expr}.length).toBeGreaterThan(0);");
424 }
425 "is_empty" => {
426 let _ = writeln!(out, " expect({field_expr}.trim()).toHaveLength(0);");
427 }
428 "contains_any" => {
429 if let Some(values) = &assertion.values {
430 let items: Vec<String> = values.iter().map(json_to_js).collect();
431 let arr_str = items.join(", ");
432 let _ = writeln!(
433 out,
434 " expect([{arr_str}].some((v) => {field_expr}.includes(v))).toBe(true);"
435 );
436 }
437 }
438 "greater_than" => {
439 if let Some(val) = &assertion.value {
440 let js_val = json_to_js(val);
441 let _ = writeln!(out, " expect({field_expr}).toBeGreaterThan({js_val});");
442 }
443 }
444 "less_than" => {
445 if let Some(val) = &assertion.value {
446 let js_val = json_to_js(val);
447 let _ = writeln!(out, " expect({field_expr}).toBeLessThan({js_val});");
448 }
449 }
450 "greater_than_or_equal" => {
451 if let Some(val) = &assertion.value {
452 let js_val = json_to_js(val);
453 let _ = writeln!(out, " expect({field_expr}).toBeGreaterThanOrEqual({js_val});");
454 }
455 }
456 "less_than_or_equal" => {
457 if let Some(val) = &assertion.value {
458 let js_val = json_to_js(val);
459 let _ = writeln!(out, " expect({field_expr}).toBeLessThanOrEqual({js_val});");
460 }
461 }
462 "starts_with" => {
463 if let Some(expected) = &assertion.value {
464 let js_val = json_to_js(expected);
465 let _ = writeln!(out, " expect({field_expr}.startsWith({js_val})).toBe(true);");
466 }
467 }
468 "count_min" => {
469 if let Some(val) = &assertion.value {
470 if let Some(n) = val.as_u64() {
471 let _ = writeln!(out, " expect({field_expr}.length).toBeGreaterThanOrEqual({n});");
472 }
473 }
474 }
475 "not_error" => {
476 }
478 "error" => {
479 }
481 other => {
482 let _ = writeln!(out, " // TODO: unsupported assertion type: {other}");
483 }
484 }
485}
486
487fn json_to_js(value: &serde_json::Value) -> String {
489 match value {
490 serde_json::Value::String(s) => format!("\"{}\"", escape_js(s)),
491 serde_json::Value::Bool(b) => b.to_string(),
492 serde_json::Value::Number(n) => n.to_string(),
493 serde_json::Value::Null => "null".to_string(),
494 serde_json::Value::Array(arr) => {
495 let items: Vec<String> = arr.iter().map(json_to_js).collect();
496 format!("[{}]", items.join(", "))
497 }
498 serde_json::Value::Object(map) => {
499 let entries: Vec<String> = map.iter().map(|(k, v)| format!("{}: {}", k, json_to_js(v))).collect();
500 format!("{{ {} }}", entries.join(", "))
501 }
502 }
503}