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