use super::extras::Language;
use super::output::{ArgvRunConfig, ArgvStep, StringOrVec, TestAppRunConfig};
use super::shell::quote_word;
use super::tools::{LangContext, require_ruby_bundler, require_tool, ruby_bundle, ruby_bundle_exec};
#[cfg(all(test, unix))]
mod shell_safety_tests;
fn strip_version_constraint(version: &str) -> &str {
version.trim_start_matches(['^', '~', '>', '<', '='])
}
pub fn default_test_apps_run_config(
lang: Language,
test_apps_dir: &str,
ctx: &LangContext,
published_version: Option<&str>,
go_module_path: Option<&str>,
) -> TestAppRunConfig {
let dir = quote_word(test_apps_dir);
match lang {
Language::Rust => TestAppRunConfig {
precondition: Some(require_tool("cargo")),
before: None,
run: Some(StringOrVec::Single(format!("cd {dir}/rust && cargo test"))),
argv_run: None,
},
Language::Python => {
let pm = ctx.tools.python_pm();
let run = match pm {
"pip" => format!("cd {dir}/python && pip install -e . && pytest"),
"poetry" => format!("cd {dir}/python && poetry install && poetry run pytest"),
_ => format!("cd {dir}/python && uv sync && uv run pytest"),
};
TestAppRunConfig {
precondition: Some(require_tool(pm)),
before: None,
run: Some(StringOrVec::Single(run)),
argv_run: None,
}
}
Language::Node => {
let pm = ctx.tools.node_pm();
let run = match pm {
"npm" => format!("cd {dir}/node && npm install --no-package-lock && npm test"),
"yarn" => format!("cd {dir}/node && yarn install && yarn test"),
_ => format!(
"cd {dir}/node && pnpm install --no-frozen-lockfile --config.minimumReleaseAge=0 && pnpm --config.minimumReleaseAge=0 test"
),
};
TestAppRunConfig {
precondition: Some(require_tool(pm)),
before: None,
run: Some(StringOrVec::Single(run)),
argv_run: None,
}
}
Language::Wasm => {
let pm = ctx.tools.node_pm();
let run = match pm {
"npm" => format!("cd {dir}/wasm && npm install --no-package-lock && npm test"),
"yarn" => format!("cd {dir}/wasm && yarn install && yarn test"),
_ => format!(
"cd {dir}/wasm && pnpm install --no-frozen-lockfile --config.minimumReleaseAge=0 && pnpm --config.minimumReleaseAge=0 test"
),
};
TestAppRunConfig {
precondition: Some(require_tool(pm)),
before: None,
run: Some(StringOrVec::Single(run)),
argv_run: None,
}
}
Language::Ruby => TestAppRunConfig {
precondition: Some(require_ruby_bundler()),
before: None,
run: Some(StringOrVec::Single(format!(
"cd {dir}/ruby && {} && {}",
ruby_bundle("install"),
ruby_bundle_exec("rspec")
))),
argv_run: None,
},
Language::Php => {
let mut install_args = vec!["install.sh".to_owned()];
if let Some(version) = published_version
.map(strip_version_constraint)
.filter(|v| !v.is_empty())
{
install_args.push(version.to_owned());
}
TestAppRunConfig {
precondition: Some(require_tool("composer")),
before: None,
run: None,
argv_run: Some(ArgvRunConfig {
work_dir: format!("{test_apps_dir}/php"),
env: Vec::new(),
steps: vec![
ArgvStep {
command: "bash".to_owned(),
args: install_args,
},
ArgvStep {
command: "composer".to_owned(),
args: vec!["install".to_owned()],
},
ArgvStep {
command: "composer".to_owned(),
args: vec!["test".to_owned()],
},
],
}),
}
}
Language::Elixir => TestAppRunConfig {
precondition: Some(require_tool("mix")),
before: None,
run: Some(StringOrVec::Single(format!(
"cd {dir}/elixir && mix deps.get && mix test"
))),
argv_run: None,
},
Language::Go => {
let mut steps = vec![ArgvStep {
command: "go".to_owned(),
args: vec!["mod".to_owned(), "tidy".to_owned()],
}];
if let Some(mod_path) = go_module_path {
steps.push(ArgvStep {
command: "go".to_owned(),
args: vec!["run".to_owned(), format!("{mod_path}/cmd/setup")],
});
}
steps.push(ArgvStep {
command: "go".to_owned(),
args: vec!["test".to_owned(), "./...".to_owned()],
});
TestAppRunConfig {
precondition: Some(require_tool("go")),
before: None,
run: None,
argv_run: Some(ArgvRunConfig {
work_dir: format!("{test_apps_dir}/go"),
env: vec![("GOWORK".to_owned(), "off".to_owned())],
steps,
}),
}
}
Language::Java => TestAppRunConfig {
precondition: Some(require_tool("mvn")),
before: None,
run: Some(StringOrVec::Single(format!(
"cd {dir}/java && mvn --batch-mode --no-transfer-progress test"
))),
argv_run: None,
},
Language::Csharp => TestAppRunConfig {
precondition: Some(require_tool("dotnet")),
before: None,
run: Some(StringOrVec::Single(format!("cd {dir}/csharp && dotnet test"))),
argv_run: None,
},
Language::Kotlin => TestAppRunConfig {
precondition: Some(require_tool("gradle")),
before: None,
run: Some(StringOrVec::Single(format!(
"cd {dir}/kotlin && gradle test --no-daemon"
))),
argv_run: None,
},
Language::KotlinAndroid => TestAppRunConfig {
precondition: Some(require_tool("gradle")),
before: None,
run: Some(StringOrVec::Single(format!(
"cd {dir}/kotlin_android && gradle test --no-daemon"
))),
argv_run: None,
},
Language::Dart => TestAppRunConfig {
precondition: Some(require_tool("dart")),
before: None,
run: Some(StringOrVec::Single(format!(
"cd {dir}/dart && \
dart pub get && \
DART_PKG=$(awk '/^dependencies:$/{{f=1;next}} f && /^ [a-z]/{{sub(/:.*/,\"\");sub(/^ /,\"\");print;exit}}' pubspec.yaml) && \
dart run \"${{DART_PKG}}:download_libs\" && \
dart test"
))),
argv_run: None,
},
Language::Swift => TestAppRunConfig {
precondition: Some(require_tool("swift")),
before: None,
run: Some(StringOrVec::Single(format!("cd {dir}/swift_e2e && swift test"))),
argv_run: None,
},
Language::Zig => TestAppRunConfig {
precondition: Some(require_tool("zig")),
before: None,
run: Some(StringOrVec::Single(format!(
r#"cd {dir}/zig && rm -rf zig-pkg .zig-cache && python3 - <<'PYEOF'
import pathlib, re, subprocess
zon = pathlib.Path('build.zig.zon')
content = zon.read_text()
# Strip any pre-existing `.hash` lines (e.g. the STALE placeholder emitted from
# `[crates.e2e.registry.packages.zig].hash`). We recompute every dependency hash
# from its published tarball below; leaving the placeholder in place would yield
# two `.hash` keys per dep and zig honors the last (stale) one, breaking fetch.
content = re.sub(r'\n[ \t]*\.hash\s*=\s*"[^"]*",', '', content)
deps = re.findall(r'\.([a-z_0-9]+)\s*=\s*\.\{{[^}}]*?\.url\s*=\s*"([^"]+)"', content, re.DOTALL)
for name, url in deps:
h = subprocess.run(['zig', 'fetch', url], capture_output=True, text=True, check=True).stdout.strip()
pat = re.compile(r'(\.' + re.escape(name) + r'\s*=\s*\.\{{[^}}]*?\.url\s*=\s*"' + re.escape(url) + r'",)(\s*\n)(\s*)', re.DOTALL)
content = pat.sub(lambda m: m.group(1) + m.group(2) + m.group(3) + '.hash = "' + h + '",\n' + m.group(3), content, count=1)
zon.write_text(content)
PYEOF
zig build test"#
))),
argv_run: None,
},
Language::Gleam => TestAppRunConfig {
precondition: Some(require_tool("gleam")),
before: None,
run: Some(StringOrVec::Single(format!("cd {dir}/gleam && gleam test"))),
argv_run: None,
},
Language::R => TestAppRunConfig {
precondition: Some(require_tool("Rscript")),
before: None,
run: Some(StringOrVec::Single(format!(
"cd {dir}/r && Rscript -e \"devtools::test()\""
))),
argv_run: None,
},
Language::C => TestAppRunConfig {
precondition: Some(require_tool("make")),
before: None,
run: Some(StringOrVec::Single(format!("cd {dir}/c && make test"))),
argv_run: None,
},
Language::Ffi => TestAppRunConfig {
precondition: None,
before: None,
run: None,
argv_run: None,
},
Language::Jni => TestAppRunConfig {
precondition: Some(require_tool("gradle")),
before: None,
run: Some(StringOrVec::Single(format!(
"cd {dir}/kotlin_android && gradle test --no-daemon"
))),
argv_run: None,
},
}
}
pub fn default_test_apps_run_config_for_name(name: &str, test_apps_dir: &str, _ctx: &LangContext) -> TestAppRunConfig {
match name {
"brew" | "homebrew" => TestAppRunConfig {
precondition: Some(require_tool("brew")),
before: None,
run: None,
argv_run: Some(ArgvRunConfig {
work_dir: format!("{test_apps_dir}/{name}"),
env: Vec::new(),
steps: vec![ArgvStep {
command: "bash".to_owned(),
args: vec!["run_tests.sh".to_owned()],
}],
}),
},
_ => TestAppRunConfig {
precondition: None,
before: None,
run: None,
argv_run: None,
},
}
}
#[cfg(test)]
mod tests {
use super::super::tools::ToolsConfig;
use super::*;
fn all_languages() -> Vec<Language> {
vec![
Language::Python,
Language::Node,
Language::Wasm,
Language::Ruby,
Language::Php,
Language::Go,
Language::Java,
Language::Csharp,
Language::Elixir,
Language::R,
Language::Rust,
Language::Kotlin,
Language::KotlinAndroid,
Language::Swift,
Language::Dart,
Language::Gleam,
Language::Zig,
Language::C,
]
}
fn cfg(lang: Language, dir: &str) -> TestAppRunConfig {
let tools = ToolsConfig::default();
let ctx = LangContext::default(&tools);
default_test_apps_run_config(lang, dir, &ctx, None, None)
}
#[test]
fn ffi_has_no_run_command() {
let c = cfg(Language::Ffi, "test_apps");
assert!(c.run.is_none(), "FFI should have no run command");
assert!(c.precondition.is_none(), "FFI should have no precondition");
}
#[test]
fn jni_runs_kotlin_android_host_jvm_tests() {
let c = cfg(Language::Jni, "test_apps");
let run = c.run.expect("JNI should have a run command");
let cmd = match run {
StringOrVec::Single(s) => s,
_ => panic!("JNI run should be a single command"),
};
assert!(
cmd.contains("kotlin_android"),
"JNI should run via kotlin_android: {cmd}"
);
assert!(cmd.contains("gradle test"), "JNI should run gradle test: {cmd}");
assert!(c.precondition.is_some(), "JNI should require gradle");
}
#[test]
fn runnable_languages_have_run_and_precondition() {
for lang in all_languages() {
let c = cfg(lang, "test_apps");
assert!(
c.run.is_some() || c.argv_run.is_some(),
"{lang} should have a default run command"
);
let pre = c
.precondition
.unwrap_or_else(|| panic!("{lang} should have a precondition"));
assert!(
pre.starts_with("command -v "),
"{lang} precondition should gate on a tool"
);
}
}
#[test]
fn rust_runs_cargo_test() {
let c = cfg(Language::Rust, "test_apps");
let run = c.run.unwrap().commands().join(" ");
assert_eq!(c.precondition.as_deref(), Some("command -v cargo >/dev/null 2>&1"));
assert!(run.contains("cd 'test_apps'/rust"), "got: {run}");
assert!(run.contains("cargo test"), "got: {run}");
}
#[test]
fn python_runs_uv_by_default() {
let c = cfg(Language::Python, "test_apps");
let run = c.run.unwrap().commands().join(" ");
assert_eq!(c.precondition.as_deref(), Some("command -v uv >/dev/null 2>&1"));
assert!(run.contains("cd 'test_apps'/python"), "got: {run}");
assert!(run.contains("uv sync"), "got: {run}");
assert!(run.contains("uv run pytest"), "got: {run}");
}
#[test]
fn python_dispatches_on_package_manager() {
for (pm, expected_pre, expected_cmd) in [
("pip", "command -v pip >/dev/null 2>&1", "pip install -e ."),
("poetry", "command -v poetry >/dev/null 2>&1", "poetry install"),
] {
let tools = ToolsConfig {
python_package_manager: Some(pm.to_string()),
..Default::default()
};
let ctx = LangContext::default(&tools);
let c = default_test_apps_run_config(Language::Python, "test_apps", &ctx, None, None);
assert_eq!(c.precondition.as_deref(), Some(expected_pre), "{pm} precondition");
let run = c.run.unwrap().commands().join(" ");
assert!(run.contains(expected_cmd), "{pm}: expected {expected_cmd}, got: {run}");
assert!(run.contains("cd 'test_apps'/python"), "{pm}: got: {run}");
}
}
#[test]
fn node_runs_pnpm_by_default() {
let c = cfg(Language::Node, "test_apps");
let run = c.run.unwrap().commands().join(" ");
assert_eq!(c.precondition.as_deref(), Some("command -v pnpm >/dev/null 2>&1"));
assert!(run.contains("cd 'test_apps'/node"), "got: {run}");
assert!(
run.contains("pnpm install --no-frozen-lockfile --config.minimumReleaseAge=0"),
"got: {run}"
);
assert!(
run.contains("pnpm --config.minimumReleaseAge=0 test"),
"pnpm test must pass minimumReleaseAge=0 for pnpm 11.3+ compatibility; got: {run}"
);
}
#[test]
fn node_dispatches_on_package_manager() {
for (pm, expected_pre, expected_cmd) in [
(
"npm",
"command -v npm >/dev/null 2>&1",
"npm install --no-package-lock && npm test",
),
("yarn", "command -v yarn >/dev/null 2>&1", "yarn install && yarn test"),
] {
let tools = ToolsConfig {
node_package_manager: Some(pm.to_string()),
..Default::default()
};
let ctx = LangContext::default(&tools);
let c = default_test_apps_run_config(Language::Node, "test_apps", &ctx, None, None);
assert_eq!(c.precondition.as_deref(), Some(expected_pre), "{pm} precondition");
let run = c.run.unwrap().commands().join(" ");
assert!(run.contains(expected_cmd), "{pm}: expected {expected_cmd}, got: {run}");
}
}
#[test]
fn ruby_runs_bundle_rspec() {
let c = cfg(Language::Ruby, "test_apps");
let run = c.run.unwrap().commands().join(" ");
assert_eq!(
c.precondition.as_deref(),
Some(
"command -v ruby >/dev/null 2>&1 && BUNDLE_PATH=vendor/bundle ruby -S bundle --version >/dev/null 2>&1"
)
);
assert!(run.contains("cd 'test_apps'/ruby"), "got: {run}");
assert!(
run.contains(
"BUNDLE_PATH=vendor/bundle ruby -S bundle install && BUNDLE_PATH=vendor/bundle ruby -S bundle exec ruby -S rspec"
),
"got: {run}"
);
}
#[test]
fn php_runs_composer_test() {
let c = cfg(Language::Php, "test_apps");
assert_eq!(c.precondition.as_deref(), Some("command -v composer >/dev/null 2>&1"));
assert!(
c.run.is_none(),
"php's default run must be argv-only, not a shell string: {:?}",
c.run
);
let argv = c.argv_run.expect("php should have an argv run command");
assert_eq!(argv.work_dir, "test_apps/php");
assert_eq!(argv.steps.len(), 3, "install.sh, composer install, composer test");
assert_eq!(argv.steps[0].command, "bash");
assert_eq!(
argv.steps[0].args,
vec!["install.sh"],
"PHP must call the alef-emitted install.sh (PIE bootstrap) before composer"
);
assert_eq!(argv.steps[1].command, "composer");
assert_eq!(argv.steps[1].args, vec!["install"]);
assert_eq!(argv.steps[2].command, "composer");
assert_eq!(argv.steps[2].args, vec!["test"]);
}
#[test]
fn php_forwards_the_published_version_as_one_literal_argument() {
let tools = ToolsConfig::default();
let ctx = LangContext::default(&tools);
let c = default_test_apps_run_config(Language::Php, "test_apps", &ctx, Some("^1.2.3"), None);
let argv = c.argv_run.expect("php should have an argv run command");
assert_eq!(
argv.steps[0].args,
vec!["install.sh", "1.2.3"],
"the constraint prefix is stripped and the bare version is a separate argv element"
);
}
#[test]
fn elixir_runs_mix_test() {
let c = cfg(Language::Elixir, "test_apps");
let run = c.run.unwrap().commands().join(" ");
assert_eq!(c.precondition.as_deref(), Some("command -v mix >/dev/null 2>&1"));
assert!(run.contains("cd 'test_apps'/elixir"), "got: {run}");
assert!(run.contains("mix deps.get && mix test"), "got: {run}");
}
#[test]
fn swift_runs_under_swift_e2e_subdir() {
let c = cfg(Language::Swift, "test_apps");
let run = c.run.unwrap().commands().join(" ");
assert_eq!(c.precondition.as_deref(), Some("command -v swift >/dev/null 2>&1"));
assert!(run.contains("cd 'test_apps'/swift_e2e"), "got: {run}");
assert!(
!run.contains("cd 'test_apps'/swift "),
"must not use swift/ subdir, got: {run}"
);
assert!(run.contains("swift test"), "got: {run}");
assert!(
!run.contains("download_swift_artifact"),
"swift run command must not invoke the legacy artifact-bundle download script, got: {run}"
);
}
#[test]
fn go_runs_go_test_with_gowork_off() {
let c = cfg(Language::Go, "test_apps");
assert!(
c.run.is_none(),
"go's default run must be argv-only, not a shell string: {:?}",
c.run
);
let argv = c.argv_run.expect("go should have an argv run command");
assert_eq!(argv.work_dir, "test_apps/go");
assert_eq!(argv.env, vec![("GOWORK".to_owned(), "off".to_owned())]);
assert_eq!(argv.steps.len(), 2, "no module path -> tidy, test");
assert_eq!(argv.steps[0].command, "go");
assert_eq!(argv.steps[0].args, vec!["mod", "tidy"]);
assert_eq!(argv.steps[1].command, "go");
assert_eq!(argv.steps[1].args, vec!["test", "./..."]);
}
#[test]
fn go_with_module_path_provisions_ffi_via_cmd_setup() {
let c = default_test_apps_run_config(
Language::Go,
"test_apps",
&LangContext::default(&ToolsConfig::default()),
None,
Some("github.com/example/mylib/packages/go"),
);
assert!(c.run.is_none(), "go's default run must be argv-only: {:?}", c.run);
let argv = c.argv_run.expect("go should have an argv run command");
assert_eq!(argv.work_dir, "test_apps/go");
assert_eq!(argv.steps.len(), 3, "with a module path -> tidy, run cmd/setup, test");
assert_eq!(argv.steps[0].command, "go");
assert_eq!(argv.steps[0].args, vec!["mod", "tidy"]);
assert_eq!(argv.steps[1].command, "go");
assert_eq!(
argv.steps[1].args,
vec!["run", "github.com/example/mylib/packages/go/cmd/setup"],
"cmd/setup must be invoked directly against the module path, as one literal argument"
);
assert_eq!(argv.steps[2].command, "go");
assert_eq!(argv.steps[2].args, vec!["test", "./..."]);
}
#[test]
fn go_module_path_survives_shell_metacharacters_as_a_single_argument() {
let malicious = "github.com/example/mylib; touch pwned; echo";
let c = default_test_apps_run_config(
Language::Go,
"test_apps",
&LangContext::default(&ToolsConfig::default()),
None,
Some(malicious),
);
assert!(
c.run.is_none(),
"must be argv-only, not a shell string that could reinterpret this payload"
);
let argv = c.argv_run.expect("go should have an argv run command");
let setup_step = &argv.steps[1];
assert_eq!(setup_step.command, "go");
assert_eq!(
setup_step.args,
vec!["run".to_owned(), format!("{malicious}/cmd/setup")],
"the entire payload, including `;`, must arrive as one literal argument"
);
}
#[test]
fn zig_runs_zig_build_test() {
let c = cfg(Language::Zig, "test_apps");
let run = c.run.unwrap().commands().join(" ");
assert!(run.contains("cd 'test_apps'/zig"), "got: {run}");
assert!(run.contains("python3"), "got: {run}");
assert!(run.contains("'zig', 'fetch'"), "got: {run}");
assert!(run.contains("zig build test"), "got: {run}");
let python_idx = run.find("python3").unwrap();
let build_idx = run.find("zig build test").unwrap();
assert!(
python_idx < build_idx,
"python3 hash-population must run before zig build test, got: {run}"
);
}
#[test]
fn wasm_runs_under_wasm_subdir() {
let c = cfg(Language::Wasm, "test_apps");
let run = c.run.unwrap().commands().join(" ");
assert!(run.contains("cd 'test_apps'/wasm"), "got: {run}");
assert!(
run.contains("pnpm install --no-frozen-lockfile --config.minimumReleaseAge=0"),
"got: {run}"
);
assert!(
run.contains("pnpm --config.minimumReleaseAge=0 test"),
"pnpm test must also pass minimumReleaseAge=0 flag for pnpm 11.3+ compatibility; got: {run}"
);
}
#[test]
fn test_apps_dir_is_substituted() {
let c = cfg(Language::Go, "my/custom/apps");
let argv = c.argv_run.expect("go should have an argv run command");
assert_eq!(argv.work_dir, "my/custom/apps/go");
}
#[test]
fn brew_target_runs_under_brew_subdir() {
let tools = ToolsConfig::default();
let ctx = LangContext::default(&tools);
let c = default_test_apps_run_config_for_name("brew", "test_apps", &ctx);
assert_eq!(c.precondition.as_deref(), Some("command -v brew >/dev/null 2>&1"));
assert!(c.run.is_none(), "brew's default run must be argv-only: {:?}", c.run);
let argv = c.argv_run.expect("brew should have an argv run command");
assert_eq!(argv.work_dir, "test_apps/brew");
assert_eq!(argv.steps.len(), 1);
assert_eq!(argv.steps[0].command, "bash");
assert_eq!(argv.steps[0].args, vec!["run_tests.sh"]);
}
#[test]
fn homebrew_target_runs_under_homebrew_subdir() {
let tools = ToolsConfig::default();
let ctx = LangContext::default(&tools);
let c = default_test_apps_run_config_for_name("homebrew", "test_apps", &ctx);
assert_eq!(c.precondition.as_deref(), Some("command -v brew >/dev/null 2>&1"));
assert!(c.run.is_none(), "homebrew's default run must be argv-only: {:?}", c.run);
let argv = c.argv_run.expect("homebrew should have an argv run command");
assert_eq!(
argv.work_dir, "test_apps/homebrew",
"must not use the brew/ subdir for the homebrew target"
);
assert_eq!(argv.steps.len(), 1);
assert_eq!(argv.steps[0].command, "bash");
assert_eq!(argv.steps[0].args, vec!["run_tests.sh"]);
}
#[test]
fn dart_run_invokes_download_libs_before_test() {
let c = cfg(Language::Dart, "test_apps");
let run = c.run.expect("dart should have a run command").commands().join(" ");
assert_eq!(c.precondition.as_deref(), Some("command -v dart >/dev/null 2>&1"));
assert!(run.contains("cd 'test_apps'/dart"), "got: {run}");
assert!(run.contains("dart pub get"), "got: {run}");
assert!(run.contains("dart test"), "got: {run}");
assert!(
run.contains("download_libs"),
"dart run must invoke download_libs to fetch the native from the GH release; got: {run}"
);
assert!(
run.contains("DART_PKG"),
"dart run must derive the under-test package name for the download_libs call; got: {run}"
);
let dl = run.find("download_libs").expect("download_libs present");
let test = run.rfind("dart test").expect("dart test present");
assert!(dl < test, "download_libs must run before dart test; got: {run}");
}
#[test]
fn kotlin_android_runs_under_kotlin_android_subdir() {
let c = cfg(Language::KotlinAndroid, "test_apps");
let run = c.run.unwrap().commands().join(" ");
assert!(run.contains("cd 'test_apps'/kotlin_android"), "got: {run}");
assert!(
!run.contains("cd 'test_apps'/kotlin "),
"must use kotlin_android/ subdir, not kotlin/, got: {run}"
);
assert!(run.contains("gradle test --no-daemon"), "got: {run}");
}
}