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