Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 8 additions & 12 deletions lua/neotest-java/core/junit_result_reader.lua
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,29 @@
--- per-file parse errors) so that callers — notably `result_builder` —
--- can stay pure orchestrators.
---
--- All I/O flows through the injected `XmlReader` (read + parse) and the
--- injected `tempname_fn` (per-result output file). Per-file parse
--- errors are logged via the injected `log` and the file is skipped;
--- the rest of the list is still processed.
--- All I/O flows through the injected `XmlReader` (read + parse). Per-file
--- parse errors are logged via the injected `log` and the file is
--- skipped; the rest of the list is still processed. `JunitResult` itself
--- does no I/O — writing test output to disk is `OutputWriter`'s job,
--- used later by `result_builder`.
---
--- Usage:
--- local JunitResultReader = require("neotest-java.core.junit_result_reader")
--- local reader = JunitResultReader({
--- xml_reader = XmlReader.new({ read_file = my_read_file }),
--- tempname_fn = function() return vim.fn.tempname() end,
--- })
--- local results = reader.read_all(report_files)
---
--- Tests typically inject a stub `xml_reader` (no real I/O) and a stub
--- `tempname_fn` (returns a known path) so the JUnit walk can be
--- exercised without touching the filesystem or async context.
--- Tests typically inject a stub `xml_reader` (no real I/O) so the JUnit
--- walk can be exercised without touching the filesystem or async context.

--- @class neotest-java.JunitResultReaderDeps
--- @field xml_reader? neotest-java.XmlReader | { parse: fun(filepath: neotest-java.Path | string): { tree: table, error: string } }
--- @field tempname_fn? fun(): string
--- @field log? neotest.Logger | { debug: fun(...), warn: fun(...) }

--- @class neotest-java.JunitResultReader
--- @field read_all fun(paths: neotest-java.Path[]): neotest-java.JunitResult[]

local nio = require("nio")
local JunitResult = require("neotest-java.model.junit_result")
local XmlReader = require("neotest-java.util.xml_reader").new

Expand All @@ -41,7 +38,6 @@ local XmlReader = require("neotest-java.util.xml_reader").new
local JunitResultReader = function(deps)
deps = deps or {}
deps.xml_reader = deps.xml_reader or XmlReader()
deps.tempname_fn = deps.tempname_fn or nio.fn.tempname
deps.log = deps.log or require("neotest-java.logger")

return {
Expand Down Expand Up @@ -71,7 +67,7 @@ local JunitResultReader = function(deps)
tcs = { tcs }
end
for _, tc in ipairs(tcs) do
table.insert(results, JunitResult:new(tc, deps.tempname_fn))
table.insert(results, JunitResult:new(tc))
end
end
end
Expand Down
25 changes: 22 additions & 3 deletions lua/neotest-java/core/result_builder.lua
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
local log = require("neotest-java.logger")
local JunitResult = require("neotest-java.model.junit_result")
local OutputWriter = require("neotest-java.util.output_writer")

local REPORT_FILE_NAMES_PATTERN = "TEST-.+%.xml$"

Expand All @@ -18,6 +19,22 @@ local function group_by_method_base(testcases)
return groups
end

--- Converts `JunitResult`/`JunitResult.merge_results`'s plain result data
--- into a `neotest.Result`, writing `output_lines` to disk via the
--- injected `output_writer`. This is the only place in the results flow
--- that touches a real (or stubbed) `output_writer`.
--- @param data neotest-java.JunitResultData
--- @param output_writer neotest-java.OutputWriter
--- @return neotest.Result
local function to_neotest_result(data, output_writer)
return {
status = data.status,
short = data.short,
errors = data.errors,
output = output_writer.write(data.output_lines),
}
end

-- -----------------------------------------------------------------------------
-- Public API
-- -----------------------------------------------------------------------------
Expand All @@ -30,6 +47,7 @@ end
--- @field junit_result_reader { read_all: fun(paths: neotest-java.Path[]): neotest-java.JunitResult[] }
--- @field remove_file fun(filepath: string): boolean, string?
--- @field tempname_fn fun(): string
--- @field output_writer? neotest-java.OutputWriter

--- @param deps neotest-java.ResultBuilderDeps
--- @return neotest-java.ResultBuilder
Expand All @@ -39,6 +57,7 @@ local ResultBuilder = function(deps)
assert(deps.junit_result_reader, "junit_result_reader should not be nil")
assert(deps.remove_file, "remove_file should not be nil")
assert(deps.tempname_fn, "tempname_fn should not be nil")
deps.output_writer = deps.output_writer or OutputWriter({ tempname_fn = deps.tempname_fn })

local find_report_files = function(dir)
return deps.scan_dir(dir, { search_patterns = { REPORT_FILE_NAMES_PATTERN } })
Expand All @@ -52,7 +71,7 @@ local ResultBuilder = function(deps)
build_results = function(spec, result, tree)
if result.code ~= 0 and result.code ~= 1 then
local node = tree:data()
return { [node.id] = JunitResult.ERROR(node.id, result.output, deps.tempname_fn) }
return { [node.id] = to_neotest_result(JunitResult.ERROR(node.id, result.output), deps.output_writer) }
end

if spec.context.strategy == "dap" then
Expand All @@ -70,7 +89,7 @@ local ResultBuilder = function(deps)
--- @type neotest-java.JunitResult
local jres = items[1]

results[id] = jres:result()
results[id] = to_neotest_result(jres:result(), deps.output_writer)
else
local _id = vim
.iter(tree:iter())
Expand All @@ -83,7 +102,7 @@ local ResultBuilder = function(deps)
end)

