Skip to content
149 changes: 93 additions & 56 deletions lua/neotest-java/build_tool/launcher.lua
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions lua/neotest-java/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions tests/integration/test_detect_project_type_spec.lua
Original file line number Diff line number Diff line change
@@ -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)
54 changes: 54 additions & 0 deletions tests/integration/test_launcher_spec.lua
Original file line number Diff line number Diff line change
@@ -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: <port>" 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)
Loading
Loading