1use crate::config::E2eConfig;
7use crate::escape::{escape_js, sanitize_filename, sanitize_ident};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, CallbackAction, 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 handle_config_type = overrides.and_then(|o| o.handle_config_type.clone());
49 let client_factory = overrides.and_then(|o| o.client_factory.as_deref());
50 let empty_enum_fields = HashMap::new();
51 let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
52 let empty_bigint_fields: Vec<String> = Vec::new();
53 let bigint_fields = overrides.map(|o| &o.bigint_fields).unwrap_or(&empty_bigint_fields);
54 let result_var = &call.result_var;
55 let is_async = call.r#async;
56
57 let wasm_pkg = e2e_config.resolve_package("wasm");
59 let pkg_path = wasm_pkg
60 .as_ref()
61 .and_then(|p| p.path.as_ref())
62 .cloned()
63 .unwrap_or_else(|| format!("../../crates/{}-wasm/pkg", alef_config.crate_config.name));
64 let pkg_name = wasm_pkg
65 .as_ref()
66 .and_then(|p| p.name.as_ref())
67 .cloned()
68 .unwrap_or_else(|| module_path.clone());
69 let pkg_version = wasm_pkg
70 .as_ref()
71 .and_then(|p| p.version.as_ref())
72 .cloned()
73 .unwrap_or_else(|| "0.1.0".to_string());
74
75 files.push(GeneratedFile {
77 path: output_base.join("package.json"),
78 content: render_package_json(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
79 generated_header: false,
80 });
81
82 files.push(GeneratedFile {
84 path: output_base.join("vitest.config.ts"),
85 content: render_vitest_config(),
86 generated_header: true,
87 });
88
89 files.push(GeneratedFile {
91 path: output_base.join("globalSetup.ts"),
92 content: render_global_setup(),
93 generated_header: true,
94 });
95
96 files.push(GeneratedFile {
98 path: output_base.join("tsconfig.json"),
99 content: render_tsconfig(),
100 generated_header: false,
101 });
102
103 for group in groups {
105 let active: Vec<&Fixture> = group
106 .fixtures
107 .iter()
108 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
109 .collect();
110
111 if active.is_empty() {
112 continue;
113 }
114
115 let filename = format!("{}.test.ts", sanitize_filename(&group.category));
116 let field_resolver = FieldResolver::new(
117 &e2e_config.fields,
118 &e2e_config.fields_optional,
119 &e2e_config.result_fields,
120 &e2e_config.fields_array,
121 );
122 let content = render_test_file(
123 &group.category,
124 &active,
125 &pkg_name,
126 &function_name,
127 result_var,
128 is_async,
129 &e2e_config.call.args,
130 &field_resolver,
131 options_type.as_deref(),
132 enum_fields,
133 handle_config_type.as_deref(),
134 client_factory,
135 bigint_fields,
136 e2e_config,
137 );
138 files.push(GeneratedFile {
139 path: tests_base.join(filename),
140 content,
141 generated_header: true,
142 });
143 }
144
145 Ok(files)
146 }
147
148 fn language_name(&self) -> &'static str {
149 "wasm"
150 }
151}
152
153fn render_package_json(
154 pkg_name: &str,
155 pkg_path: &str,
156 pkg_version: &str,
157 dep_mode: crate::config::DependencyMode,
158) -> String {
159 let dep_value = match dep_mode {
160 crate::config::DependencyMode::Registry => pkg_version.to_string(),
161 crate::config::DependencyMode::Local => format!("file:{pkg_path}"),
162 };
163 format!(
164 r#"{{
165 "name": "{pkg_name}-e2e-wasm",
166 "version": "0.1.0",
167 "private": true,
168 "type": "module",
169 "scripts": {{
170 "test": "vitest run"
171 }},
172 "devDependencies": {{
173 "{pkg_name}": "{dep_value}",
174 "vite-plugin-top-level-await": "^1.4.0",
175 "vite-plugin-wasm": "^3.4.0",
176 "vitest": "^3.0.0"
177 }}
178}}
179"#
180 )
181}
182
183fn render_vitest_config() -> String {
184 r#"// This file is auto-generated by alef. DO NOT EDIT.
185import { defineConfig } from 'vitest/config';
186import wasm from 'vite-plugin-wasm';
187import topLevelAwait from 'vite-plugin-top-level-await';
188
189export default defineConfig({
190 plugins: [wasm(), topLevelAwait()],
191 test: {
192 include: ['tests/**/*.test.ts'],
193 globalSetup: './globalSetup.ts',
194 },
195});
196"#
197 .to_string()
198}
199
200fn render_global_setup() -> String {
201 r#"// This file is auto-generated by alef. DO NOT EDIT.
202import { spawn } from 'child_process';
203import { resolve } from 'path';
204
205let serverProcess;
206
207export async function setup() {
208 // Mock server binary must be pre-built (e.g. by CI or `cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release`)
209 serverProcess = spawn(
210 resolve(__dirname, '../rust/target/release/mock-server'),
211 [resolve(__dirname, '../../fixtures')],
212 { stdio: ['pipe', 'pipe', 'inherit'] }
213 );
214
215 const url = await new Promise((resolve, reject) => {
216 serverProcess.stdout.on('data', (data) => {
217 const match = data.toString().match(/MOCK_SERVER_URL=(.*)/);
218 if (match) resolve(match[1].trim());
219 });
220 setTimeout(() => reject(new Error('Mock server startup timeout')), 30000);
221 });
222
223 process.env.MOCK_SERVER_URL = url;
224}
225
226export async function teardown() {
227 if (serverProcess) {
228 serverProcess.stdin.end();
229 serverProcess.kill();
230 }
231}
232"#
233 .to_string()
234}
235
236fn render_tsconfig() -> String {
237 r#"{
238 "compilerOptions": {
239 "target": "ES2022",
240 "module": "ESNext",
241 "moduleResolution": "bundler",
242 "strict": true,
243 "strictNullChecks": false,
244 "esModuleInterop": true,
245 "skipLibCheck": true
246 },
247 "include": ["tests/**/*.ts", "vitest.config.ts"]
248}
249"#
250 .to_string()
251}
252
253#[allow(clippy::too_many_arguments)]
254fn render_test_file(
255 category: &str,
256 fixtures: &[&Fixture],
257 pkg_name: &str,
258 function_name: &str,
259 _result_var: &str,
260 _is_async: bool,
261 args: &[crate::config::ArgMapping],
262 field_resolver: &FieldResolver,
263 options_type: Option<&str>,
264 enum_fields: &HashMap<String, String>,
265 handle_config_type: Option<&str>,
266 client_factory: Option<&str>,
267 bigint_fields: &[String],
268 e2e_config: &E2eConfig,
269) -> String {
270 let mut out = String::new();
271 let _ = writeln!(out, "// This file is auto-generated by alef. DO NOT EDIT.");
272 let _ = writeln!(out, "import {{ describe, it, expect }} from 'vitest';");
273
274 let needs_options_import = options_type.is_some()
276 && fixtures.iter().any(|f| {
277 args.iter().any(|arg| {
278 if arg.arg_type != "json_object" {
279 return false;
280 }
281 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
282 let val = if field == "input" {
283 Some(&f.input)
284 } else {
285 f.input.get(field)
286 };
287 val.is_some_and(|v| !v.is_null())
288 })
289 });
290
291 let mut enum_imports: std::collections::BTreeSet<&String> = std::collections::BTreeSet::new();
293 if needs_options_import {
294 for fixture in fixtures {
295 for arg in args {
296 if arg.arg_type == "json_object" {
297 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
298 let val = if field == "input" {
299 Some(&fixture.input)
300 } else {
301 fixture.input.get(field)
302 };
303 if let Some(val) = val {
304 if let Some(obj) = val.as_object() {
305 for k in obj.keys() {
306 if let Some(enum_type) = enum_fields.get(k) {
307 enum_imports.insert(enum_type);
308 }
309 }
310 }
311 }
312 }
313 }
314 }
315 }
316
317 let handle_constructors: Vec<String> = args
319 .iter()
320 .filter(|arg| arg.arg_type == "handle")
321 .map(|arg| format!("create{}", arg.name.to_upper_camel_case()))
322 .collect();
323
324 {
325 let mut imports: Vec<String> = if client_factory.is_some() {
326 vec![]
328 } else {
329 vec![function_name.to_string()]
330 };
331
332 for fixture in fixtures {
334 if fixture.call.is_some() {
335 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
336 let fixture_fn = resolve_wasm_function_name(call_config);
337 if client_factory.is_none() && !imports.contains(&fixture_fn) {
338 imports.push(fixture_fn);
339 }
340 }
341 }
342
343 for fixture in fixtures {
345 for assertion in &fixture.assertions {
346 if assertion.assertion_type == "method_result" {
347 if let Some(method_name) = &assertion.method {
348 if let Some(helper_fn) = wasm_method_helper_import(method_name) {
349 if !imports.contains(&helper_fn) {
350 imports.push(helper_fn);
351 }
352 }
353 }
354 }
355 }
356 }
357
358 if let Some(factory) = client_factory {
359 let camel = factory.to_lower_camel_case();
360 if !imports.contains(&camel) {
361 imports.push(camel);
362 }
363 }
364 imports.extend(handle_constructors);
365 if let (true, Some(opts_type)) = (needs_options_import, options_type) {
366 imports.push(opts_type.to_string());
367 imports.extend(enum_imports.iter().map(|s| s.to_string()));
368 }
369 if let Some(hct) = handle_config_type {
371 if !imports.contains(&hct.to_string()) {
372 imports.push(hct.to_string());
373 }
374 }
375 let _ = writeln!(out, "import {{ {} }} from '{pkg_name}';", imports.join(", "));
376 }
377 let _ = writeln!(out);
378 let _ = writeln!(out, "describe('{category}', () => {{");
379
380 for (i, fixture) in fixtures.iter().enumerate() {
381 render_test_case(
382 &mut out,
383 fixture,
384 field_resolver,
385 options_type,
386 enum_fields,
387 handle_config_type,
388 client_factory,
389 bigint_fields,
390 e2e_config,
391 );
392 if i + 1 < fixtures.len() {
393 let _ = writeln!(out);
394 }
395 }
396
397 let _ = writeln!(out, "}});");
398 out
399}
400
401fn resolve_wasm_function_name(call_config: &crate::config::CallConfig) -> String {
403 call_config
404 .overrides
405 .get("wasm")
406 .and_then(|o| o.function.clone())
407 .unwrap_or_else(|| call_config.function.clone())
408}
409
410fn wasm_method_helper_import(method_name: &str) -> Option<String> {
413 match method_name {
414 "has_error_nodes" => Some("treeHasErrorNodes".to_string()),
415 "error_count" | "tree_error_count" => Some("treeErrorCount".to_string()),
416 "tree_to_sexp" => Some("treeToSexp".to_string()),
417 "contains_node_type" => Some("treeContainsNodeType".to_string()),
418 "find_nodes_by_type" => Some("findNodesByType".to_string()),
419 "run_query" => Some("runQuery".to_string()),
420 _ => None,
423 }
424}
425
426#[allow(clippy::too_many_arguments)]
427fn render_test_case(
428 out: &mut String,
429 fixture: &Fixture,
430 field_resolver: &FieldResolver,
431 options_type: Option<&str>,
432 enum_fields: &HashMap<String, String>,
433 handle_config_type: Option<&str>,
434 client_factory: Option<&str>,
435 bigint_fields: &[String],
436 e2e_config: &E2eConfig,
437) {
438 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
440 let resolved_function_name = resolve_wasm_function_name(call_config);
441 let function_name = resolved_function_name.as_str();
442 let resolved_result_var = call_config.result_var.clone();
443 let result_var = resolved_result_var.as_str();
444 let is_async = call_config.r#async;
445 let args = &call_config.args;
446
447 let test_name = sanitize_ident(&fixture.id);
448 let description = fixture.description.replace('\'', "\\'");
449 let async_kw = if is_async { "async " } else { "" };
450 let await_kw = if is_async { "await " } else { "" };
451
452 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
453 let (mut setup_lines, arg_parts) = build_args_and_setup(
454 &fixture.input,
455 args,
456 options_type,
457 enum_fields,
458 &fixture.id,
459 handle_config_type,
460 bigint_fields,
461 );
462 let args_str = arg_parts.join(", ");
463
464 let mut visitor_arg = String::new();
466 if let Some(visitor_spec) = &fixture.visitor {
467 visitor_arg = build_wasm_visitor(&mut setup_lines, visitor_spec);
468 }
469
470 let final_args = if visitor_arg.is_empty() {
471 args_str
472 } else if args_str.is_empty() {
473 format!("{{ visitor: {visitor_arg} }}")
474 } else {
475 format!("{args_str}, {{ visitor: {visitor_arg} }}")
476 };
477
478 let call_expr = if client_factory.is_some() {
480 format!("client.{function_name}({final_args})")
481 } else {
482 format!("{function_name}({final_args})")
483 };
484
485 let has_base_url_arg = args.iter().any(|arg| arg.arg_type == "base_url");
487 let base_url_expr = if has_base_url_arg {
488 format!("`${{process.env.MOCK_SERVER_URL}}/fixtures/{}`", fixture.id)
489 } else {
490 "process.env.MOCK_SERVER_URL".to_string()
491 };
492
493 if expects_error {
494 let _ = writeln!(out, " it('{test_name}: {description}', {async_kw}() => {{");
495 if let Some(factory) = client_factory {
496 let factory_camel = factory.to_lower_camel_case();
497 let _ = writeln!(
498 out,
499 " const client = {await_kw}{factory_camel}('test-key', {base_url_expr});"
500 );
501 }
502 for line in &setup_lines {
503 let _ = writeln!(out, " {line}");
504 }
505 if is_async {
506 let _ = writeln!(
507 out,
508 " await expect({async_kw}() => {await_kw}{call_expr}).rejects.toThrow();"
509 );
510 } else {
511 let _ = writeln!(out, " expect(() => {call_expr}).toThrow();");
512 }
513 let _ = writeln!(out, " }});");
514 return;
515 }
516
517 let _ = writeln!(out, " it('{test_name}: {description}', {async_kw}() => {{");
518 if let Some(factory) = client_factory {
519 let factory_camel = factory.to_lower_camel_case();
520 let _ = writeln!(
521 out,
522 " const client = {await_kw}{factory_camel}('test-key', {base_url_expr});"
523 );
524 }
525 for line in &setup_lines {
526 let _ = writeln!(out, " {line}");
527 }
528
529 let has_usable_assertion = fixture.assertions.iter().any(|a| {
530 if a.assertion_type == "not_error" || a.assertion_type == "error" {
531 return false;
532 }
533 match &a.field {
534 Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
535 _ => true,
536 }
537 });
538
539 if has_usable_assertion {
540 let _ = writeln!(out, " const {result_var} = {await_kw}{call_expr};");
541 } else {
542 let _ = writeln!(out, " {await_kw}{call_expr};");
543 }
544
545 for assertion in &fixture.assertions {
546 render_assertion(out, assertion, result_var, field_resolver);
547 }
548
549 let _ = writeln!(out, " }});");
550}
551
552fn build_args_and_setup(
557 input: &serde_json::Value,
558 args: &[crate::config::ArgMapping],
559 options_type: Option<&str>,
560 enum_fields: &HashMap<String, String>,
561 fixture_id: &str,
562 handle_config_type: Option<&str>,
563 bigint_fields: &[String],
564) -> (Vec<String>, Vec<String>) {
565 let mut setup_lines = Vec::new();
566 let mut parts = Vec::new();
567
568 if args.is_empty() {
569 parts.push(json_to_js(input));
570 return (setup_lines, parts);
571 }
572
573 for arg in args {
574 if arg.arg_type == "mock_url" {
575 setup_lines.push(format!(
576 "const {} = `${{process.env.MOCK_SERVER_URL}}/fixtures/{fixture_id}`;",
577 arg.name,
578 ));
579 parts.push(arg.name.clone());
580 continue;
581 }
582
583 if arg.arg_type == "base_url" {
584 setup_lines.push(format!(
588 "const {} = `${{process.env.MOCK_SERVER_URL}}/fixtures/{fixture_id}`;",
589 arg.name,
590 ));
591 parts.push(arg.name.clone());
592 continue;
593 }
594
595 if arg.arg_type == "handle" {
596 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
597 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
598 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
599 if config_value.is_null()
600 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
601 {
602 setup_lines.push(format!("const {} = {constructor_name}(null);", arg.name));
603 } else if let (Some(hct), Some(obj)) = (handle_config_type, config_value.as_object()) {
604 let config_var = format!("{}Config", arg.name);
607 setup_lines.push(format!("const {config_var} = new {hct}();"));
608 for (k, field_val) in obj {
609 let camel_key = k.to_lower_camel_case();
610 let js_val = json_to_js(field_val);
611 setup_lines.push(format!("{config_var}.{camel_key} = {js_val};"));
612 }
613 setup_lines.push(format!("const {} = {constructor_name}({config_var});", arg.name));
614 } else {
615 let js_val = json_to_js(config_value);
616 setup_lines.push(format!("const {} = {constructor_name}({js_val});", arg.name));
617 }
618 parts.push(arg.name.clone());
619 continue;
620 }
621
622 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
624 let val = if field == "input" {
625 Some(input)
626 } else {
627 input.get(field)
628 };
629 match val {
630 None | Some(serde_json::Value::Null) if arg.optional => continue,
631 None | Some(serde_json::Value::Null) => {
632 let default_val = match arg.arg_type.as_str() {
633 "string" => "''".to_string(),
634 "int" | "integer" => "0".to_string(),
635 "float" | "number" => "0.0".to_string(),
636 "bool" | "boolean" => "false".to_string(),
637 _ => "null".to_string(),
638 };
639 parts.push(default_val);
640 }
641 Some(v) => {
642 if arg.arg_type == "json_object" && !v.is_null() {
643 if let Some(opts_type) = options_type {
644 if let Some(obj) = v.as_object() {
645 setup_lines.push(format!("const options = new {opts_type}();"));
646 for (k, field_val) in obj {
647 let camel_key = k.to_lower_camel_case();
648 let js_val = if let Some(enum_type) = enum_fields.get(k) {
649 if let Some(s) = field_val.as_str() {
650 let pascal_val = s.to_upper_camel_case();
651 format!("{enum_type}.{pascal_val}")
652 } else {
653 json_to_js(field_val)
654 }
655 } else if bigint_fields.iter().any(|f| f == &camel_key) && field_val.is_number() {
656 format!("BigInt({})", json_to_js(field_val))
657 } else {
658 json_to_js(field_val)
659 };
660 setup_lines.push(format!("options.{camel_key} = {js_val};"));
661 }
662 parts.push("options".to_string());
663 continue;
664 }
665 }
666 }
667 parts.push(json_to_js(v));
668 }
669 }
670 }
671
672 (setup_lines, parts)
673}
674
675fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
676 if let Some(f) = &assertion.field {
678 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
679 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
680 return;
681 }
682 }
683
684 let field_expr = match &assertion.field {
685 Some(f) if !f.is_empty() => field_resolver.accessor(f, "wasm", result_var),
686 _ => result_var.to_string(),
687 };
688
689 match assertion.assertion_type.as_str() {
690 "equals" => {
691 if let Some(expected) = &assertion.value {
692 let js_val = json_to_js(expected);
693 if expected.is_string() {
694 let _ = writeln!(out, " expect({field_expr}.trim()).toBe({js_val});");
695 } else {
696 let _ = writeln!(out, " expect({field_expr}).toBe({js_val});");
697 }
698 }
699 }
700 "contains" => {
701 if let Some(expected) = &assertion.value {
702 let js_val = json_to_js(expected);
703 let _ = writeln!(out, " expect({field_expr}).toContain({js_val});");
704 }
705 }
706 "contains_all" => {
707 if let Some(values) = &assertion.values {
708 for val in values {
709 let js_val = json_to_js(val);
710 let _ = writeln!(out, " expect({field_expr}).toContain({js_val});");
711 }
712 }
713 }
714 "not_contains" => {
715 if let Some(expected) = &assertion.value {
716 let js_val = json_to_js(expected);
717 let _ = writeln!(out, " expect({field_expr}).not.toContain({js_val});");
718 }
719 }
720 "not_empty" => {
721 let _ = writeln!(out, " expect({field_expr}.length).toBeGreaterThan(0);");
722 }
723 "is_empty" => {
724 let _ = writeln!(out, " expect({field_expr}.trim()).toHaveLength(0);");
725 }
726 "contains_any" => {
727 if let Some(values) = &assertion.values {
728 let items: Vec<String> = values.iter().map(json_to_js).collect();
729 let arr_str = items.join(", ");
730 let _ = writeln!(
731 out,
732 " expect([{arr_str}].some((v) => {field_expr}.includes(v))).toBe(true);"
733 );
734 }
735 }
736 "greater_than" => {
737 if let Some(val) = &assertion.value {
738 let js_val = json_to_js(val);
739 let _ = writeln!(out, " expect({field_expr}).toBeGreaterThan({js_val});");
740 }
741 }
742 "less_than" => {
743 if let Some(val) = &assertion.value {
744 let js_val = json_to_js(val);
745 let _ = writeln!(out, " expect({field_expr}).toBeLessThan({js_val});");
746 }
747 }
748 "greater_than_or_equal" => {
749 if let Some(val) = &assertion.value {
750 let js_val = json_to_js(val);
751 let _ = writeln!(out, " expect({field_expr}).toBeGreaterThanOrEqual({js_val});");
752 }
753 }
754 "less_than_or_equal" => {
755 if let Some(val) = &assertion.value {
756 let js_val = json_to_js(val);
757 let _ = writeln!(out, " expect({field_expr}).toBeLessThanOrEqual({js_val});");
758 }
759 }
760 "starts_with" => {
761 if let Some(expected) = &assertion.value {
762 let js_val = json_to_js(expected);
763 let _ = writeln!(out, " expect({field_expr}.startsWith({js_val})).toBe(true);");
764 }
765 }
766 "count_min" => {
767 if let Some(val) = &assertion.value {
768 if let Some(n) = val.as_u64() {
769 let _ = writeln!(out, " expect({field_expr}.length).toBeGreaterThanOrEqual({n});");
770 }
771 }
772 }
773 "count_equals" => {
774 if let Some(val) = &assertion.value {
775 if let Some(n) = val.as_u64() {
776 let _ = writeln!(out, " expect({field_expr}.length).toBe({n});");
777 }
778 }
779 }
780 "is_true" => {
781 let _ = writeln!(out, " expect({field_expr}).toBe(true);");
782 }
783 "is_false" => {
784 let _ = writeln!(out, " expect({field_expr}).toBe(false);");
785 }
786 "method_result" => {
787 if let Some(method_name) = &assertion.method {
788 let call_expr = build_wasm_method_call(result_var, method_name, assertion.args.as_ref());
789 let check = assertion.check.as_deref().unwrap_or("is_true");
790 match check {
791 "equals" => {
792 if let Some(val) = &assertion.value {
793 let js_val = json_to_js(val);
794 let _ = writeln!(out, " expect({call_expr}).toBe({js_val});");
795 }
796 }
797 "is_true" => {
798 let _ = writeln!(out, " expect({call_expr}).toBe(true);");
799 }
800 "is_false" => {
801 let _ = writeln!(out, " expect({call_expr}).toBe(false);");
802 }
803 "greater_than_or_equal" => {
804 if let Some(val) = &assertion.value {
805 let n = val.as_u64().unwrap_or(0);
806 let _ = writeln!(out, " expect({call_expr}).toBeGreaterThanOrEqual({n});");
807 }
808 }
809 "count_min" => {
810 if let Some(val) = &assertion.value {
811 let n = val.as_u64().unwrap_or(0);
812 let _ = writeln!(out, " expect({call_expr}.length).toBeGreaterThanOrEqual({n});");
813 }
814 }
815 "contains" => {
816 if let Some(val) = &assertion.value {
817 let js_val = json_to_js(val);
818 let _ = writeln!(out, " expect({call_expr}).toContain({js_val});");
819 }
820 }
821 "is_error" => {
822 let _ = writeln!(out, " expect(() => {{ {call_expr}; }}).toThrow();");
823 }
824 other_check => {
825 panic!("WASM e2e generator: unsupported method_result check type: {other_check}");
826 }
827 }
828 } else {
829 panic!("WASM e2e generator: method_result assertion missing 'method' field");
830 }
831 }
832 "not_error" => {
833 }
835 "error" => {
836 }
838 other => {
839 panic!("WASM e2e generator: unsupported assertion type: {other}");
840 }
841 }
842}
843
844fn build_wasm_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
847 match method_name {
848 "root_child_count" => format!("{result_var}.rootNode.childCount"),
849 "root_node_type" => format!("{result_var}.rootNode.type"),
850 "named_children_count" => format!("{result_var}.rootNode.namedChildCount"),
851 "has_error_nodes" => format!("treeHasErrorNodes({result_var})"),
852 "error_count" | "tree_error_count" => format!("treeErrorCount({result_var})"),
853 "tree_to_sexp" => format!("treeToSexp({result_var})"),
854 "contains_node_type" => {
855 let node_type = args
856 .and_then(|a| a.get("node_type"))
857 .and_then(|v| v.as_str())
858 .unwrap_or("");
859 format!("treeContainsNodeType({result_var}, \"{node_type}\")")
860 }
861 "find_nodes_by_type" => {
862 let node_type = args
863 .and_then(|a| a.get("node_type"))
864 .and_then(|v| v.as_str())
865 .unwrap_or("");
866 format!("findNodesByType({result_var}, \"{node_type}\")")
867 }
868 "run_query" => {
869 let query_source = args
870 .and_then(|a| a.get("query_source"))
871 .and_then(|v| v.as_str())
872 .unwrap_or("");
873 let language = args
874 .and_then(|a| a.get("language"))
875 .and_then(|v| v.as_str())
876 .unwrap_or("");
877 format!("runQuery({result_var}, \"{language}\", \"{query_source}\", source)")
878 }
879 other => {
880 let camel_method = other.to_lower_camel_case();
881 if let Some(args_val) = args {
882 let arg_str = args_val
883 .as_object()
884 .map(|obj| {
885 obj.iter()
886 .map(|(k, v)| format!("{}: {}", k, json_to_js(v)))
887 .collect::<Vec<_>>()
888 .join(", ")
889 })
890 .unwrap_or_default();
891 format!("{result_var}.{camel_method}({arg_str})")
892 } else {
893 format!("{result_var}.{camel_method}()")
894 }
895 }
896 }
897}
898
899fn json_to_js(value: &serde_json::Value) -> String {
901 match value {
902 serde_json::Value::String(s) => format!("\"{}\"", escape_js(s)),
903 serde_json::Value::Bool(b) => b.to_string(),
904 serde_json::Value::Number(n) => n.to_string(),
905 serde_json::Value::Null => "null".to_string(),
906 serde_json::Value::Array(arr) => {
907 let items: Vec<String> = arr.iter().map(json_to_js).collect();
908 format!("[{}]", items.join(", "))
909 }
910 serde_json::Value::Object(map) => {
911 let entries: Vec<String> = map
912 .iter()
913 .map(|(k, v)| {
914 let key = if k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
915 && !k.starts_with(|c: char| c.is_ascii_digit())
916 {
917 k.clone()
918 } else {
919 format!("\"{}\"", escape_js(k))
920 };
921 format!("{key}: {}", json_to_js(v))
922 })
923 .collect();
924 format!("{{ {} }}", entries.join(", "))
925 }
926 }
927}
928
929fn build_wasm_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
931 use std::fmt::Write as FmtWrite;
932 let mut visitor_obj = String::new();
933 let _ = writeln!(visitor_obj, "{{");
934 for (method_name, action) in &visitor_spec.callbacks {
935 emit_wasm_visitor_method(&mut visitor_obj, method_name, action);
936 }
937 let _ = writeln!(visitor_obj, " }}");
938
939 setup_lines.push(format!("const _testVisitor = {visitor_obj}"));
940 "_testVisitor".to_string()
941}
942
943fn emit_wasm_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
945 use std::fmt::Write as FmtWrite;
946
947 let camel_method = to_camel_case_wasm(method_name);
948 let params = match method_name {
949 "visit_link" => "ctx, href, text, title",
950 "visit_image" => "ctx, src, alt, title",
951 "visit_heading" => "ctx, level, text, id",
952 "visit_code_block" => "ctx, lang, code",
953 "visit_code_inline"
954 | "visit_strong"
955 | "visit_emphasis"
956 | "visit_strikethrough"
957 | "visit_underline"
958 | "visit_subscript"
959 | "visit_superscript"
960 | "visit_mark"
961 | "visit_button"
962 | "visit_summary"
963 | "visit_figcaption"
964 | "visit_definition_term"
965 | "visit_definition_description" => "ctx, text",
966 "visit_text" => "ctx, text",
967 "visit_list_item" => "ctx, ordered, marker, text",
968 "visit_blockquote" => "ctx, content, depth",
969 "visit_table_row" => "ctx, cells, isHeader",
970 "visit_custom_element" => "ctx, tagName, html",
971 "visit_form" => "ctx, actionUrl, method",
972 "visit_input" => "ctx, inputType, name, value",
973 "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
974 "visit_details" => "ctx, isOpen",
975 _ => "ctx",
976 };
977
978 let _ = writeln!(out, " {camel_method}({params}): string | {{ custom: string }} {{");
979 match action {
980 CallbackAction::Skip => {
981 let _ = writeln!(out, " return \"skip\";");
982 }
983 CallbackAction::Continue => {
984 let _ = writeln!(out, " return \"continue\";");
985 }
986 CallbackAction::PreserveHtml => {
987 let _ = writeln!(out, " return \"preserve_html\";");
988 }
989 CallbackAction::Custom { output } => {
990 let escaped = escape_js(output);
991 let _ = writeln!(out, " return {{ custom: {escaped} }};");
992 }
993 CallbackAction::CustomTemplate { template } => {
994 let _ = writeln!(out, " return {{ custom: `{template}` }};");
995 }
996 }
997 let _ = writeln!(out, " }},");
998}
999
1000fn to_camel_case_wasm(snake: &str) -> String {
1002 use heck::ToLowerCamelCase;
1003 snake.to_lower_camel_case()
1004}