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 "not_error" => {
836 }
838 "error" => {
839 }
841 other => {
842 panic!("WASM e2e generator: unsupported assertion type: {other}");
843 }
844 }
845}
846
847fn build_wasm_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
850 match method_name {
851 "root_child_count" => format!("{result_var}.rootNode.childCount"),
852 "root_node_type" => format!("{result_var}.rootNode.type"),
853 "named_children_count" => format!("{result_var}.rootNode.namedChildCount"),
854 "has_error_nodes" => format!("treeHasErrorNodes({result_var})"),
855 "error_count" | "tree_error_count" => format!("treeErrorCount({result_var})"),
856 "tree_to_sexp" => format!("treeToSexp({result_var})"),
857 "contains_node_type" => {
858 let node_type = args
859 .and_then(|a| a.get("node_type"))
860 .and_then(|v| v.as_str())
861 .unwrap_or("");
862 format!("treeContainsNodeType({result_var}, \"{node_type}\")")
863 }
864 "find_nodes_by_type" => {
865 let node_type = args
866 .and_then(|a| a.get("node_type"))
867 .and_then(|v| v.as_str())
868 .unwrap_or("");
869 format!("findNodesByType({result_var}, \"{node_type}\")")
870 }
871 "run_query" => {
872 let query_source = args
873 .and_then(|a| a.get("query_source"))
874 .and_then(|v| v.as_str())
875 .unwrap_or("");
876 let language = args
877 .and_then(|a| a.get("language"))
878 .and_then(|v| v.as_str())
879 .unwrap_or("");
880 format!("runQuery({result_var}, \"{language}\", \"{query_source}\", source)")
881 }
882 other => {
883 let camel_method = other.to_lower_camel_case();
884 if let Some(args_val) = args {
885 let arg_str = args_val
886 .as_object()
887 .map(|obj| {
888 obj.iter()
889 .map(|(k, v)| format!("{}: {}", k, json_to_js(v)))
890 .collect::<Vec<_>>()
891 .join(", ")
892 })
893 .unwrap_or_default();
894 format!("{result_var}.{camel_method}({arg_str})")
895 } else {
896 format!("{result_var}.{camel_method}()")
897 }
898 }
899 }
900}
901
902fn json_to_js(value: &serde_json::Value) -> String {
904 match value {
905 serde_json::Value::String(s) => format!("\"{}\"", escape_js(s)),
906 serde_json::Value::Bool(b) => b.to_string(),
907 serde_json::Value::Number(n) => n.to_string(),
908 serde_json::Value::Null => "null".to_string(),
909 serde_json::Value::Array(arr) => {
910 let items: Vec<String> = arr.iter().map(json_to_js).collect();
911 format!("[{}]", items.join(", "))
912 }
913 serde_json::Value::Object(map) => {
914 let entries: Vec<String> = map
915 .iter()
916 .map(|(k, v)| {
917 let key = if k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
918 && !k.starts_with(|c: char| c.is_ascii_digit())
919 {
920 k.clone()
921 } else {
922 format!("\"{}\"", escape_js(k))
923 };
924 format!("{key}: {}", json_to_js(v))
925 })
926 .collect();
927 format!("{{ {} }}", entries.join(", "))
928 }
929 }
930}
931
932fn build_wasm_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
934 use std::fmt::Write as FmtWrite;
935 let mut visitor_obj = String::new();
936 let _ = writeln!(visitor_obj, "{{");
937 for (method_name, action) in &visitor_spec.callbacks {
938 emit_wasm_visitor_method(&mut visitor_obj, method_name, action);
939 }
940 let _ = writeln!(visitor_obj, " }}");
941
942 setup_lines.push(format!("const _testVisitor = {visitor_obj}"));
943 "_testVisitor".to_string()
944}
945
946fn emit_wasm_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
948 use std::fmt::Write as FmtWrite;
949
950 let camel_method = to_camel_case_wasm(method_name);
951 let params = match method_name {
952 "visit_link" => "ctx, href, text, title",
953 "visit_image" => "ctx, src, alt, title",
954 "visit_heading" => "ctx, level, text, id",
955 "visit_code_block" => "ctx, lang, code",
956 "visit_code_inline"
957 | "visit_strong"
958 | "visit_emphasis"
959 | "visit_strikethrough"
960 | "visit_underline"
961 | "visit_subscript"
962 | "visit_superscript"
963 | "visit_mark"
964 | "visit_button"
965 | "visit_summary"
966 | "visit_figcaption"
967 | "visit_definition_term"
968 | "visit_definition_description" => "ctx, text",
969 "visit_text" => "ctx, text",
970 "visit_list_item" => "ctx, ordered, marker, text",
971 "visit_blockquote" => "ctx, content, depth",
972 "visit_table_row" => "ctx, cells, isHeader",
973 "visit_custom_element" => "ctx, tagName, html",
974 "visit_form" => "ctx, actionUrl, method",
975 "visit_input" => "ctx, inputType, name, value",
976 "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
977 "visit_details" => "ctx, isOpen",
978 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "ctx, output",
979 "visit_list_start" => "ctx, ordered",
980 "visit_list_end" => "ctx, ordered, output",
981 _ => "ctx",
982 };
983
984 let _ = writeln!(out, " {camel_method}({params}): string | {{ custom: string }} {{");
985 match action {
986 CallbackAction::Skip => {
987 let _ = writeln!(out, " return \"skip\";");
988 }
989 CallbackAction::Continue => {
990 let _ = writeln!(out, " return \"continue\";");
991 }
992 CallbackAction::PreserveHtml => {
993 let _ = writeln!(out, " return \"preserve_html\";");
994 }
995 CallbackAction::Custom { output } => {
996 let escaped = escape_js(output);
997 let _ = writeln!(out, " return {{ custom: {escaped} }};");
998 }
999 CallbackAction::CustomTemplate { template } => {
1000 let _ = writeln!(out, " return {{ custom: `{template}` }};");
1001 }
1002 }
1003 let _ = writeln!(out, " }},");
1004}
1005
1006fn to_camel_case_wasm(snake: &str) -> String {
1008 use heck::ToLowerCamelCase;
1009 snake.to_lower_camel_case()
1010}