diff --git a/.github/justfile b/.github/justfile index 73d61a8e986..7c811b76a17 100644 --- a/.github/justfile +++ b/.github/justfile @@ -59,6 +59,7 @@ test: bun test sh/gh/ffi.test.ts sh/gh/cpp.test.ts sh/gh/publish.test.ts python3 sh/rs/package-rename.test.py python3 sh/dart/build.test.py + python3 sh/kt/gradle.test.py python3 sh/rs/select.test.py python3 sh/rs/config.test.py python3 sh/js/workspace.test.py diff --git a/sh/kt/bench.sh b/sh/kt/bench.sh index 57e61a43cdd..a7074cc4428 100755 --- a/sh/kt/bench.sh +++ b/sh/kt/bench.sh @@ -9,6 +9,13 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" KT_DIR="$(cd "$SCRIPT_DIR/../../kt" && pwd)" export MOQ_BENCH_DIR="${MOQ_BENCH_DIR:-$KT_DIR/../target/bench/ffi}" +# `type -P` resolves only executables, so an inherited shell function named gradle cannot stand in for Gradle. +gradle_name="${GRADLE_CMD:-gradle}" +GRADLE_CMD="$(type -P "$gradle_name")" || { + echo "kt bench: Gradle executable '$gradle_name' not found; run 'nix develop'" >&2 + exit 1 +} + MOQ_FFI_PROFILE=release bash "$SCRIPT_DIR/generate.sh" -gradle -p "$KT_DIR" -Pmoqffi.version=0.0.0-dev :moq:jvmTest --tests dev.moq.FfiBench --rerun +"$GRADLE_CMD" -p "$KT_DIR" -Pmoqffi.version=0.0.0-dev :moq:jvmTest --tests dev.moq.FfiBench --rerun echo "kt bench: wrote $MOQ_BENCH_DIR/kotlin.json" diff --git a/sh/kt/check.sh b/sh/kt/check.sh index fbdebcce8d4..3d4346faf9e 100644 --- a/sh/kt/check.sh +++ b/sh/kt/check.sh @@ -28,9 +28,11 @@ if ! command -v java >/dev/null 2>&1; then exit 1 fi -GRADLE_CMD="${GRADLE_CMD:-$(command -v gradle || true)}" -if [[ -z "$GRADLE_CMD" ]]; then - echo "kt check: gradle not on PATH; run 'nix develop' or use 'just kt generate' to only regenerate bindings" >&2 +# `type -P` resolves only executables, so an inherited shell function named gradle can neither +# stand in for Gradle nor make the gradle() wrapper below call itself. +gradle_name="${GRADLE_CMD:-gradle}" +if ! GRADLE_CMD="$(type -P "$gradle_name")"; then + echo "kt check: Gradle executable '$gradle_name' not found; run 'nix develop' or use 'just kt generate' to only regenerate bindings" >&2 exit 1 fi diff --git a/sh/kt/gradle.test.py b/sh/kt/gradle.test.py new file mode 100644 index 00000000000..60551f94ec4 --- /dev/null +++ b/sh/kt/gradle.test.py @@ -0,0 +1,143 @@ +"""Kotlin runners select executables even when the shell exports Gradle functions.""" + +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +BASH = shutil.which("bash") +RUNNERS = ("check", "package", "bench") + + +class GradleTest(unittest.TestCase): + def setUp(self): + scratch = ROOT / ".tmp" + scratch.mkdir(exist_ok=True) + self.directory = tempfile.TemporaryDirectory(dir=scratch) + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.tools = self.root / "tools" + self.tools.mkdir() + self.calls = self.root / "calls.jsonl" + for name in ("bash", "dirname", "mkdir", "rm", "cp"): + (self.tools / name).symlink_to(shutil.which(name)) + (self.tools / "python3").symlink_to(sys.executable) + self.write("tools/java", "exit 0\n") + for name in (f"{runner}.sh" for runner in RUNNERS): + target = self.root / "sh/kt" / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(ROOT / "sh/kt" / name, target) + for name in ("format.sh", "generate.sh"): + self.write(f"sh/kt/{name}", "exit 0\n") + self.write("doc/lib/samples.sh", "exit 0\n") + (self.root / "sh/kt/check-poms.py").write_text("") + (self.root / "kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/docs").mkdir(parents=True) + source = self.root / "bindings/uniffi/moq/moq.kt" + source.parent.mkdir(parents=True) + source.write_text("") + self.gradle("gradle") + self.gradle("custom-gradle") + + def write(self, name, source): + target = self.root / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(f"#!{BASH}\n{source}") + target.chmod(0o755) + + def gradle(self, name): + target = self.tools / name + target.write_text( + f"#!{sys.executable}\n" + "import json, os, sys\n" + 'with open(os.environ["GRADLE_CALLS"], "a") as output:\n' + " output.write(json.dumps({\n" + ' "executable": sys.argv[0], "args": sys.argv[1:],\n' + ' "runner_keys": [key for key in os.environ\n' + ' if key.startswith(("ACTIONS_", "GITHUB_", "RUNNER_"))],\n' + ' }) + "\\n")\n' + 'sys.exit(int(os.environ.get("GRADLE_STATUS", "0")))\n' + ) + target.chmod(0o755) + + def run_script(self, script, **changes): + self.calls.unlink(missing_ok=True) + # An imported function must never satisfy executable discovery. FUNCNEST + # turns a recursive wrapper into a deterministic failure in the fixture. + env = { + "PATH": str(self.tools), + "HOME": str(self.root), + "LOCAL_SUPERVISED": "1", + "BASH_ENV": os.devnull, + "BASH_FUNC_gradle%%": "() { echo 'inherited function called' >&2; return 99; }", + "FUNCNEST": "10", + "GRADLE_CALLS": str(self.calls), + "ACTIONS_TEST": "fixture", + "GITHUB_TEST": "fixture", + "RUNNER_TEST": "fixture", + **changes, + } + args = [BASH, f"sh/kt/{script}.sh"] + if script == "package": + args += ["--version", "0.0.0-dev", "--lib-dir", "libs", "--bindings-dir", "bindings", "--output", "dist"] + return subprocess.run(args, cwd=self.root, env=env, text=True, capture_output=True) + + def recorded(self): + return [json.loads(line) for line in self.calls.read_text().splitlines()] + + def test_path_executable_wins_over_exported_function(self): + first = {"check": ":moq:jvmTest", "package": ":moq-ffi:assemble", "bench": "dev.moq.FfiBench"} + for script in RUNNERS: + for override in ({}, {"GRADLE_CMD": "gradle"}): + with self.subTest(script=script, override=override): + result = self.run_script(script, **override) + self.assertEqual(result.returncode, 0, result.stderr) + calls = self.recorded() + self.assertEqual(len(calls), 2 if script == "check" else 1) + self.assertTrue(all(Path(call["executable"]) == self.tools / "gradle" for call in calls)) + self.assertIn(first[script], calls[0]["args"]) + if script == "check": + self.assertTrue(all(call["runner_keys"] == [] for call in calls)) + self.assertIn(":moq:generatePomFileForJvmPublication", calls[1]["args"]) + + def test_explicit_executable_is_preserved(self): + for script in RUNNERS: + for override in ("custom-gradle", "tools/custom-gradle", str(self.tools / "custom-gradle")): + with self.subTest(script=script, override=override): + result = self.run_script(script, GRADLE_CMD=override) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(all(Path(call["executable"]).name == "custom-gradle" for call in self.recorded())) + + def test_function_without_executable_is_refused(self): + (self.tools / "gradle").unlink() + for script in RUNNERS: + with self.subTest(script=script): + result = self.run_script(script) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("Gradle executable 'gradle' not found", result.stderr) + self.assertNotIn("inherited function called", result.stderr) + self.assertFalse(self.calls.exists()) + + def test_missing_override_is_named(self): + for script in RUNNERS: + with self.subTest(script=script): + result = self.run_script(script, GRADLE_CMD="tools/missing-gradle") + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("Gradle executable 'tools/missing-gradle' not found", result.stderr) + self.assertFalse(self.calls.exists()) + + def test_gradle_failure_stops_the_runner(self): + for script in RUNNERS: + with self.subTest(script=script): + result = self.run_script(script, GRADLE_STATUS="7") + self.assertEqual(result.returncode, 7, result.stderr) + self.assertEqual(len(self.recorded()), 1) + self.assertNotIn("kt bench: wrote", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/sh/kt/package.sh b/sh/kt/package.sh index 03c9cc13382..58d429934b3 100755 --- a/sh/kt/package.sh +++ b/sh/kt/package.sh @@ -173,9 +173,10 @@ if [[ "$HAVE_ANDROID_LIBS" == true ]]; then GRADLE_ARGS+=("-Pandroid.enabled=true") fi -GRADLE_CMD="${GRADLE_CMD:-$(command -v gradle || true)}" -[[ -n "$GRADLE_CMD" ]] || { - echo "Error: gradle not on PATH" >&2 +# `type -P` resolves only executables, so an inherited shell function named gradle cannot stand in for Gradle. +gradle_name="${GRADLE_CMD:-gradle}" +GRADLE_CMD="$(type -P "$gradle_name")" || { + echo "Error: Gradle executable '$gradle_name' not found" >&2 exit 1 }