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