1use crate::config::E2eConfig;
12use crate::escape::sanitize_filename;
13
14use crate::fixture::{Fixture, FixtureGroup};
15use alef_core::backend::GeneratedFile;
16use alef_core::config::ResolvedCrateConfig;
17use alef_core::hash::{self, CommentStyle};
18use alef_core::template_versions as tv;
19use anyhow::Result;
20use std::fmt::Write as FmtWrite;
21use std::path::PathBuf;
22
23use super::E2eCodegen;
24
25pub struct WasmCodegen;
27
28impl E2eCodegen for WasmCodegen {
29 fn generate(
30 &self,
31 groups: &[FixtureGroup],
32 e2e_config: &E2eConfig,
33 config: &ResolvedCrateConfig,
34 type_defs: &[alef_core::ir::TypeDef],
35 enums: &[alef_core::ir::EnumDef],
36 ) -> Result<Vec<GeneratedFile>> {
37 let lang = self.language_name();
38 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
39 let tests_base = output_base.join("tests");
40
41 let mut files = Vec::new();
42
43 let call = &e2e_config.call;
45 let overrides = call.overrides.get(lang);
46 let module_path = overrides
47 .and_then(|o| o.module.as_ref())
48 .cloned()
49 .unwrap_or_else(|| call.module.clone());
50 let function_name = overrides
51 .and_then(|o| o.function.as_ref())
52 .cloned()
53 .unwrap_or_else(|| snake_to_camel(&call.function));
54 let client_factory = overrides.and_then(|o| o.client_factory.as_deref());
55
56 let wasm_pkg = e2e_config.resolve_package("wasm");
62 let (pkg_path, pkg_path_is_explicit) = match wasm_pkg.as_ref().and_then(|p| p.path.as_ref()) {
70 Some(p) => (p.clone(), true),
71 None => (config.wasm_crate_path(), false),
72 };
73 let pkg_name = wasm_pkg
74 .as_ref()
75 .and_then(|p| p.name.as_ref())
76 .cloned()
77 .unwrap_or_else(|| {
78 module_path.clone()
84 });
85 let pkg_version = wasm_pkg
86 .as_ref()
87 .and_then(|p| p.version.as_ref())
88 .cloned()
89 .or_else(|| config.resolved_version())
90 .unwrap_or_else(|| "0.1.0".to_string());
91
92 let wasm_languages = config.wasm.as_ref().and_then(|w| {
100 if w.languages.is_empty() {
101 None
102 } else {
103 Some(w.languages.clone())
104 }
105 });
106
107 let active_per_group: Vec<Vec<Fixture>> = groups
112 .iter()
113 .map(|group| {
114 let mut result = Vec::new();
115 for fixture in &group.fixtures {
116 let mut base_include = super::should_include_fixture(fixture, lang, e2e_config);
119
120 if base_include {
128 if let Some(ref wasm_langs) = wasm_languages {
129 let fix_lang = fixture.input.get("language").and_then(|v| v.as_str()).or_else(|| {
134 fixture
135 .input
136 .get("config")
137 .and_then(|c| c.get("language"))
138 .and_then(|v| v.as_str())
139 });
140 if let Some(fix_lang) = fix_lang {
141 if !wasm_langs.iter().any(|l| l == fix_lang) {
142 base_include = false;
143 }
144 }
145 }
146 }
147
148 let cc = e2e_config.resolve_call_for_fixture(
150 fixture.call.as_deref(),
151 &fixture.id,
152 &fixture.resolved_category(),
153 &fixture.tags,
154 &fixture.input,
155 );
156 if cc.skip_languages.iter().any(|l| l == lang) {
157 continue;
159 }
160
161 if base_include {
162 if let Some(http) = &fixture.http {
164 if http
165 .request
166 .headers
167 .iter()
168 .any(|(k, _)| k.eq_ignore_ascii_case("content-length"))
169 {
170 continue;
172 }
173 let m = http.request.method.to_ascii_uppercase();
174 if m == "TRACE" || m == "CONNECT" {
175 continue;
177 }
178 }
179
180 result.push(fixture.clone());
182 } else if let Some(ref wasm_langs) = wasm_languages {
183 let mut auto_skipped = fixture.clone();
187 if auto_skipped.skip.is_none() {
188 auto_skipped.skip = Some(crate::fixture::SkipDirective {
190 languages: vec!["wasm".to_string()],
191 reason: Some(format!(
192 "language not in WASM's static-compiled set: [{}]",
193 wasm_langs.join(", ")
194 )),
195 });
196 result.push(auto_skipped);
197 }
198 }
199 }
200 result
201 })
202 .collect();
203
204 let any_fixtures = active_per_group.iter().flat_map(|g| g.iter());
205 let has_http_fixtures = any_fixtures.clone().any(|f| f.needs_mock_server());
213 let has_file_fixtures = active_per_group.iter().flatten().any(|f| {
216 let cc = e2e_config.resolve_call_for_fixture(
217 f.call.as_deref(),
218 &f.id,
219 &f.resolved_category(),
220 &f.tags,
221 &f.input,
222 );
223 cc.args
224 .iter()
225 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
226 });
227
228 files.push(GeneratedFile {
231 path: output_base.join("package.json"),
232 content: render_package_json(
233 &pkg_name,
234 &pkg_path,
235 pkg_path_is_explicit,
236 &pkg_version,
237 e2e_config.dep_mode,
238 e2e_config.harness_extras.get("wasm"),
239 ),
240 generated_header: false,
241 });
242
243 let needs_global_setup = has_http_fixtures;
249 files.push(GeneratedFile {
250 path: output_base.join("vitest.config.ts"),
251 content: render_vitest_config(needs_global_setup, has_file_fixtures),
252 generated_header: true,
253 });
254
255 if needs_global_setup {
260 files.push(GeneratedFile {
261 path: output_base.join("globalSetup.ts"),
262 content: render_global_setup(),
263 generated_header: true,
264 });
265 }
266
267 if has_file_fixtures {
270 files.push(GeneratedFile {
271 path: output_base.join("setup.ts"),
272 content: render_file_setup(&e2e_config.test_documents_dir),
273 generated_header: true,
274 });
275 }
276
277 files.push(GeneratedFile {
280 path: output_base.join("tsconfig.json"),
281 content: render_tsconfig(),
282 generated_header: false,
283 });
284
285 files.push(GeneratedFile {
294 path: output_base.join("pnpm-workspace.yaml"),
295 content: "packages:\n - \".\"\n".to_string(),
296 generated_header: false,
297 });
298
299 let options_type = overrides.and_then(|o| o.options_type.clone());
301
302 let wasm_type_prefix = config.wasm_type_prefix();
309 for (group, active) in groups.iter().zip(active_per_group.iter()) {
310 if active.is_empty() {
311 continue;
312 }
313 let filename = format!("{}.test.ts", sanitize_filename(&group.category));
314 let active_refs: Vec<&Fixture> = active.iter().collect();
316 let content = super::typescript::render_test_file(
317 lang,
318 &group.category,
319 &active_refs,
320 &module_path,
321 &pkg_name,
322 &function_name,
323 &e2e_config.call.args,
324 options_type.as_deref(),
325 client_factory,
326 e2e_config,
327 type_defs,
328 enums,
329 &wasm_type_prefix,
330 );
331
332 let _ = (&pkg_path, &config.name); files.push(GeneratedFile {
342 path: tests_base.join(filename),
343 content,
344 generated_header: true,
345 });
346 }
347
348 Ok(files)
349 }
350
351 fn language_name(&self) -> &'static str {
352 "wasm"
353 }
354}
355
356fn snake_to_camel(s: &str) -> String {
357 let mut out = String::with_capacity(s.len());
358 let mut upper_next = false;
359 for ch in s.chars() {
360 if ch == '_' {
361 upper_next = true;
362 } else if upper_next {
363 out.push(ch.to_ascii_uppercase());
364 upper_next = false;
365 } else {
366 out.push(ch);
367 }
368 }
369 out
370}
371
372fn render_package_json(
373 pkg_name: &str,
374 pkg_path: &str,
375 pkg_path_is_explicit: bool,
376 pkg_version: &str,
377 dep_mode: crate::config::DependencyMode,
378 extras: Option<&alef_core::config::manifest_extras::ManifestExtras>,
379) -> String {
380 let dep_value = match dep_mode {
381 crate::config::DependencyMode::Registry => pkg_version.to_string(),
382 crate::config::DependencyMode::Local => {
391 if pkg_path_is_explicit {
392 format!("file:{pkg_path}")
393 } else {
394 format!("file:{pkg_path}/nodejs")
395 }
396 }
397 };
398 let rendered = crate::template_env::render(
399 "wasm/package.json.jinja",
400 minijinja::context! {
401 pkg_name => pkg_name,
402 dep_value => dep_value,
403 rollup => tv::npm::ROLLUP,
404 vitest => tv::npm::VITEST,
405 },
406 );
407 match extras {
408 Some(e) if !e.is_empty() => crate::codegen::typescript::config::inject_package_json_extras(&rendered, e),
409 _ => rendered,
410 }
411}
412
413fn render_vitest_config(with_global_setup: bool, with_file_setup: bool) -> String {
414 let header = hash::header(CommentStyle::DoubleSlash);
415 crate::template_env::render(
416 "wasm/vitest.config.ts.jinja",
417 minijinja::context! {
418 header => header,
419 with_global_setup => with_global_setup,
420 with_file_setup => with_file_setup,
421 },
422 )
423}
424
425fn render_file_setup(test_documents_dir: &str) -> String {
426 let header = hash::header(CommentStyle::DoubleSlash);
427 let mut out = header;
428 out.push_str("import { createRequire } from 'module';\n");
429 out.push_str("import { fileURLToPath } from 'url';\n");
430 out.push_str("import { dirname, join } from 'path';\n\n");
431 out.push_str("// Patch CommonJS `require('env')` and `require('wasi_snapshot_preview1')` to\n");
432 out.push_str("// return shim objects. wasm-pack `--target nodejs` emits bare `require()`\n");
433 out.push_str("// calls for these from getrandom/wasi transitives, but they are not real\n");
434 out.push_str("// Node modules — the WASM module imports them by name and the host is\n");
435 out.push_str("// expected to satisfy them. Patch Module._load BEFORE the wasm bundle is\n");
436 out.push_str("// imported by any test file.\n");
437 out.push_str("// Note: setupFiles run per-test-worker; vitest imports the test files\n");
438 out.push_str("// AFTER setupFiles complete, so this hook installs in time.\n");
439 out.push_str("{\n");
440 out.push_str(" const _require = createRequire(import.meta.url);\n");
441 out.push_str(" const Module = _require('module');\n");
442 out.push_str(" // env.system / env.mkstemp come from C-runtime calls embedded in some\n");
443 out.push_str(" // WASM-compiled deps (e.g. tesseract-wasm). Tests that don't exercise\n");
444 out.push_str(" // those paths only need the imports to be callable for module instantiation.\n");
445 out.push_str(" const env = {\n");
446 out.push_str(" system: (_cmd: number) => -1,\n");
447 out.push_str(" mkstemp: (_template: number) => -1,\n");
448 out.push_str(" };\n");
449 out.push_str(" // WASI shims. Critical: clock_time_get and random_get must produce realistic\n");
450 out.push_str(" // values — returning 0 for all clock calls causes WASM-side timing loops to\n");
451 out.push_str(" // spin forever (e.g. getrandom's spin-until-elapsed retry), and zero-filled\n");
452 out.push_str(" // random buffers can cause init loops in deps expecting non-zero entropy.\n");
453 out.push_str(" const _wasiMemoryView = (): DataView | null => {\n");
454 out.push_str(" // Imports are wired before the WASM is instantiated; the bundle stashes\n");
455 out.push_str(" // its instance on a runtime-known global once available. We try to grab\n");
456 out.push_str(" // it lazily so writes to wasm memory go to the right place.\n");
457 out.push_str(" const g = globalThis as unknown as { __alef_wasm_memory__?: WebAssembly.Memory };\n");
458 out.push_str(" return g.__alef_wasm_memory__ ? new DataView(g.__alef_wasm_memory__.buffer) : null;\n");
459 out.push_str(" };\n");
460 out.push_str(" const _cryptoFill = (buf: Uint8Array) => {\n");
461 out.push_str(" const c = globalThis.crypto;\n");
462 out.push_str(" if (c && typeof c.getRandomValues === 'function') c.getRandomValues(buf);\n");
463 out.push_str(" else for (let i = 0; i < buf.length; i++) buf[i] = Math.floor(Math.random() * 256);\n");
464 out.push_str(" };\n");
465 out.push_str(" const wasi_snapshot_preview1 = {\n");
466 out.push_str(" proc_exit: () => {},\n");
467 out.push_str(" environ_get: () => 0,\n");
468 out.push_str(" environ_sizes_get: (countOut: number, _sizeOut: number) => {\n");
469 out.push_str(" const v = _wasiMemoryView();\n");
470 out.push_str(" if (v) v.setUint32(countOut, 0, true);\n");
471 out.push_str(" return 0;\n");
472 out.push_str(" },\n");
473 out.push_str(" // WASI fd_write must update `nwritten_ptr` with the total bytes consumed,\n");
474 out.push_str(" // otherwise libc-style callers (e.g. tesseract-compiled-to-wasm fputs)\n");
475 out.push_str(" // see 0 of N bytes written and retry forever, hanging the host.\n");
476 out.push_str(" fd_write: (_fd: number, iovsPtr: number, iovsLen: number, nwrittenPtr: number) => {\n");
477 out.push_str(" const v = _wasiMemoryView();\n");
478 out.push_str(" if (!v) return 0;\n");
479 out.push_str(" let total = 0;\n");
480 out.push_str(" for (let i = 0; i < iovsLen; i++) {\n");
481 out.push_str(" const off = iovsPtr + i * 8;\n");
482 out.push_str(" total += v.getUint32(off + 4, true);\n");
483 out.push_str(" }\n");
484 out.push_str(" v.setUint32(nwrittenPtr, total, true);\n");
485 out.push_str(" return 0;\n");
486 out.push_str(" },\n");
487 out.push_str(" // Mirror fd_write: callers retry on partial reads. Reporting 0 bytes\n");
488 out.push_str(" // read (EOF) is fine; just make sure `nread_ptr` is written.\n");
489 out.push_str(" fd_read: (_fd: number, _iovsPtr: number, _iovsLen: number, nreadPtr: number) => {\n");
490 out.push_str(" const v = _wasiMemoryView();\n");
491 out.push_str(" if (v) v.setUint32(nreadPtr, 0, true);\n");
492 out.push_str(" return 0;\n");
493 out.push_str(" },\n");
494 out.push_str(" fd_seek: () => 0,\n");
495 out.push_str(" fd_close: () => 0,\n");
496 out.push_str(" fd_prestat_get: () => 8, // EBADF — no preopens.\n");
497 out.push_str(" fd_prestat_dir_name: () => 0,\n");
498 out.push_str(" fd_fdstat_get: () => 0,\n");
499 out.push_str(" fd_fdstat_set_flags: () => 0,\n");
500 out.push_str(" path_open: () => 44, // ENOENT.\n");
501 out.push_str(" path_create_directory: () => 0,\n");
502 out.push_str(" path_remove_directory: () => 0,\n");
503 out.push_str(" path_unlink_file: () => 0,\n");
504 out.push_str(" path_filestat_get: () => 44, // ENOENT.\n");
505 out.push_str(" path_rename: () => 0,\n");
506 out.push_str(" clock_time_get: (_clockId: number, _precision: bigint, timeOut: number) => {\n");
507 out.push_str(" const ns = BigInt(Date.now()) * 1_000_000n + BigInt(performance.now() | 0) % 1_000_000n;\n");
508 out.push_str(" const v = _wasiMemoryView();\n");
509 out.push_str(" if (v) v.setBigUint64(timeOut, ns, true);\n");
510 out.push_str(" return 0;\n");
511 out.push_str(" },\n");
512 out.push_str(" clock_res_get: (_clockId: number, resOut: number) => {\n");
513 out.push_str(" const v = _wasiMemoryView();\n");
514 out.push_str(" if (v) v.setBigUint64(resOut, 1_000n, true);\n");
515 out.push_str(" return 0;\n");
516 out.push_str(" },\n");
517 out.push_str(" random_get: (bufPtr: number, bufLen: number) => {\n");
518 out.push_str(" const g = globalThis as unknown as { __alef_wasm_memory__?: WebAssembly.Memory };\n");
519 out.push_str(" if (!g.__alef_wasm_memory__) return 0;\n");
520 out.push_str(" _cryptoFill(new Uint8Array(g.__alef_wasm_memory__.buffer, bufPtr, bufLen));\n");
521 out.push_str(" return 0;\n");
522 out.push_str(" },\n");
523 out.push_str(" args_get: () => 0,\n");
524 out.push_str(" args_sizes_get: (countOut: number, _sizeOut: number) => {\n");
525 out.push_str(" const v = _wasiMemoryView();\n");
526 out.push_str(" if (v) v.setUint32(countOut, 0, true);\n");
527 out.push_str(" return 0;\n");
528 out.push_str(" },\n");
529 out.push_str(" poll_oneoff: () => 0,\n");
530 out.push_str(" sched_yield: () => 0,\n");
531 out.push_str(" };\n");
532 out.push_str(" const _origResolve = Module._resolveFilename;\n");
533 out.push_str(" Module._resolveFilename = function(request: string, parent: unknown, ...rest: unknown[]) {\n");
534 out.push_str(" if (request === 'env' || request === 'wasi_snapshot_preview1') return request;\n");
535 out.push_str(" return _origResolve.call(this, request, parent, ...rest);\n");
536 out.push_str(" };\n");
537 out.push_str(" const _origLoad = Module._load;\n");
538 out.push_str(" Module._load = function(request: string, parent: unknown, ...rest: unknown[]) {\n");
539 out.push_str(" if (request === 'env') return env;\n");
540 out.push_str(" if (request === 'wasi_snapshot_preview1') return wasi_snapshot_preview1;\n");
541 out.push_str(" return _origLoad.call(this, request, parent, ...rest);\n");
542 out.push_str(" };\n");
543 out.push_str(" // Capture the WASM linear memory at instantiation time so the WASI shims\n");
544 out.push_str(" // can read/write into it. Without this, every shim that needs memory\n");
545 out.push_str(" // (fd_write nwritten, clock_time_get, random_get, etc.) silently no-ops\n");
546 out.push_str(" // and the host-side C runtime hangs in a retry loop.\n");
547 out.push_str(" const _OrigInstance = WebAssembly.Instance;\n");
548 out.push_str(" const PatchedInstance = function(this: WebAssembly.Instance, mod: WebAssembly.Module, imports?: WebAssembly.Imports) {\n");
549 out.push_str(" const inst = new _OrigInstance(mod, imports);\n");
550 out.push_str(" const exportsMem = (inst.exports as Record<string, unknown>).memory;\n");
551 out.push_str(" if (exportsMem instanceof WebAssembly.Memory) {\n");
552 out.push_str(" (globalThis as unknown as { __alef_wasm_memory__?: WebAssembly.Memory }).__alef_wasm_memory__ = exportsMem;\n");
553 out.push_str(" }\n");
554 out.push_str(" return inst;\n");
555 out.push_str(" } as unknown as typeof WebAssembly.Instance;\n");
556 out.push_str(" PatchedInstance.prototype = _OrigInstance.prototype;\n");
557 out.push_str(
558 " (WebAssembly as unknown as { Instance: typeof WebAssembly.Instance }).Instance = PatchedInstance;\n",
559 );
560 out.push_str("}\n\n");
561 out.push_str("// Change to the configured test-documents directory so that fixture file paths like\n");
562 out.push_str("// \"pdf/fake_memo.pdf\" resolve correctly when vitest runs from e2e/wasm/.\n");
563 out.push_str("// setup.ts lives in e2e/wasm/; the fixtures dir lives at the repository root,\n");
564 out.push_str("// two directories up: e2e/wasm/ -> e2e/ -> repo root.\n");
565 out.push_str("const __filename = fileURLToPath(import.meta.url);\n");
566 out.push_str("const __dirname = dirname(__filename);\n");
567 let _ = writeln!(
568 out,
569 "const testDocumentsDir = join(__dirname, '..', '..', '{test_documents_dir}');"
570 );
571 out.push_str("process.chdir(testDocumentsDir);\n");
572 out
573}
574
575fn render_global_setup() -> String {
576 let header = hash::header(CommentStyle::DoubleSlash);
577 crate::template_env::render(
578 "wasm/globalSetup.ts.jinja",
579 minijinja::context! {
580 header => header,
581 },
582 )
583}
584
585fn render_tsconfig() -> String {
586 crate::template_env::render("wasm/tsconfig.jinja", minijinja::context! {})
587}
588
589