Skip to main content

alef/e2e/codegen/typescript/
mod.rs

1//! TypeScript e2e test generator using vitest.
2
3#[cfg(test)]
4mod assertion_wildcard_element_tests;
5mod assertions;
6pub(crate) mod config;
7#[cfg(test)]
8mod emit_test_backend_tests;
9mod field_refusal;
10#[cfg(test)]
11mod is_true_tests;
12mod json;
13#[cfg(test)]
14mod not_error_sibling_assertion_tests;
15#[cfg(test)]
16mod npmrc_marker_tests;
17pub(crate) mod test_file;
18#[cfg(test)]
19mod undeclared_key_refusal_tests;
20mod visitors;
21#[cfg(test)]
22mod wasm_trait_bridge_stub_tests;
23
24use crate::e2e::config::E2eConfig;
25
26use crate::core::backend::GeneratedFile;
27use crate::core::config::ResolvedCrateConfig;
28use crate::e2e::fixture::{Fixture, FixtureGroup};
29use anyhow::Result;
30use std::path::PathBuf;
31
32use super::E2eCodegen;
33use crate::codegen::generators::trait_bridge::to_camel_case;
34use config::{render_file_setup, render_global_setup, render_package_json, render_tsconfig, render_vitest_config};
35pub use test_file::render_test_file;
36use test_file::resolve_node_function_name;
37
38/// TypeScript e2e code generator.
39pub struct TypeScriptCodegen;
40
41impl E2eCodegen for TypeScriptCodegen {
42    fn generate(
43        &self,
44        groups: &[FixtureGroup],
45        e2e_config: &E2eConfig,
46        config: &ResolvedCrateConfig,
47        type_defs: &[crate::core::ir::TypeDef],
48        enums: &[crate::core::ir::EnumDef],
49        functions: &[crate::core::ir::FunctionDef],
50        errors: &[crate::core::ir::ErrorDef],
51    ) -> Result<Vec<GeneratedFile>> {
52        let output_base = PathBuf::from(e2e_config.effective_output()).join(self.language_name());
53        let tests_base = output_base.join("tests");
54
55        let mut files = Vec::new();
56
57        // Resolve call config with overrides — use "node" key (Language::Node).
58        let call = &e2e_config.call;
59        let overrides = call.overrides.get("node");
60        let module_path = overrides
61            .and_then(|o| o.module.as_ref())
62            .cloned()
63            .unwrap_or_else(|| call.module.clone());
64        let function_name = overrides.and_then(|o| o.function.as_ref()).cloned().unwrap_or_else(|| {
65            let default_cc = e2e_config.resolve_call(None);
66            resolve_node_function_name(default_cc)
67        });
68        let client_factory = overrides.and_then(|o| o.client_factory.as_deref());
69
70        // Resolve package config.
71        let node_pkg = e2e_config.resolve_package("node");
72        let pkg_path = node_pkg
73            .as_ref()
74            .and_then(|p| p.path.as_ref())
75            .cloned()
76            .unwrap_or_else(|| "../../packages/node".to_string());
77        let pkg_name = node_pkg
78            .as_ref()
79            .and_then(|p| p.name.as_ref())
80            .cloned()
81            .unwrap_or_else(|| module_path.clone());
82        let pkg_version = node_pkg
83            .as_ref()
84            .and_then(|p| p.version.as_ref())
85            .cloned()
86            .or_else(|| config.resolved_version())
87            .unwrap_or_else(|| "0.1.0".to_string());
88
89        let has_http_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| f.http.is_some());
90
91        // Server-pattern app_harness.mjs (spinning up the SUT as an HTTP server)
92        // is emitted by a consumer extension via Extension::emit_e2e —
93        // server-pattern e2e is not generic, so alef does not generate it.
94
95        let file_input_scan = super::file_inputs::FileInputScan::new(type_defs, enums);
96        let has_file_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| {
97            let cc = e2e_config.resolve_call_for_fixture(
98                f.call.as_deref(),
99                &f.id,
100                &f.resolved_category(),
101                &f.tags,
102                &f.input,
103            );
104            file_input_scan.fixture_uses_test_documents(f, cc)
105        });
106
107        files.push(GeneratedFile {
108            path: output_base.join("package.json"),
109            content: render_package_json(
110                &pkg_name,
111                &pkg_path,
112                &pkg_version,
113                e2e_config.dep_mode,
114                has_http_fixtures,
115                // `harness_extras` deps support the alef-generated e2e harness code under
116                // `e2e/{lang}/tests/` (Local dep mode). Registry mode emits the published-package
117                // test_apps at `test_apps/{lang}/` whose tests only import the under-test package
118                // and never need harness-specific dev deps. Injecting harness_extras here drags
119                // unused native deps (e.g. upstream `tree-sitter`) into pnpm install, which can
120                // break on newer Node SDKs that the unrelated native build doesn't support yet.
121                match e2e_config.dep_mode {
122                    crate::e2e::config::DependencyMode::Local => e2e_config.harness_extras.get(self.language_name()),
123                    crate::e2e::config::DependencyMode::Registry => None,
124                },
125            ),
126            generated_header: false,
127        });
128
129        files.push(GeneratedFile {
130            path: output_base.join("tsconfig.json"),
131            content: render_tsconfig(),
132            generated_header: false,
133        });
134
135        // Emit an `.npmrc` that disables frozen-lockfile for the e2e install.
136        // A napi binding declares its platform binaries as optionalDependencies
137        // pinned to the exact (not-yet-published) release version, so before that
138        // version is on the registry a frozen `pnpm install` cannot resolve them
139        // and fails with ERR_PNPM_OUTDATED_LOCKFILE — and pnpm refuses to add the
140        // unresolvable optional specifiers to the lockfile, so it can never be
141        // pre-satisfied. pnpm defaults frozen-lockfile to true in CI, so opt out
142        // here; the e2e project is a disposable test harness, not a
143        // reproducibility gate. pnpm reads `.npmrc` from the install cwd
144        // (`cd e2e/<lang> && pnpm install`), so this scopes the override to e2e.
145        files.push(GeneratedFile {
146            path: output_base.join(".npmrc"),
147            content: format!("; {}\n; frozen-lockfile is disabled: the under-test napi package pins platform optionalDependencies to the unpublished release version.\nfrozen-lockfile=false\n", crate::core::hash::STANDARD_HEADER_LINE),
148            generated_header: false,
149        });
150
151        // Emit a sealed `pnpm-workspace.yaml` ONLY in Registry mode to isolate
152        // the test app's devDependencies (vitest, etc.) from the outer workspace.
153        // Without it, pnpm hoists/dedupes transitive deps against any parent
154        // workspace and `vitest` ends up uninstalled locally, breaking e2e tests.
155        //
156        // In Local mode, the test app depends on the binding via `workspace:*`,
157        // which MUST resolve through the consumer's root pnpm workspace. Emitting
158        // the sealed marker would shadow the root and cause `pnpm install` to fail:
159        // `ERR_PNPM_NO_MATCHING_VERSION_INSIDE_WORKSPACE`.
160        //
161        // For cleanup: if in Local mode, explicitly tell cleanup to remove any
162        // stale pnpm-workspace.yaml (with or without alef header). This handles
163        // the migration from Registry to Local mode, even if prior Registry-mode
164        // emissions lacked the generated header.
165        let workspace_yaml_path = output_base.join("pnpm-workspace.yaml");
166        if e2e_config.dep_mode == crate::e2e::config::DependencyMode::Registry {
167            // Registry mode: emit with generated header for proper cleanup tracking.
168            //
169            // Also emit `minimumReleaseAgeExclude` pinning the just-published
170            // package version so pnpm 11.3+'s supply-chain freshness gate does
171            // not reject installation of the package under test. The gate
172            // rejects packages younger than the configured minimum release age
173            // (default 24h); the sealed `packages: []` workspace root does not
174            // inherit any outer `minimumReleaseAge: 0` override, so without this
175            // allowlist `pnpm install` fails with a policy rejection on freshly
176            // published `@xberg-io/*` releases. Symmetric with the wasm codegen.
177            let workspace_yaml_content = {
178                let mut content = String::from("packages: []\nallowBuilds:\n  esbuild: true\n  tree-sitter: true\n");
179                use std::fmt::Write as _;
180                // A napi binding fans out into per-platform optionalDependencies
181                // (`<pkg>-darwin-arm64`, `<pkg>-linux-x64-gnu`, ...), each
182                // published in the same release burst as the main package, so
183                // pinning a single `pkg@version` is insufficient — every sibling
184                // platform package would still trip the freshness gate. Exempt
185                // the whole first-party scope (`@scope/*`) instead, which covers
186                // the main package and all its platform siblings while keeping
187                // the freshness gate active for every third-party dependency.
188                // Fall back to the exact package name when it is unscoped.
189                let exclude_pattern = match pkg_name.split_once('/') {
190                    Some((scope, _)) if scope.starts_with('@') => format!("{scope}/*"),
191                    _ => pkg_name.clone(),
192                };
193                let _ = write!(content, "minimumReleaseAgeExclude:\n  - '{exclude_pattern}'\n");
194                content
195            };
196            files.push(GeneratedFile {
197                path: workspace_yaml_path.clone(),
198                content: workspace_yaml_content,
199                generated_header: true,
200            });
201        } else {
202            // Local mode: explicitly emit a cleanup marker to remove the file if
203            // it exists, even without an alef header. This handles stale files
204            // from prior Registry-mode runs or manual creation.
205            if workspace_yaml_path.exists() {
206                std::fs::remove_file(&workspace_yaml_path)
207                    .map_err(|e| anyhow::anyhow!("Failed to remove stale pnpm-workspace.yaml: {}", e))?;
208            }
209        }
210
211        // globalSetup spawns the mock-server binary and exposes its URL via
212        // MOCK_SERVER_URL. Required whenever any fixture's call uses the mock
213        // server — either via http blocks (real HTTP test fixtures), via
214        // mock_response/mock_responses (function-call tests that build their
215        // own URLs against MOCK_SERVER_URL), or because a client_factory is
216        // wired to point at the mock server's `/fixtures/<id>` prefix.
217        let any_needs_mock_server = groups
218            .iter()
219            .flat_map(|g| g.fixtures.iter())
220            .any(|f| f.needs_mock_server());
221        let needs_global_setup = client_factory.is_some() || has_http_fixtures || any_needs_mock_server;
222
223        files.push(GeneratedFile {
224            path: output_base.join("vitest.config.ts"),
225            content: render_vitest_config(needs_global_setup, has_file_fixtures),
226            generated_header: true,
227        });
228
229        // The server-pattern globalSetup (which spawns app_harness) is emitted by
230        // a consumer extension. alef emits only the client/mock-server
231        // globalSetup; when a server harness is configured the extension owns both
232        // app_harness.mjs and its globalSetup.ts.
233        let use_server_pattern = has_http_fixtures && !e2e_config.harness.imports.is_empty();
234        if needs_global_setup && !use_server_pattern {
235            files.push(GeneratedFile {
236                path: output_base.join("globalSetup.ts"),
237                content: render_global_setup(false),
238                generated_header: true,
239            });
240        }
241
242        if has_file_fixtures {
243            files.push(GeneratedFile {
244                path: output_base.join("setup.ts"),
245                content: render_file_setup(&e2e_config.test_documents_dir),
246                generated_header: true,
247            });
248        }
249
250        let options_type = overrides.and_then(|o| o.options_type.clone());
251
252        for group in groups {
253            let active: Vec<_> = group
254                .fixtures
255                .iter()
256                .filter(|fixture| is_node_fixture_runnable(fixture, e2e_config))
257                .collect();
258
259            if active.is_empty() {
260                continue;
261            }
262
263            let filename = format!("{}.test.ts", crate::e2e::escape::sanitize_filename(&group.category));
264            let content = render_test_file(
265                "node",
266                &group.category,
267                &active,
268                &module_path,
269                &pkg_name,
270                &function_name,
271                &e2e_config.call.args,
272                options_type.as_deref(),
273                client_factory,
274                e2e_config,
275                type_defs,
276                enums,
277                functions,
278                "",
279                config,
280                errors,
281            );
282            files.push(GeneratedFile {
283                path: tests_base.join(filename),
284                content,
285                generated_header: true,
286            });
287        }
288
289        Ok(files)
290    }
291
292    fn render_snippet_body(
293        &self,
294        fixture: &Fixture,
295        e2e_config: &E2eConfig,
296        config: &ResolvedCrateConfig,
297        type_defs: &[crate::core::ir::TypeDef],
298        enums: &[crate::core::ir::EnumDef],
299    ) -> Result<String> {
300        self.render_snippet_body_with_functions(fixture, e2e_config, config, type_defs, enums, &[], &[])
301    }
302
303    fn render_snippet_body_with_functions(
304        &self,
305        fixture: &Fixture,
306        e2e_config: &E2eConfig,
307        config: &ResolvedCrateConfig,
308        type_defs: &[crate::core::ir::TypeDef],
309        enums: &[crate::core::ir::EnumDef],
310        functions: &[crate::core::ir::FunctionDef],
311        _errors: &[crate::core::ir::ErrorDef],
312    ) -> Result<String> {
313        Ok(test_file::render_node_snippet_body(
314            fixture, e2e_config, config, type_defs, enums, functions,
315        ))
316    }
317
318    fn language_name(&self) -> &'static str {
319        "node"
320    }
321}
322
323fn is_node_fixture_runnable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
324    if !super::fixture_inclusion(fixture, "node", e2e_config).is_included() {
325        return false;
326    }
327
328    if let Some(http) = &fixture.http {
329        return http.expected_response.status_code != 101;
330    }
331
332    !fixture.assertions.is_empty()
333}
334
335/// Emit a TypeScript/Node/WASM test backend stub for a trait-bridge fixture.
336///
337/// Generates a duck-typed TypeScript class `_TestStub_<fixture_id>` whose methods
338/// return sensible default values via `Promise.resolve(...)` for async methods and
339/// plain values for sync ones. When `super_trait` is set, a `name()` method is
340/// emitted returning the fixture's name string extracted from `fixture.input`.
341///
342/// TypeScript/NAPI-RS bridges use duck typing — the class just needs to provide
343/// the right methods that the NAPI-RS bridge's `register<Trait>` function can call.
344///
345/// The `language` parameter distinguishes `"node"` / `"typescript"` / `"wasm"` so
346/// the emitter can use the correct `register` call convention (camelCase for JS).
347///
348/// `wasm_type_prefix` is the wasm-bindgen class prefix (e.g. `"Wasm"`) and must be
349/// `""` for every non-wasm caller. Every enum a stub method references — in its
350/// return-type annotation and in the JSON-quoted-variant cast the bridge boundary
351/// requires — is a wasm-bindgen-emitted class under that prefix, exactly like every
352/// other wasm type (see `gen_struct`/`gen_opaque_struct` in
353/// `backends/wasm/gen_bindings/types.rs`); a bare `ProcessingStage` names nothing the
354/// wasm package exports. napi-rs does not prefix its types, so node keeps the bare
355/// name unchanged. ~keep
356///
357/// The returned `arg_expr` is `new _TestStub_<fixture_id>()`.
358pub fn emit_test_backend(
359    trait_bridge: &crate::core::config::TraitBridgeConfig,
360    methods: &[&crate::core::ir::MethodDef],
361    fixture: &Fixture,
362    enums: &[crate::core::ir::EnumDef],
363    wasm_type_prefix: &str,
364) -> super::TestBackendEmission {
365    use crate::codegen::defaults::language_defaults;
366    use crate::e2e::escape::{escape_js, sanitize_ident};
367    use std::fmt::Write as FmtWrite;
368
369    let stub_name = format!("_TestStub_{}", sanitize_ident(&fixture.id));
370    let backend_name = extract_backend_name_from_input(&fixture.input, &fixture.id);
371    let defaults = language_defaults("typescript");
372
373    // Extract the backend input block if present (e.g., fixture.input.backend).
374    // Used to populate method defaults like dimensions(), backend name, etc.
375    let backend_input = fixture.input.get("backend").and_then(|v| v.as_object());
376
377    let mut setup = String::new();
378    let mut type_imports: Vec<String> = Vec::new();
379
380    let _ = writeln!(setup, "class {stub_name} {{");
381
382    // name() from Plugin super-trait, if configured.
383    if trait_bridge.super_trait.is_some() {
384        let escaped = escape_js(&backend_name);
385        let _ = writeln!(setup, "  name(): string {{ return \"{escaped}\"; }}");
386    }
387
388    // Emit all methods the high-level binding interface may call. Methods with
389    // Rust default impls are optional in user code, but generated e2e stubs
390    // provide no-op/default implementations for lifecycle probes.
391    for method in methods {
392        // Skip Plugin::name if we already emitted it.
393        if trait_bridge.super_trait.is_some() && method.name == "name" {
394            continue;
395        }
396        emit_ts_stub_method(
397            &mut setup,
398            method,
399            &*defaults,
400            backend_input,
401            enums,
402            wasm_type_prefix,
403            &mut type_imports,
404        );
405    }
406
407    // Emit dispose() method for cleanup. The test cleanup code calls dispose()
408    // on trait bridge stubs to signal test teardown. This is a no-op in test stubs.
409    let _ = writeln!(setup, "  async dispose(): Promise<void> {{ return undefined; }}");
410
411    let _ = writeln!(setup, "}}");
412
413    let arg_expr = format!("new {stub_name}()");
414
415    super::TestBackendEmission {
416        setup_block: setup,
417        arg_expr,
418        type_imports,
419        teardown_block: String::new(),
420    }
421}
422
423/// Format a single TypeScript stub method.
424///
425/// Async methods return `Promise.resolve(<default>)`. Sync methods return the
426/// default value directly. All parameters are elided with `_p0`, `_p1`, ...
427/// prefixes (TypeScript allows unused parameters when prefixed with `_`).
428///
429/// Method defaults are extracted from fixture.input.backend first, then fall
430/// back to language defaults. For numeric types that would emit 0, emit 1
431/// instead (downstream validation rejects 0 for counts like dimensions()).
432fn emit_ts_stub_method(
433    out: &mut String,
434    method: &crate::core::ir::MethodDef,
435    defaults: &dyn crate::codegen::defaults::LanguageDefaults,
436    backend_input: Option<&serde_json::Map<String, serde_json::Value>>,
437    enums: &[crate::core::ir::EnumDef],
438    wasm_type_prefix: &str,
439    type_imports: &mut Vec<String>,
440) {
441    use crate::e2e::escape::escape_js;
442    use std::fmt::Write as FmtWrite;
443
444    // Build parameter list: `_p0?: any, _p1?: any, ...`
445    let params: Vec<String> = method
446        .params
447        .iter()
448        .enumerate()
449        .map(|(i, _)| format!("_p{i}?: any"))
450        .collect();
451    let params_str = params.join(", ");
452
453    // Named types in e2e stubs must return JSON-serialisable strings: the NAPI-RS
454    // bridge calls the JS method, coerces the return value to a string via
455    // `coerce_to_string()`, then parses it as JSON into the Rust type.  Returning
456    // `new TypeName()` would produce a JS object whose `.toString()` yields
457    // `"[object Object]"` — not valid JSON — causing a deserialization error in the
458    // bridge.  Return the string literal `"{}"` instead; it round-trips cleanly
459    // through `serde_json` as an empty object.
460    //
461    // For numeric types in test backends, use 1 instead of 0 to satisfy validation
462    // constraints (e.g., EmbeddingBackend::dimensions() must return > 0).
463    //
464    // Named enum returns are a special case: the bridge parses the coerced string
465    // via `serde_json::from_str`, so the JS value must be the *JSON-quoted* variant
466    // name (e.g. the JS string `"Early"`, quotes included) — a bare `Early` is not
467    // valid JSON and fails to parse. The `as unknown as <Enum>` cast satisfies the
468    // enum-typed return annotation (a plain string literal is not assignable to a
469    // nominal TS enum type) while keeping the emitted runtime value untouched.
470    let default_val = match &method.return_type {
471        crate::core::ir::TypeRef::Unit => "undefined".to_string(),
472        crate::core::ir::TypeRef::Named(name) if enums.iter().any(|e| &e.name == name) => {
473            let prefixed_name = format!("{wasm_type_prefix}{name}");
474            if !type_imports.contains(&prefixed_name) {
475                type_imports.push(prefixed_name.clone());
476            }
477            let enum_def = enums.iter().find(|e| &e.name == name).expect("checked above");
478            match enum_def.variants.first() {
479                Some(variant) => format!("\"\\\"{}\\\"\" as unknown as {prefixed_name}", variant.name),
480                // Enum with no variants (shouldn't happen in practice): still cast, since
481                // `ts_stub_return_type` names this same enum regardless of variant count. ~keep
482                None => format!("\"{{}}\" as unknown as {prefixed_name}"),
483            }
484        }
485        // A non-enum Named type (a plain struct such as `ExtractedDocument`) still returns
486        // the JSON-round-tripping `"{}"` string at runtime for the same coercion reason as
487        // the enum case above, but the stub's OWN declared return type now names that real
488        // struct (see `ts_stub_return_type`) rather than falling back to `string` — so the
489        // body needs the same `as unknown as <Type>` cast the enum arm already uses, or the
490        // literal's `string` type stops being assignable to its own method's declared
491        // return type. Registering the import here (not just in `ts_stub_return_type`,
492        // which has no `type_imports` to write to) is what makes the cast target resolve. ~keep
493        crate::core::ir::TypeRef::Named(name) => {
494            let prefixed_name = format!("{wasm_type_prefix}{name}");
495            if !type_imports.contains(&prefixed_name) {
496                type_imports.push(prefixed_name.clone());
497            }
498            format!("\"{{}}\" as unknown as {prefixed_name}")
499        }
500        crate::core::ir::TypeRef::String | crate::core::ir::TypeRef::Char | crate::core::ir::TypeRef::Path => {
501            fixture_backend_value(backend_input, &method.name)
502                .and_then(|val| val.as_str())
503                .map(|s| format!("\"{}\"", escape_js(s)))
504                .unwrap_or_else(|| defaults.emit_default(&method.return_type))
505        }
506        crate::core::ir::TypeRef::Primitive(crate::core::ir::PrimitiveType::Bool) => {
507            fixture_backend_value(backend_input, &method.name)
508                .and_then(|val| val.as_bool())
509                .map(|b| b.to_string())
510                .unwrap_or_else(|| "false".to_string())
511        }
512        crate::core::ir::TypeRef::Primitive(crate::core::ir::PrimitiveType::F32)
513        | crate::core::ir::TypeRef::Primitive(crate::core::ir::PrimitiveType::F64) => {
514            fixture_backend_value(backend_input, &method.name)
515                .and_then(|val| val.as_f64())
516                .map(|n| n.to_string())
517                .unwrap_or_else(|| "0.0".to_string())
518        }
519        crate::core::ir::TypeRef::Primitive(_) => {
520            // Try to extract fixture value first, then fall back to 1 instead of 0
521            if let Some(val) = fixture_backend_value(backend_input, &method.name) {
522                // Emit the fixture value directly (primitives, numbers, etc.)
523                match val {
524                    serde_json::Value::Number(n) => n.to_string(),
525                    serde_json::Value::Bool(b) => b.to_string(),
526                    _ => {
527                        // Complex types: fall back to 1 for numeric
528                        "1".to_string()
529                    }
530                }
531            } else {
532                // No fixture value: emit 1 instead of 0
533                "1".to_string()
534            }
535        }
536        other => defaults.emit_default(other),
537    };
538    let return_type = ts_stub_return_type(&method.return_type, enums, wasm_type_prefix);
539
540    if method.is_async {
541        let _ = writeln!(
542            out,
543            "  async {name}({params_str}): Promise<{return_type}> {{ return {default_val}; }}",
544            name = to_camel_case(&method.name)
545        );
546    } else {
547        let _ = writeln!(
548            out,
549            "  {name}({params_str}): {return_type} {{ return {default_val}; }}",
550            name = to_camel_case(&method.name)
551        );
552    }
553}
554
555fn fixture_backend_value<'a>(
556    backend_input: Option<&'a serde_json::Map<String, serde_json::Value>>,
557    method_name: &str,
558) -> Option<&'a serde_json::Value> {
559    backend_input
560        .and_then(|b| b.get(&method_name.to_lowercase()))
561        .or_else(|| backend_input.and_then(|b| b.get(&to_camel_case(method_name))))
562}
563
564fn ts_stub_return_type(
565    return_type: &crate::core::ir::TypeRef,
566    enums: &[crate::core::ir::EnumDef],
567    wasm_type_prefix: &str,
568) -> String {
569    match return_type {
570        crate::core::ir::TypeRef::Unit => "void".to_string(),
571        crate::core::ir::TypeRef::Primitive(crate::core::ir::PrimitiveType::Bool) => "boolean".to_string(),
572        crate::core::ir::TypeRef::Primitive(_) => "number".to_string(),
573        // Duck-typed trait-bridge stubs must declare a TS return type matching the
574        // real napi-rs interface (e.g. `EmbeddingBackend.embed(): Promise<Array<
575        // Array<number>>>`). The stub body already emits `[]` for list-typed
576        // methods (see `default_val` above); falling through to the bare
577        // `"string"` default below mismatched that `[]` against its own
578        // declared return type (TS2322: `never[]` not assignable to `string`).
579        crate::core::ir::TypeRef::Vec(inner) => {
580            format!("Array<{}>", ts_stub_return_type(inner, enums, wasm_type_prefix))
581        }
582        crate::core::ir::TypeRef::Optional(inner) => ts_stub_return_type(inner, enums, wasm_type_prefix),
583        // A Named type -- enum or plain struct -- gets its own real name as the annotation
584        // instead of falling through to the generic `string` default: the real napi-rs
585        // interface this stub implements (e.g. `OcrBackend.processImage(): Promise<
586        // ExtractedDocument>`) never declares a bare `string` return for a struct-typed
587        // method, and a stub whose declared type disagrees with the interface it is passed
588        // to fails to typecheck regardless of what its body returns. The annotation must
589        // name what the package actually exports: prefixed for wasm, bare for node (see
590        // `emit_test_backend`'s `wasm_type_prefix` doc). ~keep
591        crate::core::ir::TypeRef::Named(name) => format!("{wasm_type_prefix}{name}"),
592        _ => "string".to_string(),
593    }
594}
595
596/// Extract a backend name string from the fixture input JSON.
597fn extract_backend_name_from_input(input: &serde_json::Value, fallback: &str) -> String {
598    if let Some(obj) = input.as_object() {
599        if let Some(s) = obj.get("name").and_then(|v| v.as_str()) {
600            return s.to_string();
601        }
602        for v in obj.values() {
603            if let Some(inner) = v.as_object()
604                && let Some(s) = inner.get("name").and_then(|v| v.as_str())
605            {
606                return s.to_string();
607            }
608        }
609        for v in obj.values() {
610            if let Some(s) = v.as_str() {
611                return s.to_string();
612            }
613        }
614    }
615    fallback.to_string()
616}
617
618/// Build a minimal `MethodDef` for tests.
619#[cfg(test)]
620fn test_method(
621    name: &str,
622    return_type: crate::core::ir::TypeRef,
623    is_async: bool,
624    has_default_impl: bool,
625) -> crate::core::ir::MethodDef {
626    crate::core::ir::MethodDef {
627        name: name.to_string(),
628        params: Vec::new(),
629        return_type,
630        is_async,
631        is_static: false,
632        error_type: None,
633        doc: String::new(),
634        receiver: Some(crate::core::ir::ReceiverKind::Ref),
635        cfg: None,
636        sanitized: false,
637        trait_source: None,
638        returns_ref: false,
639        returns_cow: false,
640        return_newtype_wrapper: None,
641        has_default_impl,
642        binding_excluded: false,
643        binding_exclusion_reason: None,
644        version: Default::default(),
645    }
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651
652    #[test]
653    fn language_name_is_node() {
654        let codegen = TypeScriptCodegen;
655        assert_eq!(codegen.language_name(), "node");
656    }
657
658    #[test]
659    fn generate_empty_groups_produces_config_files_only() {
660        use crate::core::config::NewAlefConfig;
661        let cfg: NewAlefConfig = toml::from_str(
662            r#"
663[workspace]
664languages = ["node"]
665
666[[crates]]
667name = "my-lib"
668sources = ["src/lib.rs"]
669
670[crates.e2e]
671fixtures = "fixtures"
672output = "e2e"
673[crates.e2e.call]
674function = "process"
675module = "my-lib"
676result_var = "result"
677"#,
678        )
679        .unwrap();
680        let mut e2e = cfg.crates[0].e2e.clone().unwrap();
681        e2e.dep_mode = crate::e2e::config::DependencyMode::Registry;
682        let resolved = cfg.resolve().unwrap().remove(0);
683        let codegen = TypeScriptCodegen;
684        let files = codegen.generate(&[], &e2e, &resolved, &[], &[], &[], &[]).unwrap();
685        // package.json, tsconfig.json, vitest.config.ts, pnpm-workspace.yaml
686        assert!(files.len() >= 3, "got {} files", files.len());
687
688        // The node test app must emit its own pnpm-workspace.yaml in Registry
689        // mode so that `pnpm install` does not sweep it into an outer
690        // workspace and skip installing its devDependencies (vitest) locally.
691        let workspace = files
692            .iter()
693            .find(|f| f.path.ends_with("pnpm-workspace.yaml"))
694            .expect("node codegen in Registry mode must emit pnpm-workspace.yaml for install isolation");
695        assert!(
696            workspace.content.contains("packages:"),
697            "pnpm-workspace.yaml must declare an isolated workspace root, got: {}",
698            workspace.content
699        );
700
701        // The node e2e app must disable frozen-lockfile: a napi binding pins its
702        // platform binaries as optionalDependencies to the unpublished release
703        // version, which a frozen `pnpm install` cannot resolve pre-publish.
704        let npmrc = files
705            .iter()
706            .find(|f| f.path.ends_with(".npmrc"))
707            .expect("node codegen must emit .npmrc to disable frozen-lockfile for the e2e install");
708        assert!(
709            npmrc.content.contains("frozen-lockfile=false"),
710            "e2e .npmrc must set frozen-lockfile=false, got: {}",
711            npmrc.content
712        );
713    }
714
715    #[test]
716    fn local_mode_does_not_emit_pnpm_workspace_yaml() {
717        use crate::core::config::NewAlefConfig;
718        let cfg: NewAlefConfig = toml::from_str(
719            r#"
720[workspace]
721languages = ["node"]
722
723[[crates]]
724name = "my-lib"
725sources = ["src/lib.rs"]
726
727[crates.e2e]
728fixtures = "fixtures"
729output = "e2e"
730[crates.e2e.call]
731function = "process"
732module = "my-lib"
733result_var = "result"
734"#,
735        )
736        .unwrap();
737        // Default dep_mode is Local (not Registry)
738        let e2e = cfg.crates[0].e2e.clone().unwrap();
739        assert_eq!(e2e.dep_mode, crate::e2e::config::DependencyMode::Local);
740        let resolved = cfg.resolve().unwrap().remove(0);
741        let codegen = TypeScriptCodegen;
742        let files = codegen.generate(&[], &e2e, &resolved, &[], &[], &[], &[]).unwrap();
743
744        // In Local mode, pnpm-workspace.yaml must NOT be emitted. The root
745        // workspace will handle e2e/node naturally, and workspace:* deps will
746        // resolve correctly through the parent workspace.
747        let workspace = files.iter().find(|f| f.path.ends_with("pnpm-workspace.yaml"));
748        assert!(
749            workspace.is_none(),
750            "Local mode must NOT emit pnpm-workspace.yaml; found: {:?}",
751            workspace
752        );
753    }
754}