1use crate::config::E2eConfig;
11use crate::escape::sanitize_filename;
12use crate::field_access::FieldResolver;
13use crate::fixture::{Fixture, FixtureGroup};
14use alef_core::backend::GeneratedFile;
15use alef_core::config::ResolvedCrateConfig;
16use alef_core::hash::{self, CommentStyle};
17use alef_core::template_versions as tv;
18use anyhow::Result;
19use std::fmt::Write as FmtWrite;
20use std::path::PathBuf;
21
22use super::E2eCodegen;
23
24pub struct WasmCodegen;
26
27impl E2eCodegen for WasmCodegen {
28 fn generate(
29 &self,
30 groups: &[FixtureGroup],
31 e2e_config: &E2eConfig,
32 config: &ResolvedCrateConfig,
33 type_defs: &[alef_core::ir::TypeDef],
34 ) -> Result<Vec<GeneratedFile>> {
35 let lang = self.language_name();
36 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
37 let tests_base = output_base.join("tests");
38
39 let mut files = Vec::new();
40
41 let call = &e2e_config.call;
43 let overrides = call.overrides.get(lang);
44 let module_path = overrides
45 .and_then(|o| o.module.as_ref())
46 .cloned()
47 .unwrap_or_else(|| call.module.clone());
48 let function_name = overrides
49 .and_then(|o| o.function.as_ref())
50 .cloned()
51 .unwrap_or_else(|| snake_to_camel(&call.function));
52 let client_factory = overrides.and_then(|o| o.client_factory.as_deref());
53
54 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(|| config.wasm_crate_path());
65 let pkg_name = wasm_pkg
66 .as_ref()
67 .and_then(|p| p.name.as_ref())
68 .cloned()
69 .unwrap_or_else(|| {
70 module_path.clone()
76 });
77 let pkg_version = wasm_pkg
78 .as_ref()
79 .and_then(|p| p.version.as_ref())
80 .cloned()
81 .or_else(|| config.resolved_version())
82 .unwrap_or_else(|| "0.1.0".to_string());
83
84 let active_per_group: Vec<Vec<&Fixture>> = groups
88 .iter()
89 .map(|group| {
90 group
91 .fixtures
92 .iter()
93 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
94 .filter(|f| {
99 let cc = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
100 !cc.skip_languages.iter().any(|l| l == lang)
101 })
102 .filter(|f| {
103 f.http.as_ref().is_none_or(|h| {
107 !h.request
108 .headers
109 .iter()
110 .any(|(k, _)| k.eq_ignore_ascii_case("content-length"))
111 })
112 })
113 .filter(|f| {
114 f.http.as_ref().is_none_or(|h| {
117 let m = h.request.method.to_ascii_uppercase();
118 m != "TRACE" && m != "CONNECT"
119 })
120 })
121 .collect()
122 })
123 .collect();
124
125 let any_fixtures = active_per_group.iter().flat_map(|g| g.iter());
126 let has_http_fixtures = any_fixtures.clone().any(|f| f.needs_mock_server());
134 let has_file_fixtures = active_per_group.iter().flatten().any(|f| {
137 let cc = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
138 cc.args
139 .iter()
140 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
141 });
142
143 files.push(GeneratedFile {
148 path: output_base.join("package.json"),
149 content: render_package_json(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
150 generated_header: false,
151 });
152
153 let needs_global_setup = has_http_fixtures;
160 files.push(GeneratedFile {
161 path: output_base.join("vitest.config.ts"),
162 content: render_vitest_config(needs_global_setup, has_file_fixtures),
163 generated_header: true,
164 });
165
166 if needs_global_setup {
171 files.push(GeneratedFile {
172 path: output_base.join("globalSetup.ts"),
173 content: render_global_setup(),
174 generated_header: true,
175 });
176 }
177
178 if has_file_fixtures {
181 files.push(GeneratedFile {
182 path: output_base.join("setup.ts"),
183 content: render_file_setup(&e2e_config.test_documents_dir),
184 generated_header: true,
185 });
186 }
187
188 files.push(GeneratedFile {
191 path: output_base.join("tsconfig.json"),
192 content: render_tsconfig(),
193 generated_header: false,
194 });
195
196 files.push(GeneratedFile {
207 path: output_base.join("pnpm-workspace.yaml"),
208 content: "packages:\n - \".\"\n".to_string(),
209 generated_header: false,
210 });
211
212 let options_type = overrides.and_then(|o| o.options_type.clone());
214 let field_resolver = FieldResolver::new(
215 &e2e_config.fields,
216 &e2e_config.fields_optional,
217 &e2e_config.result_fields,
218 &e2e_config.fields_array,
219 &std::collections::HashSet::new(),
220 );
221
222 for (group, active) in groups.iter().zip(active_per_group.iter()) {
229 if active.is_empty() {
230 continue;
231 }
232 let filename = format!("{}.test.ts", sanitize_filename(&group.category));
233 let content = super::typescript::render_test_file(
234 lang,
235 &group.category,
236 active,
237 &module_path,
238 &pkg_name,
239 &function_name,
240 &e2e_config.call.args,
241 options_type.as_deref(),
242 &field_resolver,
243 client_factory,
244 e2e_config,
245 type_defs,
246 );
247
248 let _ = (&pkg_path, &config.name); files.push(GeneratedFile {
258 path: tests_base.join(filename),
259 content,
260 generated_header: true,
261 });
262 }
263
264 Ok(files)
265 }
266
267 fn language_name(&self) -> &'static str {
268 "wasm"
269 }
270}
271
272fn snake_to_camel(s: &str) -> String {
273 let mut out = String::with_capacity(s.len());
274 let mut upper_next = false;
275 for ch in s.chars() {
276 if ch == '_' {
277 upper_next = true;
278 } else if upper_next {
279 out.push(ch.to_ascii_uppercase());
280 upper_next = false;
281 } else {
282 out.push(ch);
283 }
284 }
285 out
286}
287
288fn render_package_json(
289 pkg_name: &str,
290 pkg_path: &str,
291 pkg_version: &str,
292 dep_mode: crate::config::DependencyMode,
293) -> String {
294 let dep_value = match dep_mode {
295 crate::config::DependencyMode::Registry => pkg_version.to_string(),
296 crate::config::DependencyMode::Local => format!("file:{pkg_path}/nodejs"),
302 };
303 crate::template_env::render(
304 "wasm/package.json.jinja",
305 minijinja::context! {
306 pkg_name => pkg_name,
307 dep_value => dep_value,
308 rollup => tv::npm::ROLLUP,
309 vite_plugin_wasm => tv::npm::VITE_PLUGIN_WASM,
310 vitest => tv::npm::VITEST,
311 },
312 )
313}
314
315fn render_vitest_config(with_global_setup: bool, with_file_setup: bool) -> String {
316 let header = hash::header(CommentStyle::DoubleSlash);
317 crate::template_env::render(
318 "wasm/vitest.config.ts.jinja",
319 minijinja::context! {
320 header => header,
321 with_global_setup => with_global_setup,
322 with_file_setup => with_file_setup,
323 },
324 )
325}
326
327fn render_file_setup(test_documents_dir: &str) -> String {
328 let header = hash::header(CommentStyle::DoubleSlash);
329 let mut out = header;
330 out.push_str("import { createRequire } from 'module';\n");
331 out.push_str("import { fileURLToPath } from 'url';\n");
332 out.push_str("import { dirname, join } from 'path';\n\n");
333 out.push_str("// Patch CommonJS `require('env')` and `require('wasi_snapshot_preview1')` to\n");
334 out.push_str("// return shim objects. wasm-pack `--target nodejs` emits bare `require()`\n");
335 out.push_str("// calls for these from getrandom/wasi transitives, but they are not real\n");
336 out.push_str("// Node modules — the WASM module imports them by name and the host is\n");
337 out.push_str("// expected to satisfy them. Patch Module._load BEFORE the wasm bundle is\n");
338 out.push_str("// imported by any test file.\n");
339 out.push_str("// Note: setupFiles run per-test-worker; vitest imports the test files\n");
340 out.push_str("// AFTER setupFiles complete, so this hook installs in time.\n");
341 out.push_str("{\n");
342 out.push_str(" const _require = createRequire(import.meta.url);\n");
343 out.push_str(" const Module = _require('module');\n");
344 out.push_str(" // env.system / env.mkstemp come from C-runtime calls embedded in some\n");
345 out.push_str(" // WASM-compiled deps (e.g. tesseract-wasm). Tests that don't exercise\n");
346 out.push_str(" // those paths only need the imports to be callable for module instantiation.\n");
347 out.push_str(" const env = {\n");
348 out.push_str(" system: (_cmd: number) => -1,\n");
349 out.push_str(" mkstemp: (_template: number) => -1,\n");
350 out.push_str(" };\n");
351 out.push_str(" // WASI shims. Critical: clock_time_get and random_get must produce realistic\n");
352 out.push_str(" // values — returning 0 for all clock calls causes WASM-side timing loops to\n");
353 out.push_str(" // spin forever (e.g. getrandom's spin-until-elapsed retry), and zero-filled\n");
354 out.push_str(" // random buffers can cause init loops in deps expecting non-zero entropy.\n");
355 out.push_str(" const _wasiMemoryView = (): DataView | null => {\n");
356 out.push_str(" // Imports are wired before the WASM is instantiated; the bundle stashes\n");
357 out.push_str(" // its instance on a runtime-known global once available. We try to grab\n");
358 out.push_str(" // it lazily so writes to wasm memory go to the right place.\n");
359 out.push_str(" const g = globalThis as unknown as { __kreuzberg_wasm_memory__?: WebAssembly.Memory };\n");
360 out.push_str(" return g.__kreuzberg_wasm_memory__ ? new DataView(g.__kreuzberg_wasm_memory__.buffer) : null;\n");
361 out.push_str(" };\n");
362 out.push_str(" const _cryptoFill = (buf: Uint8Array) => {\n");
363 out.push_str(" const c = globalThis.crypto;\n");
364 out.push_str(" if (c && typeof c.getRandomValues === 'function') c.getRandomValues(buf);\n");
365 out.push_str(" else for (let i = 0; i < buf.length; i++) buf[i] = Math.floor(Math.random() * 256);\n");
366 out.push_str(" };\n");
367 out.push_str(" const wasi_snapshot_preview1 = {\n");
368 out.push_str(" proc_exit: () => {},\n");
369 out.push_str(" environ_get: () => 0,\n");
370 out.push_str(" environ_sizes_get: (countOut: number, _sizeOut: number) => {\n");
371 out.push_str(" const v = _wasiMemoryView();\n");
372 out.push_str(" if (v) v.setUint32(countOut, 0, true);\n");
373 out.push_str(" return 0;\n");
374 out.push_str(" },\n");
375 out.push_str(" // WASI fd_write must update `nwritten_ptr` with the total bytes consumed,\n");
376 out.push_str(" // otherwise libc-style callers (e.g. tesseract-compiled-to-wasm fputs)\n");
377 out.push_str(" // see 0 of N bytes written and retry forever, hanging the host.\n");
378 out.push_str(" fd_write: (_fd: number, iovsPtr: number, iovsLen: number, nwrittenPtr: number) => {\n");
379 out.push_str(" const v = _wasiMemoryView();\n");
380 out.push_str(" if (!v) return 0;\n");
381 out.push_str(" let total = 0;\n");
382 out.push_str(" for (let i = 0; i < iovsLen; i++) {\n");
383 out.push_str(" const off = iovsPtr + i * 8;\n");
384 out.push_str(" total += v.getUint32(off + 4, true);\n");
385 out.push_str(" }\n");
386 out.push_str(" v.setUint32(nwrittenPtr, total, true);\n");
387 out.push_str(" return 0;\n");
388 out.push_str(" },\n");
389 out.push_str(" // Mirror fd_write: callers retry on partial reads. Reporting 0 bytes\n");
390 out.push_str(" // read (EOF) is fine; just make sure `nread_ptr` is written.\n");
391 out.push_str(" fd_read: (_fd: number, _iovsPtr: number, _iovsLen: number, nreadPtr: number) => {\n");
392 out.push_str(" const v = _wasiMemoryView();\n");
393 out.push_str(" if (v) v.setUint32(nreadPtr, 0, true);\n");
394 out.push_str(" return 0;\n");
395 out.push_str(" },\n");
396 out.push_str(" fd_seek: () => 0,\n");
397 out.push_str(" fd_close: () => 0,\n");
398 out.push_str(" fd_prestat_get: () => 8, // EBADF — no preopens.\n");
399 out.push_str(" fd_prestat_dir_name: () => 0,\n");
400 out.push_str(" fd_fdstat_get: () => 0,\n");
401 out.push_str(" fd_fdstat_set_flags: () => 0,\n");
402 out.push_str(" path_open: () => 44, // ENOENT.\n");
403 out.push_str(" path_create_directory: () => 0,\n");
404 out.push_str(" path_remove_directory: () => 0,\n");
405 out.push_str(" path_unlink_file: () => 0,\n");
406 out.push_str(" path_filestat_get: () => 44, // ENOENT.\n");
407 out.push_str(" path_rename: () => 0,\n");
408 out.push_str(" clock_time_get: (_clockId: number, _precision: bigint, timeOut: number) => {\n");
409 out.push_str(" const ns = BigInt(Date.now()) * 1_000_000n + BigInt(performance.now() | 0) % 1_000_000n;\n");
410 out.push_str(" const v = _wasiMemoryView();\n");
411 out.push_str(" if (v) v.setBigUint64(timeOut, ns, true);\n");
412 out.push_str(" return 0;\n");
413 out.push_str(" },\n");
414 out.push_str(" clock_res_get: (_clockId: number, resOut: number) => {\n");
415 out.push_str(" const v = _wasiMemoryView();\n");
416 out.push_str(" if (v) v.setBigUint64(resOut, 1_000n, true);\n");
417 out.push_str(" return 0;\n");
418 out.push_str(" },\n");
419 out.push_str(" random_get: (bufPtr: number, bufLen: number) => {\n");
420 out.push_str(" const g = globalThis as unknown as { __kreuzberg_wasm_memory__?: WebAssembly.Memory };\n");
421 out.push_str(" if (!g.__kreuzberg_wasm_memory__) return 0;\n");
422 out.push_str(" _cryptoFill(new Uint8Array(g.__kreuzberg_wasm_memory__.buffer, bufPtr, bufLen));\n");
423 out.push_str(" return 0;\n");
424 out.push_str(" },\n");
425 out.push_str(" args_get: () => 0,\n");
426 out.push_str(" args_sizes_get: (countOut: number, _sizeOut: number) => {\n");
427 out.push_str(" const v = _wasiMemoryView();\n");
428 out.push_str(" if (v) v.setUint32(countOut, 0, true);\n");
429 out.push_str(" return 0;\n");
430 out.push_str(" },\n");
431 out.push_str(" poll_oneoff: () => 0,\n");
432 out.push_str(" sched_yield: () => 0,\n");
433 out.push_str(" };\n");
434 out.push_str(" const _origResolve = Module._resolveFilename;\n");
435 out.push_str(" Module._resolveFilename = function(request: string, parent: unknown, ...rest: unknown[]) {\n");
436 out.push_str(" if (request === 'env' || request === 'wasi_snapshot_preview1') return request;\n");
437 out.push_str(" return _origResolve.call(this, request, parent, ...rest);\n");
438 out.push_str(" };\n");
439 out.push_str(" const _origLoad = Module._load;\n");
440 out.push_str(" Module._load = function(request: string, parent: unknown, ...rest: unknown[]) {\n");
441 out.push_str(" if (request === 'env') return env;\n");
442 out.push_str(" if (request === 'wasi_snapshot_preview1') return wasi_snapshot_preview1;\n");
443 out.push_str(" return _origLoad.call(this, request, parent, ...rest);\n");
444 out.push_str(" };\n");
445 out.push_str(" // Capture the WASM linear memory at instantiation time so the WASI shims\n");
446 out.push_str(" // can read/write into it. Without this, every shim that needs memory\n");
447 out.push_str(" // (fd_write nwritten, clock_time_get, random_get, etc.) silently no-ops\n");
448 out.push_str(" // and the host-side C runtime hangs in a retry loop.\n");
449 out.push_str(" const _OrigInstance = WebAssembly.Instance;\n");
450 out.push_str(" const PatchedInstance = function(this: WebAssembly.Instance, mod: WebAssembly.Module, imports?: WebAssembly.Imports) {\n");
451 out.push_str(" const inst = new _OrigInstance(mod, imports);\n");
452 out.push_str(" const exportsMem = (inst.exports as Record<string, unknown>).memory;\n");
453 out.push_str(" if (exportsMem instanceof WebAssembly.Memory) {\n");
454 out.push_str(" (globalThis as unknown as { __kreuzberg_wasm_memory__?: WebAssembly.Memory }).__kreuzberg_wasm_memory__ = exportsMem;\n");
455 out.push_str(" }\n");
456 out.push_str(" return inst;\n");
457 out.push_str(" } as unknown as typeof WebAssembly.Instance;\n");
458 out.push_str(" PatchedInstance.prototype = _OrigInstance.prototype;\n");
459 out.push_str(
460 " (WebAssembly as unknown as { Instance: typeof WebAssembly.Instance }).Instance = PatchedInstance;\n",
461 );
462 out.push_str("}\n\n");
463 out.push_str("// Change to the configured test-documents directory so that fixture file paths like\n");
464 out.push_str("// \"pdf/fake_memo.pdf\" resolve correctly when vitest runs from e2e/wasm/.\n");
465 out.push_str("// setup.ts lives in e2e/wasm/; the fixtures dir lives at the repository root,\n");
466 out.push_str("// two directories up: e2e/wasm/ -> e2e/ -> repo root.\n");
467 out.push_str("const __filename = fileURLToPath(import.meta.url);\n");
468 out.push_str("const __dirname = dirname(__filename);\n");
469 let _ = writeln!(
470 out,
471 "const testDocumentsDir = join(__dirname, '..', '..', '{test_documents_dir}');"
472 );
473 out.push_str("process.chdir(testDocumentsDir);\n");
474 out
475}
476
477fn render_global_setup() -> String {
478 let header = hash::header(CommentStyle::DoubleSlash);
479 crate::template_env::render(
480 "wasm/globalSetup.ts.jinja",
481 minijinja::context! {
482 header => header,
483 },
484 )
485}
486
487fn render_tsconfig() -> String {
488 crate::template_env::render("wasm/tsconfig.jinja", minijinja::context! {})
489}
490
491