use crate::config::E2eConfig;
use crate::escape::sanitize_filename;
use crate::field_access::FieldResolver;
use crate::fixture::{Fixture, FixtureGroup};
use alef_core::backend::GeneratedFile;
use alef_core::config::AlefConfig;
use alef_core::hash::{self, CommentStyle};
use alef_core::template_versions as tv;
use anyhow::Result;
use std::path::PathBuf;
use super::E2eCodegen;
pub struct WasmCodegen;
impl E2eCodegen for WasmCodegen {
fn generate(
&self,
groups: &[FixtureGroup],
e2e_config: &E2eConfig,
alef_config: &AlefConfig,
) -> Result<Vec<GeneratedFile>> {
let lang = self.language_name();
let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
let tests_base = output_base.join("tests");
let mut files = Vec::new();
let call = &e2e_config.call;
let overrides = call.overrides.get(lang);
let module_path = overrides
.and_then(|o| o.module.as_ref())
.cloned()
.unwrap_or_else(|| call.module.clone());
let function_name = overrides
.and_then(|o| o.function.as_ref())
.cloned()
.unwrap_or_else(|| snake_to_camel(&call.function));
let client_factory = overrides.and_then(|o| o.client_factory.as_deref());
let wasm_pkg = e2e_config.resolve_package("wasm");
let pkg_path = wasm_pkg
.as_ref()
.and_then(|p| p.path.as_ref())
.cloned()
.unwrap_or_else(|| format!("../../crates/{}-wasm/pkg", alef_config.crate_config.name));
let pkg_name = wasm_pkg
.as_ref()
.and_then(|p| p.name.as_ref())
.cloned()
.unwrap_or_else(|| module_path.clone());
let pkg_version = wasm_pkg
.as_ref()
.and_then(|p| p.version.as_ref())
.cloned()
.unwrap_or_else(|| "0.1.0".to_string());
let active_per_group: Vec<Vec<&Fixture>> = groups
.iter()
.map(|group| {
group
.fixtures
.iter()
.filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
.filter(|f| {
let cc = e2e_config.resolve_call(f.call.as_deref());
!cc.skip_languages.iter().any(|l| l == lang)
})
.filter(|f| {
f.http.as_ref().is_none_or(|h| {
!h.request
.headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("content-length"))
})
})
.filter(|f| {
f.http.as_ref().is_none_or(|h| {
let m = h.request.method.to_ascii_uppercase();
m != "TRACE" && m != "CONNECT"
})
})
.collect()
})
.collect();
let any_fixtures = active_per_group.iter().flat_map(|g| g.iter());
let has_http_fixtures = any_fixtures.clone().any(|f| f.is_http_test());
let has_non_http_fixtures = any_fixtures
.clone()
.any(|f| !f.is_http_test() && !f.assertions.is_empty());
let has_file_fixtures = active_per_group.iter().flatten().any(|f| {
let cc = e2e_config.resolve_call(f.call.as_deref());
cc.args
.iter()
.any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
});
files.push(GeneratedFile {
path: output_base.join("package.json"),
content: render_package_json(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
generated_header: false,
});
files.push(GeneratedFile {
path: output_base.join("vitest.config.ts"),
content: render_vitest_config(has_http_fixtures, has_file_fixtures),
generated_header: true,
});
if has_http_fixtures {
files.push(GeneratedFile {
path: output_base.join("globalSetup.ts"),
content: render_global_setup(),
generated_header: true,
});
}
if has_file_fixtures {
files.push(GeneratedFile {
path: output_base.join("setup.ts"),
content: render_file_setup(),
generated_header: true,
});
}
files.push(GeneratedFile {
path: output_base.join("tsconfig.json"),
content: render_tsconfig(),
generated_header: false,
});
let _ = has_non_http_fixtures;
let options_type = overrides.and_then(|o| o.options_type.clone());
let field_resolver = FieldResolver::new(
&e2e_config.fields,
&e2e_config.fields_optional,
&e2e_config.result_fields,
&e2e_config.fields_array,
);
for (group, active) in groups.iter().zip(active_per_group.iter()) {
if active.is_empty() {
continue;
}
let filename = format!("{}.test.ts", sanitize_filename(&group.category));
let content = super::typescript::render_test_file(
lang,
&group.category,
active,
&module_path,
&pkg_name,
&function_name,
&e2e_config.call.args,
options_type.as_deref(),
&field_resolver,
client_factory,
e2e_config,
);
files.push(GeneratedFile {
path: tests_base.join(filename),
content,
generated_header: true,
});
}
Ok(files)
}
fn language_name(&self) -> &'static str {
"wasm"
}
}
fn snake_to_camel(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut upper_next = false;
for ch in s.chars() {
if ch == '_' {
upper_next = true;
} else if upper_next {
out.push(ch.to_ascii_uppercase());
upper_next = false;
} else {
out.push(ch);
}
}
out
}
fn render_package_json(
pkg_name: &str,
pkg_path: &str,
pkg_version: &str,
dep_mode: crate::config::DependencyMode,
) -> String {
let dep_value = match dep_mode {
crate::config::DependencyMode::Registry => pkg_version.to_string(),
crate::config::DependencyMode::Local => format!("file:{pkg_path}"),
};
format!(
r#"{{
"name": "{pkg_name}-e2e-wasm",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {{
"test": "vitest run"
}},
"devDependencies": {{
"{pkg_name}": "{dep_value}",
"vite-plugin-top-level-await": "{vite_plugin_top_level_await}",
"vite-plugin-wasm": "{vite_plugin_wasm}",
"vitest": "{vitest}"
}}
}}
"#,
vite_plugin_top_level_await = tv::npm::VITE_PLUGIN_TOP_LEVEL_AWAIT,
vite_plugin_wasm = tv::npm::VITE_PLUGIN_WASM,
vitest = tv::npm::VITEST,
)
}
fn render_vitest_config(with_global_setup: bool, with_file_setup: bool) -> String {
let header = hash::header(CommentStyle::DoubleSlash);
let setup_files_line = if with_file_setup {
" setupFiles: ['./setup.ts'],\n"
} else {
""
};
let global_setup_line = if with_global_setup {
" globalSetup: './globalSetup.ts',\n"
} else {
""
};
format!(
r#"{header}import {{ defineConfig }} from 'vitest/config';
import wasm from 'vite-plugin-wasm';
import topLevelAwait from 'vite-plugin-top-level-await';
export default defineConfig({{
plugins: [wasm(), topLevelAwait()],
test: {{
include: ['tests/**/*.test.ts'],
{global_setup_line}{setup_files_line} }},
}});
"#
)
}
fn render_file_setup() -> String {
let header = hash::header(CommentStyle::DoubleSlash);
header
+ r#"import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
// Change to the test_documents directory so that fixture file paths like
// "pdf/fake_memo.pdf" resolve correctly when vitest runs from e2e/wasm/.
// setup.ts lives in e2e/wasm/; test_documents lives at the repository root,
// two directories up: e2e/wasm/ -> e2e/ -> repo root -> test_documents/.
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const testDocumentsDir = join(__dirname, '..', '..', 'test_documents');
process.chdir(testDocumentsDir);
"#
}
fn render_global_setup() -> String {
let header = hash::header(CommentStyle::DoubleSlash);
format!(
r#"{header}import {{ spawn }} from 'child_process';
import {{ resolve }} from 'path';
let serverProcess: any;
export async function setup() {{
// Mock server binary must be pre-built (e.g. by CI or `cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release`)
serverProcess = spawn(
resolve(__dirname, '../rust/target/release/mock-server'),
[resolve(__dirname, '../../fixtures')],
{{ stdio: ['pipe', 'pipe', 'inherit'] }}
);
const url = await new Promise<string>((resolve, reject) => {{
serverProcess.stdout.on('data', (data: Buffer) => {{
const match = data.toString().match(/MOCK_SERVER_URL=(.*)/);
if (match) resolve(match[1].trim());
}});
setTimeout(() => reject(new Error('Mock server startup timeout')), 30000);
}});
process.env.MOCK_SERVER_URL = url;
}}
export async function teardown() {{
if (serverProcess) {{
serverProcess.stdin.end();
serverProcess.kill();
}}
}}
"#
)
}
fn render_tsconfig() -> String {
r#"{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"strictNullChecks": false,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["tests/**/*.ts", "vitest.config.ts"]
}
"#
.to_string()
}