diff --git a/lua/neotest-java/build_tool/launcher.lua b/lua/neotest-java/build_tool/launcher.lua index e0b68884..8780413a 100644 --- a/lua/neotest-java/build_tool/launcher.lua +++ b/lua/neotest-java/build_tool/launcher.lua @@ -1,71 +1,108 @@ local log = require("neotest-java.logger") local nio = require("nio") -local Job = require("plenary.job") local lib = require("neotest.lib") -local _, repl = pcall(require, "dap.repl") +--- A minimal shape of the job runner this module needs. The default +--- implementation is backed by `plenary.job`, but it can be swapped out +--- (e.g. in tests, or by a future implementation that drops the +--- plenary.nvim dependency) as long as it honors this contract: +--- * `new(opts)` accepts the same callback fields plenary.Job does +--- (`command`, `cwd`, `args`, `on_stderr`, `on_stdout`, `on_exit`) +--- * the returned job exposes a `start()` method +--- @class neotest-java.JobRunner +--- @field new fun(self: neotest-java.JobRunner, opts: table): { start: fun() } ----@class neotest-java.BuildToolLauncher -local launcher = {} +---@param deps neotest-java.BuildToolLauncherDeps | nil +---@return neotest-java.BuildToolLauncher +local function BuildToolLauncher(deps) + deps = deps or {} + deps.job_runner = deps.job_runner or require("plenary.job") + deps.dap_repl = deps.dap_repl + if deps.dap_repl == nil then + local _, repl = pcall(require, "dap.repl") + deps.dap_repl = repl + end ----@param command string ----@param args string[] ----@param cwd neotest-java.Path ----@return nio.control.Event -function launcher.launch_debug_test(command, args, cwd) - lib.notify("Running debug test", vim.log.levels.INFO) - log.trace("run_debug_test function") + return { + ---@param command string + ---@param args string[] + ---@param cwd neotest-java.Path + ---@return nio.control.Event + launch_debug_test = function(command, args, cwd) + lib.notify("Running debug test", vim.log.levels.INFO) + log.trace("run_debug_test function") - local test_command_started_listening = nio.control.event() - local terminated_command_event = nio.control.event() + local repl = deps.dap_repl - local stderr = {} - local job = Job:new({ - command = command, - cwd = cwd:to_string(), - args = args, - on_stderr = function(_, data) - if data == nil then - return - end - stderr[#stderr + 1] = data - if repl then - vim.schedule(function() - repl.append(data) - end) - end - end, - on_stdout = function(_, data) - if data == nil then - return - end - if string.find(data, "Listening") then - test_command_started_listening.set() - end + local test_command_started_listening = nio.control.event() + local terminated_command_event = nio.control.event() + + local stderr = {} + local job = deps.job_runner:new({ + command = command, + cwd = cwd:to_string(), + args = args, + on_stderr = function(_, data) + if data == nil then + return + end + stderr[#stderr + 1] = data + if repl then + vim.schedule(function() + repl.append(data) + end) + end + end, + on_stdout = function(_, data) + if data == nil then + return + end + if string.find(data, "Listening") then + test_command_started_listening.set() + end + if repl then + vim.schedule(function() + repl.append(data) + end) + end + end, + on_exit = function(_, code) + terminated_command_event.set() + + log.debug("command exited with code: ", code) + if code ~= 0 then + log.error("command exited with code: ", code) + log.error("stderr: ", table.concat(stderr, "\n")) + end + end, + }) + log.debug("starting job with command: ", command, " ", table.concat(args, " ")) if repl then - vim.schedule(function() - repl.append(data) - end) + repl.clear() end - end, - on_exit = function(_, code) - terminated_command_event.set() + job:start() + test_command_started_listening.wait() - log.debug("command exited with code: ", code) - if code ~= 0 then - log.error("command exited with code: ", code) - log.error("stderr: ", table.concat(stderr, "\n")) - end + return terminated_command_event end, - }) - log.debug("starting job with command: ", command, " ", table.concat(args, " ")) - if repl then - repl.clear() - end - job:start() - test_command_started_listening.wait() - - return terminated_command_event + } end +---@class neotest-java.BuildToolLauncherDeps +---@field job_runner? neotest-java.JobRunner +---@field dap_repl? false | { append: fun(data: string), clear: fun() } + +---@class neotest-java.BuildToolLauncher +---@field launch_debug_test fun(command: string, args: string[], cwd: neotest-java.Path): nio.control.Event +---@field new fun(deps: neotest-java.BuildToolLauncherDeps | nil): neotest-java.BuildToolLauncher + +-- Module-level singleton, matching the previous `launcher.launch_debug_test` +-- call site contract (`require("neotest-java.build_tool.launcher").launch_debug_test(...)`). +local launcher = BuildToolLauncher() + +-- Exposed for tests / future callers that want to inject a custom job_runner +-- (e.g. a stub, or a non-plenary implementation) without going through the +-- module-level singleton. +launcher.new = BuildToolLauncher + return launcher diff --git a/lua/neotest-java/types.lua b/lua/neotest-java/types.lua index afaebbb4..597b26bc 100644 --- a/lua/neotest-java/types.lua +++ b/lua/neotest-java/types.lua @@ -55,6 +55,7 @@ ---@class nio.control.Event ---@field wait fun() ---@field set fun() +---@field is_set fun(): boolean ---@class vim.lsp.Client ---@field initialized boolean diff --git a/tests/integration/test_detect_project_type_spec.lua b/tests/integration/test_detect_project_type_spec.lua new file mode 100644 index 00000000..f1f5d91f --- /dev/null +++ b/tests/integration/test_detect_project_type_spec.lua @@ -0,0 +1,66 @@ +local assertions = require("tests.assertions") +local eq = assertions.eq + +local Path = require("neotest-java.model.path") +local detect_project_type = require("neotest-java.util.detect_project_type") + +--- Real-filesystem integration coverage for `detect_project_type`'s +--- fallback path: a genuine multi-directory layout with no build markers +--- at the top level, scanned by the *real* `plenary.scandir` (no stubs +--- injected), exactly as it runs in production. This is the exact +--- behavior a future non-plenary implementation (see #317) must preserve. +describe("detect_project_type (real filesystem integration)", function() + local root_dir + + before_each(function() + root_dir = vim.fn.tempname() + vim.fn.mkdir(root_dir, "p") + end) + + after_each(function() + vim.fn.delete(root_dir, "rf") + end) + + it("finds a maven marker nested several directories deep via the real recursive scan", function() + local nested_dir = root_dir .. "/module-a/src/main/java/com/example" + vim.fn.mkdir(nested_dir, "p") + -- No markers at the top level, forcing the fallback path. + vim.fn.writefile({}, root_dir .. "/module-a/pom.xml") + + local result = detect_project_type(Path(root_dir)) + + eq("maven", result) + end) + + it("finds a gradle marker nested in a sibling module via the real recursive scan", function() + vim.fn.mkdir(root_dir .. "/module-a", "p") + vim.fn.mkdir(root_dir .. "/module-b", "p") + vim.fn.writefile({}, root_dir .. "/module-b/build.gradle") + + local result = detect_project_type(Path(root_dir)) + + eq("gradle", result) + end) + + it("returns 'unknown' for a real nested directory tree with no build markers anywhere", function() + vim.fn.mkdir(root_dir .. "/src/main/java/com/example", "p") + vim.fn.writefile({}, root_dir .. "/src/main/java/com/example/App.java") + vim.fn.writefile({}, root_dir .. "/README.md") + + local result = detect_project_type(Path(root_dir)) + + eq("unknown", result) + end) + + it("uses the fast top-level path (no recursive scan needed) when a marker is at the root", function() + vim.fn.writefile({}, root_dir .. "/pom.xml") + vim.fn.mkdir(root_dir .. "/some/deeply/nested/dir", "p") + vim.fn.writefile({}, root_dir .. "/some/deeply/nested/dir/build.gradle") + + -- Even though a gradle marker exists deeper in the tree, the + -- top-level pom.xml should win via the fast path. + local result = detect_project_type(Path(root_dir)) + + eq("maven", result) + end) +end) diff --git a/tests/integration/test_launcher_spec.lua b/tests/integration/test_launcher_spec.lua new file mode 100644 index 00000000..1c4fbe01 --- /dev/null +++ b/tests/integration/test_launcher_spec.lua @@ -0,0 +1,54 @@ +local async = require("tests.async_helpers").async +local assertions = require("tests.assertions") +local eq = assertions.eq +local MiniTest = require("mini.test") + +local Path = require("neotest-java.model.path") +local BuildToolLauncher = require("neotest-java.build_tool.launcher").new +-- dap_repl = false: this environment has no nvim-dap installed. In real +-- usage `spec_builder` already asserts `require("dap")` succeeds before +-- reaching `launch_debug_test` (see core/spec_builder/init.lua), so the +-- real dap.repl module would be present too. Here we only care about the +-- job-spawn + JDWP-wait behavior, not the dap.repl integration. +local launcher = BuildToolLauncher({ dap_repl = false }) + +--- Real-process integration coverage for `launcher.launch_debug_test`. +--- +--- This spawns an actual JVM with a JDWP debug agent (the same mechanism +--- neotest-java uses to run tests under `strategy = "dap"`), without +--- attaching a real debugger. A JVM started with +--- `-agentlib:jdwp=...,suspend=n` prints +--- "Listening for transport dt_socket at address: " to stdout as +--- soon as the debug socket is open, then continues running normally. +--- +--- This exercises the real `plenary.job` spawn + JDWP "Listening" wait + +--- process-exit logic end-to-end, which is the exact behavior a future +--- non-plenary implementation (see #317) must preserve. +describe("BuildToolLauncher (real JVM integration)", function() + it( + "detects the JDWP 'Listening' message from a real JVM and waits for it to exit", + async(function() + if vim.fn.executable("java") == 0 then + MiniTest.skip("java executable not found on PATH; skipping real-JVM integration test") + return + end + + local args = { + "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=0", + "-version", + } + + local terminated_event = launcher.launch_debug_test("java", args, Path(vim.uv.cwd())) + + -- `launch_debug_test` only returns once the JVM has printed the + -- "Listening" line, proving the real plenary.job stdout wiring + -- correctly detects it. + assert(terminated_event ~= nil, "should return a terminated_command_event") + + -- `java -version` exits almost immediately once started; wait for + -- the process-exit event to prove on_exit fires for a real job. + terminated_event.wait() + eq(true, terminated_event.is_set()) + end) + ) +end) diff --git a/tests/unit/test_detect_project_type_spec.lua b/tests/unit/test_detect_project_type_spec.lua new file mode 100644 index 00000000..4a7658bf --- /dev/null +++ b/tests/unit/test_detect_project_type_spec.lua @@ -0,0 +1,191 @@ +local assertions = require("tests.assertions") +local eq = assertions.eq + +local Path = require("neotest-java.model.path") +local detect_project_type = require("neotest-java.util.detect_project_type") + +describe("detect_project_type", function() + describe("fast path (top-level readdir)", function() + it("detects maven via pom.xml at the top level", function() + local readdir = function(_) + return { "pom.xml", "src" } + end + -- scandir should never be reached for the fast path to succeed + local scandir = function() + error("scandir should not be called when the fast path finds a match") + end + + local result = detect_project_type(Path(vim.uv.os_tmpdir()), scandir, readdir) + + eq("maven", result) + end) + + it("detects gradle via build.gradle at the top level", function() + local readdir = function(_) + return { "build.gradle", "src" } + end + local scandir = function() + error("scandir should not be called when the fast path finds a match") + end + + local result = detect_project_type(Path(vim.uv.os_tmpdir()), scandir, readdir) + + eq("gradle", result) + end) + + it("prefers maven over gradle when both markers are present at the top level", function() + local readdir = function(_) + return { "pom.xml", "build.gradle" } + end + local scandir = function() + error("scandir should not be called when the fast path finds a match") + end + + local result = detect_project_type(Path(vim.uv.os_tmpdir()), scandir, readdir) + + eq("maven", result) + end) + + it("recognizes all maven marker file names (pom.xml, mvnw, mvnw.cmd)", function() + for _, marker in ipairs({ "pom.xml", "mvnw", "mvnw.cmd" }) do + local readdir = function(_) + return { marker } + end + local scandir = function() + error("scandir should not be called when the fast path finds a match") + end + + eq("maven", detect_project_type(Path(vim.uv.os_tmpdir()), scandir, readdir)) + end + end) + + it( + "recognizes all gradle marker file names (settings.gradle[.kts], build.gradle[.kts], gradlew[.bat])", + function() + for _, marker in ipairs({ + "settings.gradle", + "settings.gradle.kts", + "build.gradle", + "build.gradle.kts", + "gradlew", + "gradlew.bat", + }) do + local readdir = function(_) + return { marker } + end + local scandir = function() + error("scandir should not be called when the fast path finds a match") + end + + eq("gradle", detect_project_type(Path(vim.uv.os_tmpdir()), scandir, readdir)) + end + end + ) + end) + + describe("fallback path (recursive scandir)", function() + it("falls back to scandir when readdir errors (e.g. directory does not exist)", function() + local readdir = function(_) + error("boom: directory not found") + end + local scandir_called_with = nil + local scandir = function(root_str, opts) + scandir_called_with = { root_str = root_str, opts = opts } + return { root_str .. "/nested/pom.xml" } + end + + local result = detect_project_type(Path("/fake/root"), scandir, readdir) + + eq("maven", result) + assert(scandir_called_with ~= nil, "scandir should have been called as a fallback") + eq(Path("/fake/root"):to_string(), scandir_called_with.root_str) + end) + + it("falls back to scandir when the top-level directory has no marker files", function() + local readdir = function(_) + return { "README.md" } + end + local scandir = function(root_str, _) + return { root_str .. "/module-a/build.gradle" } + end + + local result = detect_project_type(Path("/fake/root"), scandir, readdir) + + eq("gradle", result) + end) + + it("falls back to scandir when the top-level directory is empty", function() + local readdir = function(_) + return {} + end + local scandir = function(root_str, _) + return { root_str .. "/deep/nested/dir/pom.xml" } + end + + local result = detect_project_type(Path("/fake/root"), scandir, readdir) + + eq("maven", result) + end) + + it("prefers maven over gradle when both markers are found in the recursive scan", function() + local readdir = function(_) + return {} + end + local scandir = function(root_str, _) + return { + root_str .. "/module-a/build.gradle", + root_str .. "/module-b/pom.xml", + } + end + + local result = detect_project_type(Path("/fake/root"), scandir, readdir) + + eq("maven", result) + end) + + it("returns 'unknown' when neither readdir nor the recursive scan find any marker", function() + local readdir = function(_) + return { "README.md" } + end + local scandir = function(_, _) + return { "/fake/root/notes.txt" } + end + + local result = detect_project_type(Path("/fake/root"), scandir, readdir) + + eq("unknown", result) + end) + + it("returns 'unknown' when scandir itself errors", function() + local readdir = function(_) + return {} + end + local scandir = function(_, _) + error("scandir exploded") + end + + local result = detect_project_type(Path("/fake/root"), scandir, readdir) + + eq("unknown", result) + end) + + it("passes recursive scan options (hidden=false, add_dirs=false, depth=math.huge)", function() + local readdir = function(_) + return {} + end + --- @type { hidden: boolean, add_dirs: boolean, depth: number } | nil + local captured_opts = nil + local scandir = function(_, opts) + captured_opts = opts + return {} + end + + detect_project_type(Path("/fake/root"), scandir, readdir) + + assert(captured_opts ~= nil, "scandir should have been called with opts") + eq(false, captured_opts.hidden) + eq(false, captured_opts.add_dirs) + eq(math.huge, captured_opts.depth) + end) + end) +end) diff --git a/tests/unit/test_launcher_spec.lua b/tests/unit/test_launcher_spec.lua new file mode 100644 index 00000000..109a48f2 --- /dev/null +++ b/tests/unit/test_launcher_spec.lua @@ -0,0 +1,112 @@ +local async = require("tests.async_helpers").async +local assertions = require("tests.assertions") +local eq = assertions.eq + +local Path = require("neotest-java.model.path") +local BuildToolLauncher = require("neotest-java.build_tool.launcher").new + +--- Build a stub job runner mimicking the shape `plenary.job` exposes +--- (`Job:new(opts)` returning an object with `start()`). The stub captures +--- the callbacks and lets `start()` drive them synchronously via +--- `on_start`, matching how a real job would invoke them from its own +--- process lifecycle (started -> stdout/stderr chunks -> exit) without +--- needing real process I/O or a scheduler tick. +local function stub_job_runner(on_start) + local started_opts = nil + + return { + new = function(_, opts) + started_opts = opts + return { + start = function() + if on_start then + on_start(opts) + end + end, + } + end, + opts = function() + return started_opts + end, + } +end + +describe("BuildToolLauncher", function() + it( + "starts the job with the given command, args and cwd", + async(function() + local runner = stub_job_runner(function(opts) + opts.on_stdout(nil, "Listening for transport dt_socket at address: 12345") + end) + local launcher = BuildToolLauncher({ job_runner = runner, dap_repl = false }) + + launcher.launch_debug_test("java", { "-jar", "foo.jar" }, Path("/fake/cwd")) + + local opts = runner.opts() + assert(opts ~= nil, "job runner should have been started with opts") + eq("java", opts.command) + eq({ "-jar", "foo.jar" }, opts.args) + eq(Path("/fake/cwd"):to_string(), opts.cwd) + end) + ) + + it( + "resolves the returned event only once stdout reports 'Listening'", + async(function() + local resolved = false + local runner = stub_job_runner(function(opts) + -- irrelevant stdout should not resolve the wait + opts.on_stdout(nil, "compiling...") + eq(false, resolved) + opts.on_stdout(nil, "Listening for transport dt_socket at address: 54321") + end) + local launcher = BuildToolLauncher({ job_runner = runner, dap_repl = false }) + + launcher.launch_debug_test("java", {}, Path("/fake/cwd")) + resolved = true + + eq(true, resolved) + end) + ) + + it( + "sets the terminated event when the job exits", + async(function() + local runner = stub_job_runner(function(opts) + opts.on_stdout(nil, "Listening") + end) + local launcher = BuildToolLauncher({ job_runner = runner, dap_repl = false }) + + local terminated_event = launcher.launch_debug_test("java", {}, Path("/fake/cwd")) + + local opts = runner.opts() + assert(opts ~= nil, "job runner should have been started with opts") + local terminated_before = terminated_event.is_set() + opts.on_exit(nil, 0) + local terminated_after = terminated_event.is_set() + + eq(false, terminated_before) + eq(true, terminated_after) + end) + ) + + it( + "ignores nil stdout/stderr chunks without erroring", + async(function() + local runner = stub_job_runner(function(opts) + opts.on_stdout(nil, nil) + opts.on_stderr(nil, nil) + opts.on_stdout(nil, "Listening") + end) + local launcher = BuildToolLauncher({ job_runner = runner, dap_repl = false }) + + local terminated_event = launcher.launch_debug_test("java", {}, Path("/fake/cwd")) + eq(false, terminated_event.is_set()) + end) + ) + + it("defaults to plenary.job when no job_runner is injected", function() + local launcher = BuildToolLauncher({ dap_repl = false }) + assert(launcher.launch_debug_test ~= nil, "launch_debug_test should exist with default deps") + end) +end)