use sqlx::postgres::Postgres; use sqlx::Pool; use uuid::Uuid; use windmill_common::jobs::{JobPayload, RawCode}; use windmill_common::scripts::ScriptLang; use windmill_test_utils::*; // ============================================================================ // Basic Execution Tests // ============================================================================ #[sqlx::test(fixtures("base"))] async fn test_bun_job_simple(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let content = r#" export function main() { return "hello world"; } "# .to_owned(); let job = JobPayload::Code(RawCode { hash: None, content, path: None, language: ScriptLang::Bun, lock: None, concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) .await .json_result() .unwrap(); assert_eq!(result, serde_json::json!("hello world")); Ok(()) } #[sqlx::test(fixtures("base"))] async fn test_bun_job_with_args(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let content = r#" export function main(name: string, count: number) { return `Hello ${name}, count: ${count}`; } "# .to_owned(); let job = JobPayload::Code(RawCode { hash: None, content, path: None, language: ScriptLang::Bun, lock: None, concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); let result = RunJob::from(job) .arg("name", serde_json::json!("World")) .arg("count", serde_json::json!(42)) .run_until_complete(&db, false, port) .await .json_result() .unwrap(); assert_eq!(result, serde_json::json!("Hello World, count: 42")); Ok(()) } #[sqlx::test(fixtures("base"))] async fn test_bun_job_return_types(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); // Test object return { let content = r#" export function main() { return { name: "test", value: 123 }; } "# .to_owned(); let job = JobPayload::Code(RawCode { hash: None, content, path: None, language: ScriptLang::Bun, lock: None, concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ) .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) .await .json_result() .unwrap(); assert_eq!(result, serde_json::json!({"name": "test", "value": 123})); } // Test array return { let content = r#" export function main() { return [1, 2, 3, "four"]; } "# .to_owned(); let job = JobPayload::Code(RawCode { hash: None, content, path: None, language: ScriptLang::Bun, lock: None, concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ) .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) .await .json_result() .unwrap(); assert_eq!(result, serde_json::json!([1, 2, 3, "four"])); } // Test BigInt serialization { let content = r#" export function main() { // Use BigInt literal notation to avoid JavaScript number precision loss return 9007199254740993n; } "# .to_owned(); let job = JobPayload::Code(RawCode { hash: None, content, path: None, language: ScriptLang::Bun, lock: None, concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ) .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) .await .json_result() .unwrap(); // BigInt should be serialized as string assert_eq!(result, serde_json::json!("9007199254740993")); } Ok(()) } #[sqlx::test(fixtures("base"))] async fn test_bun_job_async(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let content = r#" export async function main() { await new Promise(resolve => setTimeout(resolve, 100)); return "async completed"; } "# .to_owned(); let job = JobPayload::Code(RawCode { hash: None, content, path: None, language: ScriptLang::Bun, lock: None, concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) .await .json_result() .unwrap(); assert_eq!(result, serde_json::json!("async completed")); Ok(()) } #[sqlx::test(fixtures("base"))] async fn test_bun_job_null_undefined_handling(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); // Test null return { let content = r#" export function main() { return null; } "# .to_owned(); let job = JobPayload::Code(RawCode { hash: None, content, path: None, language: ScriptLang::Bun, lock: None, concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ) .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) .await .json_result() .unwrap(); assert_eq!(result, serde_json::json!(null)); } // Test undefined return (should be serialized as null) { let content = r#" export function main() { return undefined; } "# .to_owned(); let job = JobPayload::Code(RawCode { hash: None, content, path: None, language: ScriptLang::Bun, lock: None, concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ) .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) .await .json_result() .unwrap(); assert_eq!(result, serde_json::json!(null)); } Ok(()) } // ============================================================================ // Error Handling Tests // ============================================================================ #[sqlx::test(fixtures("base"))] async fn test_bun_job_runtime_error(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let content = r#" export function main() { throw new Error("intentional error"); } "# .to_owned(); let job = JobPayload::Code(RawCode { hash: None, content, path: None, language: ScriptLang::Bun, lock: None, concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); let completed = run_job_in_new_worker_until_complete(&db, false, job, port).await; assert!(!completed.success); let result = completed.json_result().unwrap(); // Error is wrapped: {"error": {"message": "...", "name": "...", "stack": "..."}} let error = &result["error"]; assert!(error["message"] .as_str() .unwrap() .contains("intentional error")); Ok(()) } #[sqlx::test(fixtures("base"))] async fn test_bun_job_missing_main_function(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let content = r#" export function notMain() { return "hello"; } "# .to_owned(); let job = JobPayload::Code(RawCode { hash: None, content, path: None, language: ScriptLang::Bun, lock: None, concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); let completed = run_job_in_new_worker_until_complete(&db, false, job, port).await; assert!(!completed.success); let result = completed.json_result().unwrap(); // Error is wrapped: {"error": {"message": "...", "name": "...", "stack": "..."}} let error = &result["error"]; assert!(error["message"] .as_str() .unwrap() .contains("main function is missing")); Ok(()) } #[sqlx::test(fixtures("base"))] async fn test_bun_job_syntax_error(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let content = r#" export function main() { return "unclosed string } "# .to_owned(); let job = JobPayload::Code(RawCode { hash: None, content, path: None, language: ScriptLang::Bun, lock: None, concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); let completed = run_job_in_new_worker_until_complete(&db, false, job, port).await; assert!(!completed.success); Ok(()) } #[sqlx::test(fixtures("base"))] async fn test_bun_job_syntax_error_unclosed_bracket(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); // Reproduces the "Unexpected end of file at main.ts:0" error reported // when a TS file has a missing closing bracket — Bun's bundler gives no // useful location info. let content = r#" export async function main() { if (true) { return "hello"; // missing closing bracket for the function "# .to_owned(); let job = JobPayload::Code(RawCode { hash: None, content, path: None, language: ScriptLang::Bun, lock: None, concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); let completed = run_job_in_new_worker_until_complete(&db, false, job, port).await; assert!(!completed.success); let result = completed .result .as_ref() .and_then(|v| v.get("error")) .and_then(|v| v.get("message")) .and_then(|v| v.as_str()) .unwrap_or(""); assert!( result.contains("Unexpected end of file"), "should contain bun parser error, got: {result}" ); assert!( result.contains("syntax error"), "should contain helpful hint about syntax errors, got: {result}" ); Ok(()) } // ============================================================================ // Annotation Mode Tests // ============================================================================ #[sqlx::test(fixtures("base"))] async fn test_bun_nodejs_mode(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let content = r#"//nodejs export function main() { // Node.js specific API return process.version.startsWith("v"); } "# .to_owned(); let job = JobPayload::Code(RawCode { hash: None, content, path: None, language: ScriptLang::Bun, lock: None, concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) .await .json_result() .unwrap(); assert_eq!(result, serde_json::json!(true)); Ok(()) } #[sqlx::test(fixtures("base"))] async fn test_bun_nobundling_mode(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let content = r#"//nobundling export function main() { return "nobundling works"; } "# .to_owned(); let job = JobPayload::Code(RawCode { hash: None, content, path: None, language: ScriptLang::Bun, lock: None, concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) .await .json_result() .unwrap(); assert_eq!(result, serde_json::json!("nobundling works")); Ok(()) } /// Regression test: a `//nobundling` script that pulls a package whose CJS /// internals do bare-specifier `require()` of a sibling dependency. /// /// Before the `--preserve-symlinks` fix, Bun 1.2/1.3+ would follow the /// directory symlink in `node_modules/@langchain/core` to its global cache /// entry, walk parent dirs from the cache realpath, and fail to find /// `node_modules/zod` — producing: /// ENOENT while resolving package 'zod/v3' from /// '.../cache_nomount/bun/@langchain/core@@@@1/dist/runnables/base.js' /// /// The fix passes `--preserve-symlinks` so Bun resolves from the /// symlink path under `/node_modules/`, where `zod` is a sibling. #[sqlx::test(fixtures("base"))] async fn test_bun_nobundling_transitive_require(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let content = r#"//nobundling import { ChatPromptTemplate } from "@langchain/core/prompts"; export async function main() { const tpl = ChatPromptTemplate.fromMessages([ ["system", "you are a {role}"], ["human", "{input}"], ]); const out = await tpl.formatMessages({ role: "tester", input: "ping" }); return out.length; } "# .to_owned(); let job = JobPayload::Code(RawCode { hash: None, content, path: None, language: ScriptLang::Bun, lock: None, concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) .await .json_result() .unwrap(); assert_eq!(result, serde_json::json!(2)); Ok(()) } // ============================================================================ // Native Mode Tests (requires deno_core feature) // ============================================================================ #[cfg(feature = "deno_core")] #[sqlx::test(fixtures("base"))] async fn test_bun_native_mode(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let content = r#"//native export function main() { return "native execution"; } "# .to_owned(); let job = JobPayload::Code(RawCode { hash: None, content, path: None, language: ScriptLang::Bun, lock: None, concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) .await .json_result() .unwrap(); assert_eq!(result, serde_json::json!("native execution")); Ok(()) } // ============================================================================ // Relative Import Tests // ============================================================================ #[sqlx::test(fixtures("base", "relative_bun"))] async fn test_bun_relative_imports(db: Pool) -> anyhow::Result<()> { let content = r#" import { main as test1 } from "/f/system/same_folder_script.ts"; import { main as test2 } from "./same_folder_script.ts"; import { main as test3 } from "/f/system_relative/different_folder_script.ts"; import { main as test4 } from "../system_relative/different_folder_script.ts"; export function main() { return [test1(), test2(), test3(), test4()]; } "# .to_string(); run_deployed_relative_imports(&db, content.clone(), ScriptLang::Bun).await?; run_preview_relative_imports(&db, content, ScriptLang::Bun).await?; Ok(()) } #[sqlx::test(fixtures("base", "relative_bun"))] async fn test_bun_nested_imports(db: Pool) -> anyhow::Result<()> { // Test with absolute path (/f/...) let content_absolute = r#" import { main as test } from "/f/system_relative/nested_script.ts"; export function main() { return test(); } "# .to_string(); run_deployed_relative_imports(&db, content_absolute.clone(), ScriptLang::Bun).await?; run_preview_relative_imports(&db, content_absolute, ScriptLang::Bun).await?; // Test with relative path (../...) let content_relative = r#" import { main as test } from "../system_relative/nested_script.ts"; export function main() { return test(); } "# .to_string(); run_preview_relative_imports(&db, content_relative, ScriptLang::Bun).await?; Ok(()) } // ============================================================================ // Deeply Nested Import Tests // ============================================================================ #[sqlx::test(fixtures("base", "bun_edge_cases"))] async fn test_bun_deeply_nested_imports(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); // Test with absolute path import (/f/nested/level1.ts) // Note: The fixture uses relative imports internally (level1 -> ./level2 -> ./level3) { let content = r#" import { main as level1 } from "/f/nested/level1.ts"; export function main() { return level1(); } "# .to_owned(); let job = JobPayload::Code(RawCode { hash: None, content, path: Some("f/nested/test_deep".to_string()), language: ScriptLang::Bun, lock: None, concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ) .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) .await .json_result() .unwrap(); // level1 -> level2 -> level3, each adds to the chain assert_eq!(result, serde_json::json!("level1 -> level2 -> level3")); } // Test with relative path import (./level1.ts) { let content = r#" import { main as level1 } from "./level1.ts"; export function main() { return level1(); } "# .to_owned(); let job = JobPayload::Code(RawCode { hash: None, content, path: Some("f/nested/test_deep_relative".to_string()), language: ScriptLang::Bun, lock: None, concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( ) .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) .await .json_result() .unwrap(); // Same result: level1 -> level2 -> level3 assert_eq!(result, serde_json::json!("level1 -> level2 -> level3")); } Ok(()) } #[sqlx::test(fixtures("base", "bun_edge_cases"))] async fn test_bun_shared_imports_both_styles(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); // Test importing modules that use different import styles internally: // - module_a uses relative import: ./shared.ts // - module_b uses absolute import: /f/circular/shared.ts // Both should work correctly let content = r#" import { getValue as getA } from "/f/circular/module_a.ts"; import { getValue as getB } from "./module_b.ts"; export function main() { return [getA(), getB()]; } "# .to_owned(); let job = JobPayload::Code(RawCode { hash: None, content, path: Some("f/circular/test_both".to_string()), language: ScriptLang::Bun, lock: None, concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) .await .json_result() .unwrap(); // Both modules should correctly import SHARED_VALUE assert_eq!( result, serde_json::json!(["from_a_shared", "from_b_shared"]) ); Ok(()) } // ============================================================================ // Preprocessor Tests // ============================================================================ #[sqlx::test(fixtures("base"))] async fn test_bun_preprocessor_execution(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); // Test that the main function works correctly // Note: Preprocessor execution requires specific job configuration // (flow_step_id != "preprocessor" and preprocessed == Some(false)) // which is not set by default in RawCode jobs let content = r#" export function main(x: number) { return x + 10; } "# .to_owned(); let job = JobPayload::Code(RawCode { hash: None, content, path: None, language: ScriptLang::Bun, lock: None, concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); // x=5, main adds 10 = 15 let result = RunJob::from(job) .arg("x", serde_json::json!(5)) .run_until_complete(&db, false, port) .await .json_result() .unwrap(); assert_eq!(result, serde_json::json!(15)); Ok(()) } // ============================================================================ // Wmill SDK Tests // ============================================================================ #[sqlx::test(fixtures("base"))] async fn test_bun_job_with_wmill_env_vars(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); // Test accessing Windmill environment variables (which the SDK uses internally) // This validates that the execution environment is properly configured let content = r#" export function main() { // WM_WORKSPACE and WM_TOKEN are injected by Windmill worker return { workspace: process.env.WM_WORKSPACE, hasToken: process.env.WM_TOKEN !== undefined && process.env.WM_TOKEN.length > 0, baseUrl: process.env.BASE_URL !== undefined }; } "# .to_owned(); let job = JobPayload::Code(RawCode { hash: None, content, path: None, language: ScriptLang::Bun, lock: None, concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) .await .json_result() .unwrap(); assert_eq!(result["workspace"], serde_json::json!("test-workspace")); assert_eq!(result["hasToken"], serde_json::json!(true)); assert_eq!(result["baseUrl"], serde_json::json!(true)); Ok(()) } // ============================================================================ // Environment Variable Tests // ============================================================================ #[sqlx::test(fixtures("base"))] async fn test_bun_job_env_vars(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let content = r#" export function main() { return { workspace: process.env.WM_WORKSPACE, hasToken: !!process.env.WM_TOKEN, }; } "# .to_owned(); let job = JobPayload::Code(RawCode { hash: None, content, path: None, language: ScriptLang::Bun, lock: None, concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) .await .json_result() .unwrap(); assert_eq!(result["workspace"], serde_json::json!("test-workspace")); assert_eq!(result["hasToken"], serde_json::json!(true)); Ok(()) } // ============================================================================ // Bundle Wrapper Safety Tests // ============================================================================ /// Regression test for the "TS source ends up in the bun bundle cache" bug. /// /// The wrapper-side hardening: `node_builder.ts` discarded `Bun.build`'s /// return value, so any silent-failure mode (`success: false` without /// throwing — `throw: false`, or a future Bun where defaults change) made /// the wrapper exit 0 even though no `main.js` was written. Pair that with /// a pre-existing `main.js` containing raw TypeScript and `save_cache` /// happily copied that TS into the bundle cache; the worker later choked /// on `type GpgKey = {`. /// /// This test patches `node_builder.ts` to force the silent-failure shape /// and asserts that our wrapper now refuses to silently succeed — bun must /// exit non-zero so prebundling fails loudly instead of writing TypeScript /// into the bundle cache. #[test] fn test_bun_bundle_wrapper_catches_silent_failure() { use std::process::Command; use windmill_worker::{build_loader, LoaderMode, BUN_PATH}; let temp_dir = tempfile::tempdir().unwrap(); let dir = temp_dir.path(); let dir_str = dir.to_str().unwrap(); // Script imports a package that won't exist in node_modules. std::fs::write( dir.join("main.ts"), r#" import x from "definitely-not-a-real-pkg-windmill-test"; export function main() { return x; } "#, ) .unwrap(); // Generate the real node_builder.ts via the production code path. tokio::runtime::Runtime::new() .unwrap() .block_on(build_loader( dir_str, "http://localhost:8000", "test_token", "test-workspace", "f/test/script", LoaderMode::BunBundle, &None, )) .expect("build_loader failed"); // Force the silent-failure shape by injecting `throw: false`. The // wrapper's pre-fix `try/catch` would have swallowed this; the fixed // wrapper inspects `result.success` and `result.outputs` and exits 1. let path = dir.join("node_builder.ts"); let original = std::fs::read_to_string(&path).unwrap(); let patched = original.replace( "external: [\"electron\"],", "external: [\"electron\"], throw: false,", ); assert_ne!( original, patched, "expected to find Bun.build options block to patch; node_builder.ts template changed?" ); std::fs::write(&path, patched).unwrap(); // Pre-seed main.js with raw TypeScript (mimics the historical // pre-write that originally seeded the bug). std::fs::write( dir.join("main.js"), "type GpgKey = { email: string };\nexport const main = (): GpgKey => ({ email: \"\" });\n", ) .unwrap(); let output = Command::new(BUN_PATH.as_str()) .args(["run", path.to_str().unwrap()]) .current_dir(dir) .output() .expect("Failed to run bun"); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!( !output.status.success(), "node_builder.ts must exit non-zero when Bun.build silently fails to write a bundle.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); assert!( stdout.contains("Failed to build node bundle"), "expected diagnostic in stdout, got:\n{stdout}" ); } /// Regression test for the actual root cause of the "TS source in bundle /// cache" bug: `generate_bun_bundle` was awaiting `child_process.wait()` /// without checking the exit code on the no-DB path (used by Docker-build /// `windmill cache hubPaths.json`). bun would exit 1 after Bun.build threw, /// `wait().await?` propagated only IO errors, and `generate_bun_bundle` /// returned `Ok(())`. `save_cache` then copied a stale `main.js` (raw TS /// source) straight into the bundle cache. /// /// This test runs `generate_bun_bundle` with `db: None` against a `node_builder.ts` /// that calls `process.exit(1)`, and asserts the function now returns an error. #[test] fn test_generate_bun_bundle_propagates_exit_status() { use windmill_worker::{generate_bun_bundle, get_common_bun_proc_envs}; let temp_dir = tempfile::tempdir().unwrap(); let dir = temp_dir.path(); let dir_str = dir.to_str().unwrap(); // node_builder.ts that exits 1, mimicking what bun does when Bun.build throws. std::fs::write( dir.join("node_builder.ts"), "console.log('simulated bun build failure');\nprocess.exit(1);\n", ) .unwrap(); let runtime = tokio::runtime::Runtime::new().unwrap(); let envs = runtime.block_on(get_common_bun_proc_envs(None)); let result = runtime.block_on(generate_bun_bundle( dir_str, "test-workspace", &uuid::Uuid::new_v4(), "test-worker", None, // db: None — this is the cache_hub_scripts path that had the bug None, &mut 0, &mut None, &envs, &mut None, )); assert!( result.is_err(), "generate_bun_bundle must surface bun's non-zero exit on the no-DB path. \ If it returns Ok(()) when bun exited 1, save_cache will silently cache stale main.js content." ); let err_msg = format!("{:?}", result.unwrap_err()); assert!( err_msg.contains("non-zero status"), "expected exit-status error, got: {err_msg}" ); } /// Regression test for the install_bun_lockfile no-DB path: same code shape as /// `generate_bun_bundle` (site 3 of the original bug) — `wait().await?` ignored /// non-zero bun exits. A `bun install` failure (e.g. malformed package.json) /// must now surface as an error so callers don't proceed with a half-installed /// node_modules. #[test] fn test_install_bun_lockfile_propagates_exit_status() { use windmill_worker::{get_common_bun_proc_envs, install_bun_lockfile}; let temp_dir = tempfile::tempdir().unwrap(); let dir = temp_dir.path(); let dir_str = dir.to_str().unwrap(); // Malformed package.json -> bun install fails with exit 1. std::fs::write(dir.join("package.json"), "this is not valid json").unwrap(); let runtime = tokio::runtime::Runtime::new().unwrap(); let envs = runtime.block_on(get_common_bun_proc_envs(None)); let result = runtime.block_on(install_bun_lockfile( &mut 0, &mut None, &uuid::Uuid::new_v4(), "test-workspace", None, // db: None — no-DB path that had the bug dir_str, "test-worker", envs, false, // npm_mode &mut None, true, // quiet )); assert!( result.is_err(), "install_bun_lockfile must surface bun's non-zero exit on the no-DB path" ); let err_msg = format!("{:?}", result.unwrap_err()); assert!( err_msg.contains("non-zero status"), "expected exit-status error, got: {err_msg}" ); } /// Regression test for the post-bundle existence check in `prebundle_bun_script` /// and `handle_bun_job`. Both call sites guard against the case where /// `generate_bun_bundle` returns `Ok(())` but `main.js` was never written — /// the upstream wait-status fix is the primary defense, this is the catch-all /// for any other silent-failure mode (Bun output-naming change, custom plugin /// swallowing the build, etc.). Without this check, `save_cache` would /// happily copy whatever's at the bundle path (often raw TypeScript that some /// other code path left there). #[test] fn test_ensure_bundle_output_exists_rejects_missing_file() { use windmill_worker::ensure_bundle_output_exists; let temp_dir = tempfile::tempdir().unwrap(); let dir = temp_dir.path(); let missing = dir.join("main.js").to_str().unwrap().to_string(); let result = ensure_bundle_output_exists(&missing); assert!( result.is_err(), "ensure_bundle_output_exists must reject when the bundle file is missing" ); let err_msg = format!("{:?}", result.unwrap_err()); assert!( err_msg.contains("bun bundle output missing"), "expected 'bun bundle output missing' in error, got: {err_msg}" ); // Sanity: when the file does exist, it returns Ok. std::fs::write(&missing, "// @bun\n").unwrap(); assert!(ensure_bundle_output_exists(&missing).is_ok()); } // ============================================================================ // Dedicated Worker Protocol Tests // ============================================================================ #[cfg(feature = "private")] mod dedicated_worker_protocol { use std::io::{BufRead, BufReader, Write}; use std::process::{Command, Stdio}; use windmill_test_utils::{parse_dedicated_worker_line, DedicatedWorkerResult}; use windmill_worker::{ build_loader, compute_ts_codegen, generate_multi_script_wrapper, LoaderMode, TsScriptEntry, BUN_DEDICATED_WORKER_ARGS, BUN_PATH, NODE_BIN_PATH, }; const TEST_SCRIPT_PATH: &str = "f/test/script"; /// Creates test worker files and optionally bundles for Node.js (like production) /// Returns the path to the wrapper file to execute fn create_test_worker_files( dir: &std::path::Path, script: &str, bundle_for_node: bool, ) -> std::path::PathBuf { let dir_str = dir.to_str().unwrap(); // Write main.ts at root (like production single-script) std::fs::write(dir.join("main.ts"), script).unwrap(); let codegen = compute_ts_codegen(script); let ext = if bundle_for_node { "js" } else { "ts" }; let scripts = [TsScriptEntry { import_name: "main", original_path: TEST_SCRIPT_PATH, codegen: &codegen, }]; let wrapper = generate_multi_script_wrapper(&scripts, ext); if bundle_for_node { std::fs::write(dir.join("wrapper.mjs"), &wrapper).unwrap(); // Use the exact same build_loader function as production tokio::runtime::Runtime::new() .unwrap() .block_on(build_loader( dir_str, "http://localhost:8000", "test_token", "test-workspace", TEST_SCRIPT_PATH, LoaderMode::Node, &None, )) .expect("build_loader failed"); // Run the bundler with bun (build_loader creates node_builder.ts) let output = Command::new(BUN_PATH.as_str()) .args(["run", dir.join("node_builder.ts").to_str().unwrap()]) .current_dir(dir) .output() .expect("Failed to run bun build"); if !output.status.success() { panic!( "Bun build failed: {}", String::from_utf8_lossy(&output.stderr) ); } // Bun outputs to wrapper.js, rename to wrapper.mjs for ES module let bundled_path = dir.join("wrapper.js"); let output_path = dir.join("wrapper_bundled.mjs"); std::fs::rename(&bundled_path, &output_path).unwrap(); output_path } else { let wrapper_path = dir.join("wrapper.mjs"); std::fs::write(&wrapper_path, &wrapper).unwrap(); wrapper_path } } /// Helper to run a dedicated worker test with given runtime fn run_worker_test( runtime: &str, script: &str, jobs: Vec, ) -> Vec> { let temp_dir = tempfile::tempdir().unwrap(); // Create files and get the wrapper path (bundled for node, raw for bun) let wrapper_path = create_test_worker_files(temp_dir.path(), script, runtime == "node"); let wrapper_str = wrapper_path.to_str().unwrap(); // Build args matching production behavior let (cmd, args): (&str, Vec<&str>) = match runtime { "bun" => { // Production: bun run -i --prefer-offline wrapper.mjs let mut args: Vec<&str> = BUN_DEDICATED_WORKER_ARGS.to_vec(); args.push(wrapper_str); (BUN_PATH.as_str(), args) } "node" => { // Production: node wrapper.mjs (after bundling to JS) (NODE_BIN_PATH.as_str(), vec![wrapper_str]) } _ => panic!("Unknown runtime: {}", runtime), }; let mut child = Command::new(cmd) .args(args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .current_dir(temp_dir.path()) .spawn() .expect("Failed to spawn worker process"); let mut stdin = child.stdin.take().unwrap(); let stdout = child.stdout.take().unwrap(); let mut reader = BufReader::new(stdout); // Wait for "start" signal let mut start_line = String::new(); reader.read_line(&mut start_line).unwrap(); assert_eq!( parse_dedicated_worker_line(start_line.trim()), DedicatedWorkerResult::Start, "Expected 'start', got: {}", start_line.trim() ); let mut results = Vec::new(); for job_args in jobs { // Protocol: execd: (single-script, no path needed) writeln!(stdin, "execd:{}", job_args.to_string()).unwrap(); stdin.flush().unwrap(); let mut response = String::new(); reader.read_line(&mut response).unwrap(); match parse_dedicated_worker_line(response.trim()) { DedicatedWorkerResult::Success(value) => results.push(Ok(value)), DedicatedWorkerResult::Error(err) => { let msg = err["message"] .as_str() .unwrap_or("Unknown error") .to_string(); results.push(Err(msg)); } other => panic!("Unexpected response: {:?}", other), } } writeln!(stdin, "end").unwrap(); stdin.flush().unwrap(); let _ = child.wait().expect("Worker process failed to exit"); results } // ==================== Node.js Runtime Tests ==================== #[test] fn test_dedicated_worker_nodejs_simple() { let script = r#" export function main(x: number, y: number): number { return x + y; } "#; let results = run_worker_test("node", script, vec![serde_json::json!({"x": 5, "y": 3})]); assert_eq!(results.len(), 1); assert_eq!(results[0], Ok(serde_json::json!(8))); } #[test] fn test_dedicated_worker_nodejs_multiple_jobs() { let script = r#" export function main(n: number): number { return n * 2; } "#; let jobs: Vec = (1..=5).map(|i| serde_json::json!({"n": i})).collect(); let results = run_worker_test("node", script, jobs); assert_eq!(results.len(), 5); for (i, result) in results.iter().enumerate() { let expected = ((i + 1) * 2) as i64; assert_eq!(*result, Ok(serde_json::json!(expected))); } } #[test] fn test_dedicated_worker_nodejs_error() { let script = r#" export function main(msg: string): never { throw new Error(msg); } "#; let results = run_worker_test( "node", script, vec![serde_json::json!({"msg": "test error"})], ); assert_eq!(results.len(), 1); assert!(results[0].is_err()); assert_eq!(results[0], Err("test error".to_string())); } // ==================== Bun Runtime Tests ==================== #[test] fn test_dedicated_worker_bun_simple() { let script = r#" export function main(x: number, y: number): number { return x + y; } "#; let results = run_worker_test("bun", script, vec![serde_json::json!({"x": 5, "y": 3})]); assert_eq!(results.len(), 1); assert_eq!(results[0], Ok(serde_json::json!(8))); } #[test] fn test_dedicated_worker_bun_multiple_jobs() { let script = r#" export function main(n: number): number { return n * 2; } "#; let jobs: Vec = (1..=5).map(|i| serde_json::json!({"n": i})).collect(); let results = run_worker_test("bun", script, jobs); assert_eq!(results.len(), 5); for (i, result) in results.iter().enumerate() { let expected = ((i + 1) * 2) as i64; assert_eq!(*result, Ok(serde_json::json!(expected))); } } #[test] fn test_dedicated_worker_bun_error() { let script = r#" export function main(msg: string): never { throw new Error(msg); } "#; let results = run_worker_test( "bun", script, vec![serde_json::json!({"msg": "test error"})], ); assert_eq!(results.len(), 1); assert!(results[0].is_err()); assert_eq!(results[0], Err("test error".to_string())); } // ==================== Multi-Script (Runner Group) Tests ==================== /// Job to send to a specific script in a multi-script wrapper struct MultiScriptJob { script_path: String, args: serde_json::Value, } /// Creates a multi-script wrapper with multiple scripts as flat files, returns the wrapper path fn create_multi_script_worker_files( dir: &std::path::Path, scripts: &[(&str, &str)], // (original_path, script_content) ) -> std::path::PathBuf { let mut entries_data = Vec::new(); for (path, content) in scripts { let safe_name = format!("_wm_{}", path.replace('/', "__")); std::fs::write(dir.join(format!("{safe_name}.ts")), content).unwrap(); entries_data.push((safe_name, path.to_string(), compute_ts_codegen(content))); } let entries: Vec> = entries_data .iter() .map(|(safe, path, cg)| TsScriptEntry { import_name: safe.as_str(), original_path: path.as_str(), codegen: cg, }) .collect(); let wrapper = generate_multi_script_wrapper(&entries, "ts"); let wrapper_path = dir.join("wrapper.mjs"); std::fs::write(&wrapper_path, &wrapper).unwrap(); wrapper_path } /// Helper to run a multi-script dedicated worker test fn run_multi_script_worker_test( scripts: &[(&str, &str)], jobs: Vec, ) -> Vec> { let temp_dir = tempfile::tempdir().unwrap(); let wrapper_path = create_multi_script_worker_files(temp_dir.path(), scripts); let wrapper_str = wrapper_path.to_str().unwrap(); let mut cmd_args: Vec<&str> = BUN_DEDICATED_WORKER_ARGS.to_vec(); cmd_args.push(wrapper_str); let mut child = Command::new(BUN_PATH.as_str()) .args(cmd_args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .current_dir(temp_dir.path()) .spawn() .expect("Failed to spawn worker process"); let mut stdin = child.stdin.take().unwrap(); let stdout = child.stdout.take().unwrap(); let mut reader = BufReader::new(stdout); // Wait for "start" signal let mut start_line = String::new(); reader.read_line(&mut start_line).unwrap(); assert_eq!( parse_dedicated_worker_line(start_line.trim()), DedicatedWorkerResult::Start, "Expected 'start', got: {}", start_line.trim() ); let mut results = Vec::new(); for job in &jobs { writeln!(stdin, "exec:{}:{}", job.script_path, job.args.to_string()).unwrap(); stdin.flush().unwrap(); let mut response = String::new(); reader.read_line(&mut response).unwrap(); match parse_dedicated_worker_line(response.trim()) { DedicatedWorkerResult::Success(value) => results.push(Ok(value)), DedicatedWorkerResult::Error(err) => { let msg = err["message"] .as_str() .unwrap_or("Unknown error") .to_string(); results.push(Err(msg)); } other => panic!("Unexpected response: {:?}", other), } } writeln!(stdin, "end").unwrap(); stdin.flush().unwrap(); let _ = child.wait().expect("Worker process failed to exit"); results } #[test] fn test_multi_script_routing_basic() { let script_add = r#" export function main(a: number, b: number): number { return a + b; } "#; let script_mul = r#" export function main(x: number, y: number): number { return x * y; } "#; let results = run_multi_script_worker_test( &[("f/math/add", script_add), ("f/math/mul", script_mul)], vec![ MultiScriptJob { script_path: "f/math/add".to_string(), args: serde_json::json!({"a": 3, "b": 4}), }, MultiScriptJob { script_path: "f/math/mul".to_string(), args: serde_json::json!({"x": 5, "y": 6}), }, // Route back to add MultiScriptJob { script_path: "f/math/add".to_string(), args: serde_json::json!({"a": 10, "b": 20}), }, ], ); assert_eq!(results.len(), 3); assert_eq!(results[0], Ok(serde_json::json!(7))); // 3 + 4 assert_eq!(results[1], Ok(serde_json::json!(30))); // 5 * 6 assert_eq!(results[2], Ok(serde_json::json!(30))); // 10 + 20 } #[test] fn test_multi_script_interleaved_jobs() { let script_upper = r#" export function main(s: string): string { return s.toUpperCase(); } "#; let script_len = r#" export function main(s: string): number { return s.length; } "#; let results = run_multi_script_worker_test( &[("f/str/upper", script_upper), ("f/str/len", script_len)], vec![ MultiScriptJob { script_path: "f/str/upper".to_string(), args: serde_json::json!({"s": "hello"}), }, MultiScriptJob { script_path: "f/str/len".to_string(), args: serde_json::json!({"s": "hello"}), }, MultiScriptJob { script_path: "f/str/upper".to_string(), args: serde_json::json!({"s": "world"}), }, MultiScriptJob { script_path: "f/str/len".to_string(), args: serde_json::json!({"s": "ab"}), }, ], ); assert_eq!(results.len(), 4); assert_eq!(results[0], Ok(serde_json::json!("HELLO"))); assert_eq!(results[1], Ok(serde_json::json!(5))); assert_eq!(results[2], Ok(serde_json::json!("WORLD"))); assert_eq!(results[3], Ok(serde_json::json!(2))); } #[test] fn test_multi_script_unknown_path_error() { let script = r#" export function main(x: number): number { return x; } "#; let results = run_multi_script_worker_test( &[("f/known", script)], vec![MultiScriptJob { script_path: "f/unknown".to_string(), args: serde_json::json!({"x": 1}), }], ); assert_eq!(results.len(), 1); assert!(results[0].is_err()); assert!(results[0] .as_ref() .unwrap_err() .contains("Script not found")); } #[test] fn test_multi_script_error_doesnt_break_other_scripts() { let script_ok = r#" export function main(x: number): number { return x * 2; } "#; let script_err = r#" export function main(msg: string): never { throw new Error(msg); } "#; let results = run_multi_script_worker_test( &[("f/ok", script_ok), ("f/err", script_err)], vec![ MultiScriptJob { script_path: "f/ok".to_string(), args: serde_json::json!({"x": 5}), }, MultiScriptJob { script_path: "f/err".to_string(), args: serde_json::json!({"msg": "boom"}), }, // Should still work after error in other script MultiScriptJob { script_path: "f/ok".to_string(), args: serde_json::json!({"x": 10}), }, ], ); assert_eq!(results.len(), 3); assert_eq!(results[0], Ok(serde_json::json!(10))); assert!(results[1].is_err()); assert_eq!(results[1], Err("boom".to_string())); assert_eq!(results[2], Ok(serde_json::json!(20))); } // ==================== exec_preprocess Tests ==================== /// Raw protocol command to send to a dedicated worker enum ProtocolCmd { Exec { path: String, args: serde_json::Value }, ExecPreprocess { path: String, args: serde_json::Value }, } /// Run a multi-script worker test with raw protocol commands, returning all protocol lines fn run_raw_protocol_test( scripts: &[(&str, &str)], commands: Vec, ) -> Vec { let temp_dir = tempfile::tempdir().unwrap(); let wrapper_path = create_multi_script_worker_files(temp_dir.path(), scripts); let wrapper_str = wrapper_path.to_str().unwrap(); let mut cmd_args: Vec<&str> = BUN_DEDICATED_WORKER_ARGS.to_vec(); cmd_args.push(wrapper_str); let mut child = Command::new(BUN_PATH.as_str()) .args(cmd_args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .current_dir(temp_dir.path()) .spawn() .expect("Failed to spawn worker process"); let mut stdin = child.stdin.take().unwrap(); let stdout = child.stdout.take().unwrap(); let mut reader = BufReader::new(stdout); let mut start_line = String::new(); reader.read_line(&mut start_line).unwrap(); assert_eq!( parse_dedicated_worker_line(start_line.trim()), DedicatedWorkerResult::Start, ); let mut results = Vec::new(); for cmd in &commands { let line = match cmd { ProtocolCmd::Exec { path, args } => format!("exec:{}:{}", path, args), ProtocolCmd::ExecPreprocess { path, args } => { format!("exec_preprocess:{}:{}", path, args) } }; writeln!(stdin, "{}", line).unwrap(); stdin.flush().unwrap(); // exec_preprocess produces 2 response lines (preprocessed_args + success/error) // exec produces 1 response line (success/error) let expected_lines = match cmd { ProtocolCmd::ExecPreprocess { .. } => 2, ProtocolCmd::Exec { .. } => 1, }; for _ in 0..expected_lines { let mut response = String::new(); reader.read_line(&mut response).unwrap(); let parsed = parse_dedicated_worker_line(response.trim()); // If it's an error, stop reading more lines for this command if matches!(parsed, DedicatedWorkerResult::Error(_)) { results.push(parsed); break; } results.push(parsed); } } writeln!(stdin, "end").unwrap(); stdin.flush().unwrap(); let _ = child.wait().expect("Worker process failed to exit"); results } #[test] fn test_bun_exec_preprocess() { let script = r#" export function preprocessor(x: number) { return { x: x * 10 }; } export function main(x: number): number { return x + 1; } "#; let results = run_raw_protocol_test( &[("f/test/pre", script)], vec![ProtocolCmd::ExecPreprocess { path: "f/test/pre".to_string(), args: serde_json::json!({"x": 5}), }], ); // Should get preprocessed_args then success assert_eq!(results.len(), 2); assert_eq!( results[0], DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 50})) ); // main(50) => 51 assert_eq!( results[1], DedicatedWorkerResult::Success(serde_json::json!(51)) ); } #[test] fn test_bun_exec_preprocess_missing_preprocessor() { let script = r#" export function main(x: number): number { return x; } "#; let results = run_raw_protocol_test( &[("f/test/nopre", script)], vec![ProtocolCmd::ExecPreprocess { path: "f/test/nopre".to_string(), args: serde_json::json!({"x": 5}), }], ); assert_eq!(results.len(), 1); assert!(matches!(results[0], DedicatedWorkerResult::Error(_))); } #[test] fn test_bun_exec_preprocess_then_exec() { let script = r#" export function preprocessor(x: number) { return { x: x * 2 }; } export function main(x: number): number { return x + 100; } "#; let results = run_raw_protocol_test( &[("f/test/mixed", script)], vec![ ProtocolCmd::ExecPreprocess { path: "f/test/mixed".to_string(), args: serde_json::json!({"x": 5}), }, ProtocolCmd::Exec { path: "f/test/mixed".to_string(), args: serde_json::json!({"x": 7}), }, ], ); // preprocess: preprocessor(5) => {"x":10}, main(10) => 110 // exec: main(7) => 107 assert_eq!(results.len(), 3); assert_eq!( results[0], DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 10})) ); assert_eq!( results[1], DedicatedWorkerResult::Success(serde_json::json!(110)) ); assert_eq!( results[2], DedicatedWorkerResult::Success(serde_json::json!(107)) ); } // ==================== Argument Transformation Tests ==================== #[test] fn test_bun_date_arg_transformation() { let script = r#" export function main(d: Date): string { return d instanceof Date ? d.toISOString() : typeof d; } "#; let results = run_worker_test( "bun", script, vec![serde_json::json!({"d": "2024-01-15T10:30:00.000Z"})], ); assert_eq!(results.len(), 1); assert_eq!( results[0], Ok(serde_json::json!("2024-01-15T10:30:00.000Z")) ); } #[test] fn test_bun_null_and_undefined_args() { let script = r#" export function main(x?: number): string { return x === null ? "null" : x === undefined ? "undefined" : String(x); } "#; let results = run_worker_test( "bun", script, vec![ serde_json::json!({"x": null}), serde_json::json!({"x": 42}), serde_json::json!({}), ], ); assert_eq!(results.len(), 3); assert_eq!(results[0], Ok(serde_json::json!("null"))); assert_eq!(results[1], Ok(serde_json::json!("42"))); // Missing arg should be undefined assert_eq!(results[2], Ok(serde_json::json!("undefined"))); } } // ============================================================================ // Deno Dedicated Worker Protocol Tests // ============================================================================ #[cfg(feature = "private")] mod dedicated_worker_protocol_deno { use std::io::{BufRead, BufReader, Write}; use std::process::{Command, Stdio}; use windmill_test_utils::{parse_dedicated_worker_line, DedicatedWorkerResult}; use windmill_worker::{generate_deno_dedicated_worker_wrapper, DENO_PATH}; fn run_deno_worker_test( script: &str, jobs: Vec, ) -> Vec> { let temp_dir = tempfile::tempdir().unwrap(); std::fs::write(temp_dir.path().join("main.ts"), script).unwrap(); let wrapper = generate_deno_dedicated_worker_wrapper(script).unwrap(); std::fs::write(temp_dir.path().join("wrapper.ts"), &wrapper).unwrap(); let mut child = Command::new(DENO_PATH.as_str()) .args([ "run", "--no-check", "--unstable-unsafe-proto", "--unstable-bare-node-builtins", "-A", "wrapper.ts", ]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .current_dir(temp_dir.path()) .spawn() .expect("Failed to spawn deno process"); let mut stdin = child.stdin.take().unwrap(); let stdout = child.stdout.take().unwrap(); let mut reader = BufReader::new(stdout); // Wait for "start" — deno outputs 'start\n' via console.log which adds // its own newline, producing double newlines. Skip empty lines. loop { let mut line = String::new(); reader.read_line(&mut line).unwrap(); if line.trim().is_empty() { continue; } assert_eq!( parse_dedicated_worker_line(line.trim()), DedicatedWorkerResult::Start, "Expected 'start', got: {}", line.trim() ); break; } let mut results = Vec::new(); for job_args in jobs { writeln!(stdin, "execd:{}", job_args.to_string()).unwrap(); stdin.flush().unwrap(); loop { let mut response = String::new(); reader.read_line(&mut response).unwrap(); let trimmed = response.trim(); if trimmed.is_empty() { continue; } match parse_dedicated_worker_line(trimmed) { DedicatedWorkerResult::Success(value) => results.push(Ok(value)), DedicatedWorkerResult::Error(err) => { let msg = err["message"] .as_str() .unwrap_or("Unknown error") .to_string(); results.push(Err(msg)); } other => panic!("Unexpected response: {:?}", other), } break; } } writeln!(stdin, "end").unwrap(); stdin.flush().unwrap(); let _ = child.wait().expect("Worker process failed to exit"); results } #[test] fn test_deno_dedicated_worker_simple() { let script = r#" export function main(x: number, y: number): number { return x + y; } "#; let results = run_deno_worker_test(script, vec![serde_json::json!({"x": 5, "y": 3})]); assert_eq!(results.len(), 1); assert_eq!(results[0], Ok(serde_json::json!(8))); } #[test] fn test_deno_dedicated_worker_multiple_jobs() { let script = r#" export function main(n: number): number { return n * 2; } "#; let jobs: Vec = (1..=5).map(|i| serde_json::json!({"n": i})).collect(); let results = run_deno_worker_test(script, jobs); assert_eq!(results.len(), 5); for (i, result) in results.iter().enumerate() { assert_eq!(*result, Ok(serde_json::json!(((i + 1) * 2) as i64))); } } #[test] fn test_deno_dedicated_worker_error() { let script = r#" export function main(msg: string): never { throw new Error(msg); } "#; let results = run_deno_worker_test(script, vec![serde_json::json!({"msg": "test error"})]); assert_eq!(results.len(), 1); assert!(results[0].is_err()); assert_eq!(results[0], Err("test error".to_string())); } // ==================== exec_preprocess Tests ==================== /// Run a raw deno protocol test, reading all output lines per command fn run_deno_raw_protocol_test( script: &str, commands: Vec<(&str, serde_json::Value)>, // ("exec" or "exec_preprocess", args) ) -> Vec { let temp_dir = tempfile::tempdir().unwrap(); std::fs::write(temp_dir.path().join("main.ts"), script).unwrap(); let wrapper = generate_deno_dedicated_worker_wrapper(script).unwrap(); std::fs::write(temp_dir.path().join("wrapper.ts"), &wrapper).unwrap(); let mut child = Command::new(DENO_PATH.as_str()) .args([ "run", "--no-check", "--unstable-unsafe-proto", "--unstable-bare-node-builtins", "-A", "wrapper.ts", ]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .current_dir(temp_dir.path()) .spawn() .expect("Failed to spawn deno process"); let mut stdin = child.stdin.take().unwrap(); let stdout = child.stdout.take().unwrap(); let mut reader = BufReader::new(stdout); // Wait for start, skip empty lines loop { let mut line = String::new(); reader.read_line(&mut line).unwrap(); if line.trim().is_empty() { continue; } assert_eq!( parse_dedicated_worker_line(line.trim()), DedicatedWorkerResult::Start, ); break; } let mut results = Vec::new(); for (cmd, args) in &commands { // Single-script Deno wrapper uses execd:/execd_preprocess: (no path) let direct_cmd = if *cmd == "exec_preprocess" { "execd_preprocess" } else { "execd" }; writeln!(stdin, "{}:{}", direct_cmd, args).unwrap(); stdin.flush().unwrap(); let expected_lines = if *cmd == "exec_preprocess" { 2 } else { 1 }; for _ in 0..expected_lines { loop { let mut response = String::new(); reader.read_line(&mut response).unwrap(); if response.trim().is_empty() { continue; } let parsed = parse_dedicated_worker_line(response.trim()); if matches!(parsed, DedicatedWorkerResult::Error(_)) { results.push(parsed); break; } results.push(parsed); break; } // If last result was an error, don't read more lines for this command if matches!(results.last(), Some(DedicatedWorkerResult::Error(_))) { break; } } } writeln!(stdin, "end").unwrap(); stdin.flush().unwrap(); let _ = child.wait().expect("Worker process failed to exit"); results } #[test] fn test_deno_exec_preprocess() { let script = r#" export function preprocessor(x: number) { return { x: x * 10 }; } export function main(x: number): number { return x + 1; } "#; let results = run_deno_raw_protocol_test( script, vec![("exec_preprocess", serde_json::json!({"x": 5}))], ); assert_eq!(results.len(), 2); assert_eq!( results[0], DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 50})) ); assert_eq!( results[1], DedicatedWorkerResult::Success(serde_json::json!(51)) ); } // Note: no "missing preprocessor" test for Deno because the wrapper only generates // the exec_preprocess handler when the script actually has a preprocessor function. // Without one, exec_preprocess messages are unrecognized (by design — Rust never sends them). // ==================== Argument Transformation Tests ==================== #[test] fn test_deno_date_arg_transformation() { let script = r#" export function main(d: Date): string { return d instanceof Date ? d.toISOString() : typeof d; } "#; let results = run_deno_worker_test( script, vec![serde_json::json!({"d": "2024-01-15T10:30:00.000Z"})], ); assert_eq!(results.len(), 1); assert_eq!( results[0], Ok(serde_json::json!("2024-01-15T10:30:00.000Z")) ); } } // ============================================================================ // Private Registry Tests // ============================================================================ /// Test that bun can install packages from a private npm registry with authentication. /// The registry requires auth tokens for accessing @windmill-test/* packages. /// Requires: /// - `private_registry_test` feature enabled /// - `TEST_NPM_REGISTRY` environment variable set to registry URL with auth token /// Format: `http://registry-url/:_authToken=TOKEN` #[cfg(feature = "private_registry_test")] #[sqlx::test(fixtures("base"))] async fn test_bun_job_private_npm_registry(db: Pool) -> anyhow::Result<()> { use windmill_worker::NPM_CONFIG_REGISTRY; let registry_url = std::env::var("TEST_NPM_REGISTRY") .expect("TEST_NPM_REGISTRY must be set when running private_registry_test"); initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); // Set the private registry configuration { let mut registry = NPM_CONFIG_REGISTRY.write().await; *registry = Some(registry_url.clone()); } let content = r#" import { greet } from "@windmill-test/private-pkg"; export function main(name: string) { return greet(name); } "# .to_owned(); let job = JobPayload::Code(RawCode { hash: None, content, path: None, language: ScriptLang::Bun, lock: None, concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); let result = RunJob::from(job) .arg("name", serde_json::json!("World")) .run_until_complete(&db, false, port) .await .json_result() .unwrap(); // Clean up { let mut registry = NPM_CONFIG_REGISTRY.write().await; *registry = None; } assert_eq!( result, serde_json::json!("Hello from private package, World!") ); Ok(()) } /// Test that full .npmrc content works for bun jobs with private registries. /// Requires: /// - `TEST_NPMRC` environment variable set to the full .npmrc content #[cfg(feature = "private_registry_test")] #[sqlx::test(fixtures("base"))] async fn test_bun_job_private_npmrc(db: Pool) -> anyhow::Result<()> { use windmill_worker::NPMRC; let npmrc_content = std::env::var("TEST_NPMRC") .expect("TEST_NPMRC must be set when running private_registry_test"); initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); { let mut npmrc = NPMRC.write().await; *npmrc = Some(npmrc_content.clone()); } let content = r#" import { greet } from "@windmill-test/private-pkg"; export function main(name: string) { return greet(name); } "# .to_owned(); let job = JobPayload::Code(RawCode { hash: None, content, path: None, language: ScriptLang::Bun, lock: None, concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); let result = RunJob::from(job) .arg("name", serde_json::json!("World")) .run_until_complete(&db, false, port) .await .json_result() .unwrap(); { let mut npmrc = NPMRC.write().await; *npmrc = None; } assert_eq!( result, serde_json::json!("Hello from private package, World!") ); Ok(()) } /// Tests for RELATIVE_BUN_BUILDER (loader_builder.bun.js) /// These tests verify Bun's behavior for import scanning and package.json generation. /// Purpose: Catch regressions when upgrading Bun versions. mod bun_builder_tests { use std::process::{Command, Stdio}; use windmill_worker::{BUN_PATH, RELATIVE_BUN_BUILDER, RELATIVE_BUN_LOADER}; /// Run the builder and return the generated package.json content fn run_builder(main_ts_content: &str) -> serde_json::Value { let temp_dir = tempfile::tempdir().unwrap(); let dir = temp_dir.path(); // Write main.ts std::fs::write(dir.join("main.ts"), main_ts_content).unwrap(); // Write build.js using the loader and builder constants directly // Parameters are dummy values since tests don't use Windmill relative imports let loader = RELATIVE_BUN_LOADER .replace("TEMP_SCRIPT_REFS_PLACEHOLDER", "{}") .replace("W_ID", "test-workspace") .replace("BASE_INTERNAL_URL", "http://localhost:8000") .replace("TOKEN", "test-token") .replace("CURRENT_PATH", "f/test/script") .replace("RAW_GET_ENDPOINT", "raw"); let build_script = format!( r#" {loader} {RELATIVE_BUN_BUILDER} "# ); std::fs::write(dir.join("build.js"), build_script).unwrap(); // Run bun build.js let output = Command::new(BUN_PATH.as_str()) .args(["run", "build.js"]) .current_dir(dir) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .expect("Failed to run bun"); if !output.status.success() { panic!( "Builder failed:\nstdout: {}\nstderr: {}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); } // Read generated package.json let package_json = std::fs::read_to_string(dir.join("package.json")).expect("package.json not generated"); serde_json::from_str(&package_json).expect("Invalid JSON in package.json") } /// Test: scanImports() detects basic imports #[test] fn test_builder_simple_import() { let main_ts = r#" import lodash from "lodash"; export function main() { return lodash; } "#; let pkg = run_builder(main_ts); let deps = pkg["dependencies"].as_object().unwrap(); assert!( deps.contains_key("lodash"), "lodash should be in dependencies" ); assert_eq!(deps["lodash"], "latest"); } /// Test: scanImports() preserves version info from versioned imports #[test] fn test_builder_versioned_import() { let main_ts = r#" import _ from "lodash@4.17.21"; export function main() { return _; } "#; let pkg = run_builder(main_ts); let deps = pkg["dependencies"].as_object().unwrap(); assert!( deps.contains_key("lodash"), "lodash should be in dependencies" ); assert_eq!(deps["lodash"], "4.17.21"); } /// Test: scanImports() handles @scope/package correctly #[test] fn test_builder_scoped_package() { let main_ts = r#" import babel from "@babel/core"; export function main() { return babel; } "#; let pkg = run_builder(main_ts); let deps = pkg["dependencies"].as_object().unwrap(); assert!( deps.contains_key("@babel/core"), "@babel/core should be in dependencies" ); assert_eq!(deps["@babel/core"], "latest"); } /// Test: scanImports() handles multiple imports #[test] fn test_builder_multiple_packages() { let main_ts = r#" import lodash from "lodash"; import axios from "axios"; import dayjs from "dayjs"; export function main() { return { lodash, axios, dayjs }; } "#; let pkg = run_builder(main_ts); let deps = pkg["dependencies"].as_object().unwrap(); assert!( deps.contains_key("lodash"), "lodash should be in dependencies" ); assert!( deps.contains_key("axios"), "axios should be in dependencies" ); assert!( deps.contains_key("dayjs"), "dayjs should be in dependencies" ); assert_eq!(deps.len(), 3, "Should have exactly 3 dependencies"); } /// Test: isBuiltin() filters out Node.js builtins #[test] fn test_builder_builtin_skipped() { let main_ts = r#" import fs from "fs"; import path from "path"; import lodash from "lodash"; export function main() { return { fs, path, lodash }; } "#; let pkg = run_builder(main_ts); let deps = pkg["dependencies"].as_object().unwrap(); assert!( !deps.contains_key("fs"), "fs (builtin) should NOT be in dependencies" ); assert!( !deps.contains_key("path"), "path (builtin) should NOT be in dependencies" ); assert!( deps.contains_key("lodash"), "lodash should be in dependencies" ); assert_eq!( deps.len(), 1, "Should have exactly 1 dependency (lodash only)" ); } /// Test: semver.order() resolves version conflicts (picks lowest version) #[test] fn test_builder_version_conflict() { // This test simulates what happens when the same package is imported with different versions // The builder should use semver.order() to pick the lowest version let main_ts = r#" import a from "lodash@4.17.21"; import b from "lodash@4.17.10"; export function main() { return { a, b }; } "#; let pkg = run_builder(main_ts); let deps = pkg["dependencies"].as_object().unwrap(); assert!( deps.contains_key("lodash"), "lodash should be in dependencies" ); // The builder sorts by semver and picks the first (lowest) version assert_eq!( deps["lodash"], "4.17.10", "Should resolve to lower version 4.17.10" ); } } // ============================================================================ // Codebase Mode Tests // ============================================================================ /// Create a TAR archive in memory containing a single `main.js` file. fn create_codebase_tar(main_js_content: &str) -> Vec { let mut builder = tar::Builder::new(Vec::new()); let content = main_js_content.as_bytes(); let mut header = tar::Header::new_gnu(); header.set_path("main.js").unwrap(); header.set_size(content.len() as u64); header.set_mode(0o644); header.set_cksum(); builder.append(&header, content).unwrap(); builder.into_inner().unwrap() } /// Place a TAR codebase at the expected cache path for the given job ID and hash. fn place_codebase_in_cache(job_id: &Uuid, tar_bytes: &[u8], is_esm: bool) { let codebase_id = if is_esm { format!("{}.esm.tar", job_id) } else { format!("{}.tar", job_id) }; let bundle_path = format!("script_bundle/test-workspace/{}", codebase_id); let cache_path = format!( "{}/{}.tar", *windmill_common::worker::ROOT_CACHE_NOMOUNT_DIR, bundle_path, ); let parent = std::path::Path::new(&cache_path).parent().unwrap(); std::fs::create_dir_all(parent).unwrap(); std::fs::write(&cache_path, tar_bytes).unwrap(); } #[sqlx::test(fixtures("base"))] async fn test_cjs_codebase_tar(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let main_js = r#" module.exports.main = function() { return "cjs codebase ok"; }; "#; let inner_content = r#"export function main() { return "cjs codebase ok"; }"#; let job_id = Uuid::new_v4(); let tar_bytes = create_codebase_tar(main_js); place_codebase_in_cache(&job_id, &tar_bytes, false); let job = JobPayload::Code(RawCode { hash: Some(-43), // PREVIEW_IS_TAR_CODEBASE_HASH content: inner_content.to_string(), path: None, language: ScriptLang::Bun, lock: None, concurrency_settings: Default::default(), debouncing_settings: Default::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); let result = RunJob::from(job) .job_id(job_id) .run_until_complete(&db, false, port) .await .json_result() .unwrap(); assert_eq!(result, serde_json::json!("cjs codebase ok")); Ok(()) } #[sqlx::test(fixtures("base"))] async fn test_esm_codebase_tar(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let main_js = r#" export function main() { return "esm codebase ok"; } "#; let inner_content = r#"export function main() { return "esm codebase ok"; }"#; let job_id = Uuid::new_v4(); let tar_bytes = create_codebase_tar(main_js); place_codebase_in_cache(&job_id, &tar_bytes, true); let job = JobPayload::Code(RawCode { hash: Some(-45), // PREVIEW_IS_TAR_ESM_CODEBASE_HASH content: inner_content.to_string(), path: None, language: ScriptLang::Bun, lock: None, concurrency_settings: Default::default(), debouncing_settings: Default::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); let result = RunJob::from(job) .job_id(job_id) .run_until_complete(&db, false, port) .await .json_result() .unwrap(); assert_eq!(result, serde_json::json!("esm codebase ok")); Ok(()) } #[sqlx::test(fixtures("base"))] async fn test_cjs_codebase_tar_nsjail(db: Pool) -> anyhow::Result<()> { if std::process::Command::new("nsjail") .arg("--help") .output() .is_err() { eprintln!("nsjail not found, skipping test"); return Ok(()); } initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let main_js = r#" module.exports.main = function() { return "cjs nsjail ok"; }; "#; let inner_content = r#"export function main() { return "cjs nsjail ok"; }"#; let job_id = Uuid::new_v4(); let tar_bytes = create_codebase_tar(main_js); place_codebase_in_cache(&job_id, &tar_bytes, false); let job = JobPayload::Code(RawCode { hash: Some(-43), content: inner_content.to_string(), path: None, language: ScriptLang::Bun, lock: None, concurrency_settings: Default::default(), debouncing_settings: Default::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); use std::sync::atomic::Ordering; windmill_worker::JOB_ISOLATION.store( windmill_worker::JobIsolationLevel::NsjailSandboxing as u8, Ordering::Relaxed, ); let result = RunJob::from(job) .job_id(job_id) .run_until_complete(&db, false, port) .await; windmill_worker::JOB_ISOLATION.store( windmill_worker::JobIsolationLevel::Undefined as u8, Ordering::Relaxed, ); let json = result.json_result().unwrap(); assert_eq!(json, serde_json::json!("cjs nsjail ok")); Ok(()) } #[sqlx::test(fixtures("base"))] async fn test_esm_codebase_tar_nsjail(db: Pool) -> anyhow::Result<()> { if std::process::Command::new("nsjail") .arg("--help") .output() .is_err() { eprintln!("nsjail not found, skipping test"); return Ok(()); } initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); let main_js = r#" export function main() { return "esm nsjail ok"; } "#; let inner_content = r#"export function main() { return "esm nsjail ok"; }"#; let job_id = Uuid::new_v4(); let tar_bytes = create_codebase_tar(main_js); place_codebase_in_cache(&job_id, &tar_bytes, true); let job = JobPayload::Code(RawCode { hash: Some(-45), content: inner_content.to_string(), path: None, language: ScriptLang::Bun, lock: None, concurrency_settings: Default::default(), debouncing_settings: Default::default(), cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, modules: None, tag: None, }); use std::sync::atomic::Ordering; windmill_worker::JOB_ISOLATION.store( windmill_worker::JobIsolationLevel::NsjailSandboxing as u8, Ordering::Relaxed, ); let result = RunJob::from(job) .job_id(job_id) .run_until_complete(&db, false, port) .await; windmill_worker::JOB_ISOLATION.store( windmill_worker::JobIsolationLevel::Undefined as u8, Ordering::Relaxed, ); let json = result.json_result().unwrap(); assert_eq!(json, serde_json::json!("esm nsjail ok")); Ok(()) }