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 files.push(GeneratedFile {
87 path: output_base.join("tsconfig.json"),
88 content: render_tsconfig(),
89 generated_header: false,
90 });
91
92 for group in groups {
94 let active: Vec<&Fixture> = group
95 .fixtures
96 .iter()
97 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
98 .collect();
99
100 if active.is_empty() {
101 continue;
102 }
103
104 let filename = format!("{}.test.ts", sanitize_filename(&group.category));
105 let field_resolver = FieldResolver::new(
106 &e2e_config.fields,
107 &e2e_config.fields_optional,
108 &e2e_config.result_fields,
109 &e2e_config.fields_array,
110 );
111 let content = render_test_file(
112 &group.category,
113 &active,
114 &pkg_name,
115 &function_name,
116 result_var,
117 is_async,
118 &e2e_config.call.args,
119 &field_resolver,
120 options_type.as_deref(),
121 enum_fields,
122 );
123 files.push(GeneratedFile {
124 path: tests_base.join(filename),
125 content,
126 generated_header: true,
127 });
128 }
129
130 Ok(files)
131 }
132
133 fn language_name(&self) -> &'static str {
134 "wasm"
135 }
136}
137
138fn render_package_json(
139 pkg_name: &str,
140 pkg_path: &str,
141 pkg_version: &str,
142 dep_mode: crate::config::DependencyMode,
143) -> String {
144 let dep_value = match dep_mode {
145 crate::config::DependencyMode::Registry => pkg_version.to_string(),
146 crate::config::DependencyMode::Local => format!("file:{pkg_path}"),
147 };
148 format!(
149 r#"{{
150 "name": "{pkg_name}-e2e-wasm",
151 "version": "0.1.0",
152 "private": true,
153 "type": "module",
154 "scripts": {{
155 "test": "vitest run"
156 }},
157 "devDependencies": {{
158 "{pkg_name}": "{dep_value}",
159 "vite-plugin-top-level-await": "^1.4.0",
160 "vite-plugin-wasm": "^3.4.0",
161 "vitest": "^3.0.0"
162 }}
163}}
164"#
165 )
166}
167
168fn render_vitest_config() -> String {
169 r#"// This file is auto-generated by alef. DO NOT EDIT.
170import { defineConfig } from 'vitest/config';
171import wasm from 'vite-plugin-wasm';
172import topLevelAwait from 'vite-plugin-top-level-await';
173
174export default defineConfig({
175 plugins: [wasm(), topLevelAwait()],
176 test: {
177 include: ['tests/**/*.test.ts'],
178 },
179});
180"#
181 .to_string()
182}
183
184fn render_tsconfig() -> String {
185 r#"{
186 "compilerOptions": {
187 "target": "ES2022",
188 "module": "ESNext",
189 "moduleResolution": "bundler",
190 "strict": true,
191 "strictNullChecks": false,
192 "esModuleInterop": true,
193 "skipLibCheck": true
194 },
195 "include": ["tests/**/*.ts", "vitest.config.ts"]
196}
197"#
198 .to_string()
199}
200
201#[allow(clippy::too_many_arguments)]
202fn render_test_file(
203 category: &str,
204 fixtures: &[&Fixture],
205 pkg_name: &str,
206 function_name: &str,
207 result_var: &str,
208 is_async: bool,
209 args: &[crate::config::ArgMapping],
210 field_resolver: &FieldResolver,
211 options_type: Option<&str>,
212 enum_fields: &HashMap<String, String>,
213) -> String {
214 let mut out = String::new();
215 let _ = writeln!(out, "// This file is auto-generated by alef. DO NOT EDIT.");
216 let _ = writeln!(out, "import {{ describe, it, expect }} from 'vitest';");
217
218 let needs_options_import = options_type.is_some()
220 && fixtures.iter().any(|f| {
221 args.iter()
222 .any(|arg| arg.arg_type == "json_object" && f.input.get(&arg.field).is_some_and(|v| !v.is_null()))
223 });
224
225 let mut enum_imports: std::collections::BTreeSet<&String> = std::collections::BTreeSet::new();
227 if needs_options_import {
228 for fixture in fixtures {
229 for arg in args {
230 if arg.arg_type == "json_object" {
231 if let Some(val) = fixture.input.get(&arg.field) {
232 if let Some(obj) = val.as_object() {
233 for k in obj.keys() {
234 if let Some(enum_type) = enum_fields.get(k) {
235 enum_imports.insert(enum_type);
236 }
237 }
238 }
239 }
240 }
241 }
242 }
243 }
244
245 let handle_constructors: Vec<String> = args
247 .iter()
248 .filter(|arg| arg.arg_type == "handle")
249 .map(|arg| format!("create{}", arg.name.to_upper_camel_case()))
250 .collect();
251
252 {
253 let mut imports = vec![function_name.to_string()];
254 imports.extend(handle_constructors);
255 if let (true, Some(opts_type)) = (needs_options_import, options_type) {
256 imports.push(opts_type.to_string());
257 imports.extend(enum_imports.iter().map(|s| s.to_string()));
258 }
259 let _ = writeln!(out, "import {{ {} }} from '{pkg_name}';", imports.join(", "));
260 }
261 let _ = writeln!(out);
262 let _ = writeln!(out, "describe('{category}', () => {{");
263
264 for (i, fixture) in fixtures.iter().enumerate() {
265 render_test_case(
266 &mut out,
267 fixture,
268 function_name,
269 result_var,
270 is_async,
271 args,
272 field_resolver,
273 options_type,
274 enum_fields,
275 );
276 if i + 1 < fixtures.len() {
277 let _ = writeln!(out);
278 }
279 }
280
281 let _ = writeln!(out, "}});");
282 out
283}
284
285#[allow(clippy::too_many_arguments)]
286fn render_test_case(
287 out: &mut String,
288 fixture: &Fixture,
289 function_name: &str,
290 result_var: &str,
291 is_async: bool,
292 args: &[crate::config::ArgMapping],
293 field_resolver: &FieldResolver,
294 options_type: Option<&str>,
295 enum_fields: &HashMap<String, String>,
296) {
297 let test_name = sanitize_ident(&fixture.id);
298 let description = fixture.description.replace('\'', "\\'");
299 let async_kw = if is_async { "async " } else { "" };
300 let await_kw = if is_async { "await " } else { "" };
301
302 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
303 let (setup_lines, arg_parts) = build_args_and_setup(&fixture.input, args, options_type, enum_fields, &fixture.id);
304 let args_str = arg_parts.join(", ");
305
306 if expects_error {
307 let _ = writeln!(out, " it('{test_name}: {description}', {async_kw}() => {{");
308 for line in &setup_lines {
309 let _ = writeln!(out, " {line}");
310 }
311 if is_async {
312 let _ = writeln!(
313 out,
314 " await expect({async_kw}() => {await_kw}{function_name}({args_str})).rejects.toThrow();"
315 );
316 } else {
317 let _ = writeln!(out, " expect(() => {function_name}({args_str})).toThrow();");
318 }
319 let _ = writeln!(out, " }});");
320 return;
321 }
322
323 let _ = writeln!(out, " it('{test_name}: {description}', {async_kw}() => {{");
324 for line in &setup_lines {
325 let _ = writeln!(out, " {line}");
326 }
327 let _ = writeln!(out, " const {result_var} = {await_kw}{function_name}({args_str});");
328
329 for assertion in &fixture.assertions {
330 render_assertion(out, assertion, result_var, field_resolver);
331 }
332
333 let _ = writeln!(out, " }});");
334}
335
336fn build_args_and_setup(
341 input: &serde_json::Value,
342 args: &[crate::config::ArgMapping],
343 options_type: Option<&str>,
344 enum_fields: &HashMap<String, String>,
345 fixture_id: &str,
346) -> (Vec<String>, Vec<String>) {
347 let mut setup_lines = Vec::new();
348 let mut parts = Vec::new();
349
350 if args.is_empty() {
351 parts.push(json_to_js(input));
352 return (setup_lines, parts);
353 }
354
355 for arg in args {
356 if arg.arg_type == "mock_url" {
357 setup_lines.push(format!(
358 "const {} = `${{process.env.MOCK_SERVER_URL}}/fixtures/{fixture_id}`;",
359 arg.name,
360 ));
361 parts.push(arg.name.clone());
362 continue;
363 }
364
365 if arg.arg_type == "handle" {
366 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
367 let config_value = input.get(&arg.field).unwrap_or(&serde_json::Value::Null);
368 if config_value.is_null()
369 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
370 {
371 setup_lines.push(format!("const {} = {constructor_name}(null);", arg.name));
372 } else {
373 let js_val = json_to_js(config_value);
374 setup_lines.push(format!("const {} = {constructor_name}({js_val});", arg.name));
375 }
376 parts.push(arg.name.clone());
377 continue;
378 }
379
380 let val = input.get(&arg.field);
381 match val {
382 None | Some(serde_json::Value::Null) if arg.optional => continue,
383 None | Some(serde_json::Value::Null) => {
384 let default_val = match arg.arg_type.as_str() {
385 "string" => "''".to_string(),
386 "int" | "integer" => "0".to_string(),
387 "float" | "number" => "0.0".to_string(),
388 "bool" | "boolean" => "false".to_string(),
389 _ => "null".to_string(),
390 };
391 parts.push(default_val);
392 }
393 Some(v) => {
394 if arg.arg_type == "json_object" && !v.is_null() {
395 if let Some(opts_type) = options_type {
396 if let Some(obj) = v.as_object() {
397 setup_lines.push(format!("const options = {opts_type}.default();"));
398 for (k, field_val) in obj {
399 let camel_key = k.to_lower_camel_case();
400 let js_val = if let Some(enum_type) = enum_fields.get(k) {
401 if let Some(s) = field_val.as_str() {
402 let pascal_val = s.to_upper_camel_case();
403 format!("{enum_type}.{pascal_val}")
404 } else {
405 json_to_js(field_val)
406 }
407 } else {
408 json_to_js(field_val)
409 };
410 setup_lines.push(format!("options.{camel_key} = {js_val};"));
411 }
412 parts.push("options".to_string());
413 continue;
414 }
415 }
416 }
417 parts.push(json_to_js(v));
418 }
419 }
420 }
421
422 (setup_lines, parts)
423}
424
425fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
426 if let Some(f) = &assertion.field {
428 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
429 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
430 return;
431 }
432 }
433
434 let field_expr = match &assertion.field {
435 Some(f) if !f.is_empty() => field_resolver.accessor(f, "wasm", result_var),
436 _ => result_var.to_string(),
437 };
438
439 match assertion.assertion_type.as_str() {
440 "equals" => {
441 if let Some(expected) = &assertion.value {
442 let js_val = json_to_js(expected);
443 if expected.is_string() {
444 let _ = writeln!(out, " expect({field_expr}.trim()).toBe({js_val});");
445 } else {
446 let _ = writeln!(out, " expect({field_expr}).toBe({js_val});");
447 }
448 }
449 }
450 "contains" => {
451 if let Some(expected) = &assertion.value {
452 let js_val = json_to_js(expected);
453 let _ = writeln!(out, " expect({field_expr}).toContain({js_val});");
454 }
455 }
456 "contains_all" => {
457 if let Some(values) = &assertion.values {
458 for val in values {
459 let js_val = json_to_js(val);
460 let _ = writeln!(out, " expect({field_expr}).toContain({js_val});");
461 }
462 }
463 }
464 "not_contains" => {
465 if let Some(expected) = &assertion.value {
466 let js_val = json_to_js(expected);
467 let _ = writeln!(out, " expect({field_expr}).not.toContain({js_val});");
468 }
469 }
470 "not_empty" => {
471 let _ = writeln!(out, " expect({field_expr}.length).toBeGreaterThan(0);");
472 }
473 "is_empty" => {
474 let _ = writeln!(out, " expect({field_expr}.trim()).toHaveLength(0);");
475 }
476 "contains_any" => {
477 if let Some(values) = &assertion.values {
478 let items: Vec<String> = values.iter().map(json_to_js).collect();
479 let arr_str = items.join(", ");
480 let _ = writeln!(
481 out,
482 " expect([{arr_str}].some((v) => {field_expr}.includes(v))).toBe(true);"
483 );
484 }
485 }
486 "greater_than" => {
487 if let Some(val) = &assertion.value {
488 let js_val = json_to_js(val);
489 let _ = writeln!(out, " expect({field_expr}).toBeGreaterThan({js_val});");
490 }
491 }
492 "less_than" => {
493 if let Some(val) = &assertion.value {
494 let js_val = json_to_js(val);
495 let _ = writeln!(out, " expect({field_expr}).toBeLessThan({js_val});");
496 }
497 }
498 "greater_than_or_equal" => {
499 if let Some(val) = &assertion.value {
500 let js_val = json_to_js(val);
501 let _ = writeln!(out, " expect({field_expr}).toBeGreaterThanOrEqual({js_val});");
502 }
503 }
504 "less_than_or_equal" => {
505 if let Some(val) = &assertion.value {
506 let js_val = json_to_js(val);
507 let _ = writeln!(out, " expect({field_expr}).toBeLessThanOrEqual({js_val});");
508 }
509 }
510 "starts_with" => {
511 if let Some(expected) = &assertion.value {
512 let js_val = json_to_js(expected);
513 let _ = writeln!(out, " expect({field_expr}.startsWith({js_val})).toBe(true);");
514 }
515 }
516 "count_min" => {
517 if let Some(val) = &assertion.value {
518 if let Some(n) = val.as_u64() {
519 let _ = writeln!(out, " expect({field_expr}.length).toBeGreaterThanOrEqual({n});");
520 }
521 }
522 }
523 "not_error" => {
524 }
526 "error" => {
527 }
529 other => {
530 let _ = writeln!(out, " // TODO: unsupported assertion type: {other}");
531 }
532 }
533}
534
535fn json_to_js(value: &serde_json::Value) -> String {
537 match value {
538 serde_json::Value::String(s) => format!("\"{}\"", escape_js(s)),
539 serde_json::Value::Bool(b) => b.to_string(),
540 serde_json::Value::Number(n) => n.to_string(),
541 serde_json::Value::Null => "null".to_string(),
542 serde_json::Value::Array(arr) => {
543 let items: Vec<String> = arr.iter().map(json_to_js).collect();
544 format!("[{}]", items.join(", "))
545 }
546 serde_json::Value::Object(map) => {
547 let entries: Vec<String> = map.iter().map(|(k, v)| format!("{}: {}", k, json_to_js(v))).collect();
548 format!("{{ {} }}", entries.join(", "))
549 }
550 }
551}