if _id then
results[_id] = JunitResult.merge_results(items, deps.tempname_fn)
results[_id] = to_neotest_result(JunitResult.merge_results(items), deps.output_writer)
else
log.error("Could not find matching test node for results with id: " .. items[1]:id())
end
Expand Down
81 changes: 81 additions & 0 deletions lua/neotest-java/model/junit_failure.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
--- @class neotest-java.JunitFailure
--- @field failure_message string
--- @field failure_output string | nil

local M = {}

---@param output string | nil
---@param fallback? string
---@return string
local function failure_message_from_output(output, fallback)
if type(output) ~= "string" then
return fallback or "<unknown failure>"
end

local first_line = output:match("^%s*([^\n]+)")
if not first_line then
return fallback or "<unknown failure>"
end

return first_line:match("^[%w%._$]+:%s*(.+)$") or first_line
end

--- Parses a `failure` or `error` XML node (as produced by the JUnit XML
--- reader) into a normalized `{failure_message, failure_output}` shape.
---
--- JUnit XML failure/error nodes can appear in several shapes depending on
--- the xml parser and the report itself:
--- - a scalar (string) node
--- - a node with a `_attr` table (single failure with `message`/`type` attrs)
--- - an array-like node without `_attr` (ambiguous parsing quirk, see below)
---
---@param node table | string
---@return neotest-java.JunitFailure
function M.from_node(node)
if type(node) ~= "table" then
local output = tostring(node)
return {
failure_message = failure_message_from_output(output),
failure_output = output,
}
end

if node._attr then
return {
failure_message = node._attr.message or node._attr.type or "<unknown failure>",
failure_output = node[1],
}
end

-- This is not parsed correctly by the xml library
-- <failure message="expected: &lt;1> but was: &lt;2>" type="org.opentest4j.AssertionFailedError">
-- it breaks in the first '>'
-- so it does not detect the message attribute sometimes
local output = type(node[#node]) == "string" and node[#node] or nil
local fallback = type(node[1]) == "string" and node[1]:match('type="([^"]+)"') or nil
return {
failure_message = failure_message_from_output(output, fallback),
failure_output = output,
}
end

--- Scrapes the 0-indexed line number of the failing test from a stack trace,
--- matching against the given fully-qualified `classname`.
---
---@param failure_output string | nil
---@param classname string
---@return integer | nil
function M.line_number(failure_output, classname)
if not failure_output then
return nil
end

local filename = string.match(classname, "[%.]?([%a%$_][%a%d%$_]+)$") .. ".java"
local line_searchpattern = string.gsub(filename, "%.", "%%.") .. ":(%d+)%)"

local line = string.match(failure_output, line_searchpattern)
-- NOTE: errors array is expecting lines properties to be 0 index based
return line and line - 1 or nil
end

return M
Loading
Loading