diff --git a/lua/neotest-java/core/junit_result_reader.lua b/lua/neotest-java/core/junit_result_reader.lua index 5c313d8e..9bb42301 100644 --- a/lua/neotest-java/core/junit_result_reader.lua +++ b/lua/neotest-java/core/junit_result_reader.lua @@ -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 @@ -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 { @@ -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 diff --git a/lua/neotest-java/core/result_builder.lua b/lua/neotest-java/core/result_builder.lua index 29cf132f..82a4b0ae 100644 --- a/lua/neotest-java/core/result_builder.lua +++ b/lua/neotest-java/core/result_builder.lua @@ -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$" @@ -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 -- ----------------------------------------------------------------------------- @@ -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 @@ -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 } }) @@ -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 @@ -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()) @@ -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 diff --git a/lua/neotest-java/model/junit_failure.lua b/lua/neotest-java/model/junit_failure.lua new file mode 100644 index 00000000..36bf6fda --- /dev/null +++ b/lua/neotest-java/model/junit_failure.lua @@ -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 "" + end + + local first_line = output:match("^%s*([^\n]+)") + if not first_line then + return fallback or "" + 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 "", + failure_output = node[1], + } + end + + -- This is not parsed correctly by the xml library + -- + -- 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 diff --git a/lua/neotest-java/model/junit_result.lua b/lua/neotest-java/model/junit_result.lua index 30d1c580..86280526 100644 --- a/lua/neotest-java/model/junit_result.lua +++ b/lua/neotest-java/model/junit_result.lua @@ -1,112 +1,45 @@ -local nio = require("nio") +local junit_failure = require("neotest-java.model.junit_failure") +local junit_status = require("neotest-java.model.junit_status") local FAILED = require("neotest.types").ResultStatus.failed local PASSED = require("neotest.types").ResultStatus.passed local SKIPPED = require("neotest.types").ResultStatus.skipped -local LINE_SEPARATOR = "=================================\n" local NEW_LINE = "\n" ----@param data string | string[] | table ----@param tempname? fun(): string ----@return string | nil filepath -local function create_file_with_content(data, tempname) - if not data then - return nil - end - - if type(data) == "table" then - if #data == 0 then - return nil - end - data = table.concat(vim.iter(vim.tbl_values(data)):flatten(math.huge):totable(), LINE_SEPARATOR) - end - - -- Generate a unique temporary file name - local filepath = (tempname or nio.fn.tempname)() - - nio.run(function() - -- Open the file in write mode - local file = assert(io.open(filepath, "w")) - - file:write(data) - - -- Close the file - file:close() - end) - - -- Return the path to the file - return filepath -end - ----@class neotest-java.JunitResult ----@field testcase table ----@field tempname? fun(): string +--- @class neotest-java.JunitResult +--- @field testcase table local JunitResult = {} -local function failure_message_from_output(output, fallback) - if type(output) ~= "string" then - return fallback or "" - end - - local first_line = output:match("^%s*([^\n]+)") - if not first_line then - return fallback or "" - end - - return first_line:match("^[%w%._$]+:%s*(.+)$") or first_line -end - -local function failure_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 "", - failure_output = node[1], - } - end - - 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 +--- @class neotest-java.JunitResultData +--- @field status neotest.ResultStatus +--- @field output_lines string | string[] +--- @field errors neotest.Error[] | nil +--- @field short string | nil ---@param id string ----@param tempname? fun(): string ----@return neotest.Result -function JunitResult.SKIPPED(id, tempname) +---@return neotest-java.JunitResultData +function JunitResult.SKIPPED(id) return { status = SKIPPED, - output = create_file_with_content({ id, "This test was not executed." }, tempname), + output_lines = { id, "This test was not executed." }, } end ---@param id string ---@param output? string ----@param tempname? fun(): string ----@return neotest.Result -function JunitResult.ERROR(id, output, tempname) +---@return neotest-java.JunitResultData +function JunitResult.ERROR(id, output) return { status = "failed", - output = output or create_file_with_content({ id, "This test execution had an unexpected error." }, tempname), + output_lines = output or { id, "This test execution had an unexpected error." }, } end ---@param testcase table ----@param tempname? fun(): string -function JunitResult:new(testcase, tempname) +function JunitResult:new(testcase) self.__index = self - return setmetatable({ testcase = testcase, tempname = tempname }, self) + return setmetatable({ testcase = testcase }, self) end function JunitResult:id() @@ -129,26 +62,7 @@ end ---@return neotest.ResultStatus ---@return table an array-like table containing tables of the form {failure_message: string, failure_output: string} function JunitResult:status() - local failed = self.testcase.failure or self.testcase.error - -- This is not parsed correctly by the library - -- - -- it breaks in the first '>' - -- so it does not detect message attribute sometimes - if failed and not failed._attr then - local failures = {} - for i, fail in ipairs(failed) do - if type(fail) ~= "table" then - return FAILED, { failure_from_node(failed) } - end - - failures[i] = failure_from_node(fail) - end - return FAILED, failures - end - if failed and failed._attr then - return FAILED, { failure_from_node(failed) } - end - return PASSED, {} + return junit_status.derive(self.testcase) end ---@param with_name_prefix? boolean @@ -160,17 +74,10 @@ function JunitResult:errors(with_name_prefix) return nil end - local filename = string.match(self:classname(), "[%.]?([%a%$_][%a%d%$_]+)$") .. ".java" - local line_searchpattern = string.gsub(filename, "%.", "%%.") .. ":(%d+)%)" local errors = {} for i, failure in ipairs(failures) do - local line - if failure.failure_output then - line = string.match(failure.failure_output, line_searchpattern) - -- NOTE: errors array is expecting lines properties to be 0 index based - line = line and line - 1 or nil - end + local line = junit_failure.line_number(failure.failure_output, self:classname()) local failure_message = failure.failure_message if with_name_prefix then @@ -228,42 +135,41 @@ function JunitResult:output() return output_lines end ---- Convert neotest-java.JunitResult to neotest.Result ---- Each time this function is called, it will create a temporary file with the output content ----@return neotest.Result -function JunitResult:result() - local status, failures = self:status() - +---@return string | nil +local function short_failure_message(status, failures) if status == PASSED then - return { - status = status, - output = create_file_with_content(self:output(), self.tempname), - } + return nil end - local failure_message = "" - if failures then - for i, failure in ipairs(failures) do - failure_message = failure_message .. failure.failure_message - if i < #failures then - failure_message = failure_message .. NEW_LINE - end + local message = "" + for i, failure in ipairs(failures) do + message = message .. failure.failure_message + if i < #failures then + message = message .. NEW_LINE end end + return message +end + +--- Converts this JunitResult into plain result data: status, output lines, +--- errors and a short failure summary. Callers (e.g. `result_builder`) are +--- responsible for turning `output_lines` into a `neotest.Result.output` +--- filepath via an `neotest-java.OutputWriter` — this method does no I/O. +---@return neotest-java.JunitResultData +function JunitResult:result() + local status, failures = self:status() return { status = status, - short = failure_message, + output_lines = self:output(), errors = self:errors(), - output = create_file_with_content(self:output(), self.tempname), + short = short_failure_message(status, failures), } end ---@param results neotest-java.JunitResult[] ----@param tempname? fun(): string ----@return neotest.Result -function JunitResult.merge_results(results, tempname) - tempname = tempname or (results[1] and results[1].tempname) +---@return neotest-java.JunitResultData +function JunitResult.merge_results(results) table.sort(results, function(a, b) return a:name() < b:name() end) @@ -272,53 +178,52 @@ function JunitResult.merge_results(results, tempname) return result:status() == FAILED end) and FAILED or PASSED - local output = vim.iter(results) + local output_lines = vim.iter(results) :map(function(result) return result:output() end) :flatten(math.huge) :totable() - if status == PASSED then - return { status = status, output = create_file_with_content(output, tempname) } - end - - local errors = vim.iter(results) - :map(function(result) - return result:errors(true) - end) - :flatten() - :totable() - - local short = vim.iter(results) - :filter(function(result) - return result:status() == FAILED - end) - :map(function(result) - return result:errors(), result:name() - end) - :map(function(error, name) - if #error == 1 then - return name .. " -> " .. error[1].message - end + local errors, short + if status == FAILED then + errors = vim.iter(results) + :map(function(result) + return result:errors(true) + end) + :flatten() + :totable() + + short = vim.iter(results) + :filter(function(result) + return result:status() == FAILED + end) + :map(function(result) + return result:errors(), result:name() + end) + :map(function(error, name) + if #error == 1 then + return name .. " -> " .. error[1].message + end - local errs = name .. " -> {" .. NEW_LINE - for i, err in ipairs(error) do - errs = errs .. err.message - if i < #error then - errs = errs .. NEW_LINE + local errs = name .. " -> {" .. NEW_LINE + for i, err in ipairs(error) do + errs = errs .. err.message + if i < #error then + errs = errs .. NEW_LINE + end end - end - return errs .. NEW_LINE .. "}" - end) - :fold(nil, function(a, b) - if not a then - return b - end - return a .. NEW_LINE .. b - end) + return errs .. NEW_LINE .. "}" + end) + :fold(nil, function(a, b) + if not a then + return b + end + return a .. NEW_LINE .. b + end) + end - return { status = status, errors = errors, short = short, output = create_file_with_content(output, tempname) } + return { status = status, output_lines = output_lines, errors = errors, short = short } end return JunitResult diff --git a/lua/neotest-java/model/junit_status.lua b/lua/neotest-java/model/junit_status.lua new file mode 100644 index 00000000..4a615b72 --- /dev/null +++ b/lua/neotest-java/model/junit_status.lua @@ -0,0 +1,37 @@ +local junit_failure = require("neotest-java.model.junit_failure") + +local FAILED = require("neotest.types").ResultStatus.failed +local PASSED = require("neotest.types").ResultStatus.passed + +local M = {} + +--- Derives the neotest result status (and normalized failures) from a +--- JUnit XML `testcase` node. +--- +---@param testcase table +---@return neotest.ResultStatus +---@return table an array-like table containing tables of the form {failure_message: string, failure_output: string} +function M.derive(testcase) + local failed = testcase.failure or testcase.error + -- This is not parsed correctly by the library + -- + -- it breaks in the first '>' + -- so it does not detect message attribute sometimes + if failed and not failed._attr then + local failures = {} + for i, fail in ipairs(failed) do + if type(fail) ~= "table" then + return FAILED, { junit_failure.from_node(failed) } + end + + failures[i] = junit_failure.from_node(fail) + end + return FAILED, failures + end + if failed and failed._attr then + return FAILED, { junit_failure.from_node(failed) } + end + return PASSED, {} +end + +return M diff --git a/lua/neotest-java/util/output_writer.lua b/lua/neotest-java/util/output_writer.lua new file mode 100644 index 00000000..5bcc15b8 --- /dev/null +++ b/lua/neotest-java/util/output_writer.lua @@ -0,0 +1,69 @@ +--- OutputWriter +--- +--- The single place in this codebase that writes test output to a +--- temporary file for neotest to read back (`neotest.Result.output` +--- expects a filepath, not raw text). Everything else — `JunitResult`, +--- `result_builder` — deals only with in-memory output data; this module +--- owns the actual I/O. +--- +--- Usage: +--- local OutputWriter = require("neotest-java.util.output_writer") +--- local writer = OutputWriter({ tempname_fn = nio.fn.tempname }) +--- local filepath = writer.write({ "line one", "line two" }) +--- +--- Tests typically inject a stub `tempname_fn` (returns a known path) and +--- a stub `open_file` (records writes without touching the filesystem). + +local nio = require("nio") + +local LINE_SEPARATOR = "=================================\n" + +--- @class neotest-java.OutputWriterDeps +--- @field tempname_fn? fun(): string +--- @field open_file? fun(filepath: string, mode: string): file*|nil, string? +--- @field run? fun(fn: function) + +--- @class neotest-java.OutputWriter +--- @field write fun(data: string | table | nil): string | nil + +--- @param deps neotest-java.OutputWriterDeps | nil +--- @return neotest-java.OutputWriter +local OutputWriter = function(deps) + deps = deps or {} + deps.tempname_fn = deps.tempname_fn or nio.fn.tempname + deps.open_file = deps.open_file or io.open + deps.run = deps.run or nio.run + + return { + --- Writes `data` to a new temporary file and returns its path. + --- Nested tables (e.g. an array of output lines, possibly containing + --- further nested arrays) are flattened and joined with a visual + --- separator before writing. + --- @param data string | table | nil + --- @return string | nil filepath + write = function(data) + if not data then + return nil + end + + if type(data) == "table" then + if #data == 0 then + return nil + end + data = table.concat(vim.iter(vim.tbl_values(data)):flatten(math.huge):totable(), LINE_SEPARATOR) + end + + local filepath = deps.tempname_fn() + + deps.run(function() + local file = assert(deps.open_file(filepath, "w")) + file:write(data) + file:close() + end) + + return filepath + end, + } +end + +return OutputWriter diff --git a/tests/unit/test_junit_failure_spec.lua b/tests/unit/test_junit_failure_spec.lua new file mode 100644 index 00000000..71f43318 --- /dev/null +++ b/tests/unit/test_junit_failure_spec.lua @@ -0,0 +1,75 @@ +---@diagnostic disable: undefined-field +local junit_failure = require("neotest-java.model.junit_failure") +local eq = require("tests.assertions").eq + +describe("junit_failure", function() + describe("from_node", function() + it("parses a scalar node", function() + local failure = junit_failure.from_node("boom") + eq(failure.failure_message, "boom") + eq(failure.failure_output, "boom") + end) + + it("uses the first line as message when it has no type prefix", function() + local failure = junit_failure.from_node("assertion failed\nsome stack trace") + eq(failure.failure_message, "assertion failed") + eq(failure.failure_output, "assertion failed\nsome stack trace") + end) + + it("strips a leading `Type:` prefix from the first line", function() + local failure = + junit_failure.from_node("org.opentest4j.AssertionFailedError: expected <1> but was <2>\nstack") + eq(failure.failure_message, "expected <1> but was <2>") + end) + + it("parses a node with an _attr table (message present)", function() + local node = { + _attr = { message = "expected: <1> but was: <2>", type = "org.opentest4j.AssertionFailedError" }, + "stacktrace here", + } + local failure = junit_failure.from_node(node) + eq(failure.failure_message, "expected: <1> but was: <2>") + eq(failure.failure_output, "stacktrace here") + end) + + it("falls back to the type attribute when message is missing", function() + local node = { _attr = { type = "java.lang.AssertionError" } } + local failure = junit_failure.from_node(node) + eq(failure.failure_message, "java.lang.AssertionError") + end) + + it("falls back to '' when neither message nor type is present", function() + local node = { _attr = {} } + local failure = junit_failure.from_node(node) + eq(failure.failure_message, "") + end) + + it("parses an array-like node without _attr (xml parser quirk)", function() + -- Simulates the case where the xml parser breaks on the first '>' + -- inside the message attribute and produces an array-like node. + local node = { + 'type="org.opentest4j.AssertionFailedError', + "full stack trace output", + } + local failure = junit_failure.from_node(node) + eq(failure.failure_message, "full stack trace output") + eq(failure.failure_output, "full stack trace output") + end) + end) + + describe("line_number", function() + it("returns nil when there is no failure output", function() + eq(junit_failure.line_number(nil, "com.example.ExampleTest"), nil) + end) + + it("extracts a 0-indexed line number matching the classname's simple name", function() + local output = "at com.example.ExampleTest.someTest(ExampleTest.java:42)" + eq(junit_failure.line_number(output, "com.example.ExampleTest"), 41) + end) + + it("returns nil when the output does not reference the test file", function() + local output = "at some.other.Class.method(Other.java:10)" + eq(junit_failure.line_number(output, "com.example.ExampleTest"), nil) + end) + end) +end) diff --git a/tests/unit/test_junit_result_reader_social_spec.lua b/tests/unit/test_junit_result_reader_social_spec.lua index e188cd60..f568958e 100644 --- a/tests/unit/test_junit_result_reader_social_spec.lua +++ b/tests/unit/test_junit_result_reader_social_spec.lua @@ -14,8 +14,8 @@ local P2 = Path("/fake/TEST-B.xml") local K1 = tostring(P1) local K2 = tostring(P2) ---- Build a reader with a stub xml_reader keyed by stringified filepath, plus ---- a known tempname. Records warns in `warned` if provided. +--- Build a reader with a stub xml_reader keyed by stringified filepath. +--- Records warns in `warned` if provided. local function reader_with(trees, warned) local function parse(filepath) local key = tostring(filepath) @@ -26,9 +26,6 @@ local function reader_with(trees, warned) end return JunitResultReader({ xml_reader = { parse = parse }, - tempname_fn = function() - return "/tmp/social.txt" - end, log = { debug = function() end, warn = function(...) diff --git a/tests/unit/test_junit_result_reader_spec.lua b/tests/unit/test_junit_result_reader_spec.lua index becf819f..27d7b985 100644 --- a/tests/unit/test_junit_result_reader_spec.lua +++ b/tests/unit/test_junit_result_reader_spec.lua @@ -30,14 +30,11 @@ local function stub_xml_reader(trees) return { parse = parse } end ---- Build a JunitResultReader with the given trees and a known tempname. ---- Records warn calls in `warned`. -local function make_reader(trees, warned, tempname_fn) +--- Build a JunitResultReader with the given trees. Records warn calls in +--- `warned`. +local function make_reader(trees, warned) local xml_reader = stub_xml_reader(trees) warned = warned or {} - tempname_fn = tempname_fn or function() - return "/tmp/test-output.txt" - end local log = { debug = function() end, warn = function(...) @@ -46,7 +43,6 @@ local function make_reader(trees, warned, tempname_fn) } return JunitResultReader({ xml_reader = xml_reader, - tempname_fn = tempname_fn, log = log, }) end @@ -88,10 +84,6 @@ describe("JunitResultReader", function() eq("com.example.ExampleTest#a()", results[1]:id()) eq("com.example.ExampleTest#b()", results[2]:id()) eq("com.example.ExampleTest#c()", results[3]:id()) - -- and each was constructed with the injected tempname function - for _, jres in ipairs(results) do - eq("function", type(jres.tempname)) - end -- and the testcase payload is preserved eq("a()", results[1].testcase._attr.name) end) @@ -200,31 +192,6 @@ describe("JunitResultReader", function() eq(1, #warned) end) - it("stores the injected tempname function on every JunitResult (no call yet)", function() - -- given - local tree = { - _attr = {}, - testsuite = { _attr = {}, testcase = { TC("a()"), TC("b()") } }, - } - local xml_reader = stub_xml_reader({ [K1] = { tree = tree, error = nil } }) - local injected = function() - return "/tmp/should-not-be-called.txt" - end - local reader = JunitResultReader({ - xml_reader = xml_reader, - tempname_fn = injected, - log = { debug = function() end, warn = function() end }, - }) - - -- when - local results = reader.read_all({ P1 }) - - -- then — every JunitResult holds a reference to the injected function - eq(2, #results) - eq(injected, results[1].tempname) - eq(injected, results[2].tempname) - end) - it("uses the injected xml_reader — no real XmlReader construction", function() -- given — a reader with an xml_reader that records its calls local calls = 0 @@ -237,9 +204,6 @@ describe("JunitResultReader", function() local warned = {} local reader = JunitResultReader({ xml_reader = xml_reader, - tempname_fn = function() - return "/tmp/x" - end, log = { debug = function() end, warn = function(...) diff --git a/tests/unit/test_junit_result_spec.lua b/tests/unit/test_junit_result_spec.lua new file mode 100644 index 00000000..32fd53ce --- /dev/null +++ b/tests/unit/test_junit_result_spec.lua @@ -0,0 +1,165 @@ +---@diagnostic disable: undefined-field +local JunitResult = require("neotest-java.model.junit_result") +local ResultStatus = require("neotest.types").ResultStatus +local eq = require("tests.assertions").eq + +--- Build a passing testcase (xml2lua shape). +local function passing(name, classname) + return { + _attr = { name = name, classname = classname or "com.example.ExampleTest", time = "0" }, + } +end + +--- Build a failing testcase (xml2lua shape, single failure with a body). +local function failing(name, classname, message, body) + return { + _attr = { name = name, classname = classname or "com.example.ExampleTest", time = "0.001" }, + failure = { + _attr = { message = message, type = "org.opentest4j.AssertionFailedError" }, + body, + }, + } +end + +describe("JunitResult", function() + describe("id/name/classname", function() + it("builds the id from classname and name", function() + local jres = JunitResult:new(passing("shouldWork()", "com.example.Foo")) + eq("com.example.Foo#shouldWork()", jres:id()) + end) + + it("strips the parameterized-test iteration suffix from id", function() + local jres = JunitResult:new(passing("shouldWork(int)[1]", "com.example.Foo")) + eq("com.example.Foo#shouldWork(int)", jres:id()) + end) + + it("exposes name and classname directly from the testcase", function() + local jres = JunitResult:new(passing("shouldWork()", "com.example.Foo")) + eq("shouldWork()", jres:name()) + eq("com.example.Foo", jres:classname()) + end) + end) + + describe("status", function() + it("is PASSED with no failures for a testcase with no failure/error node", function() + local status, failures = JunitResult:new(passing("a()")):status() + eq(ResultStatus.passed, status) + eq({}, failures) + end) + + it("is FAILED with the parsed failure for a testcase with a failure node", function() + local status, failures = JunitResult:new(failing("a()", nil, "expected true", "stack")):status() + eq(ResultStatus.failed, status) + eq(1, #failures) + eq("expected true", failures[1].failure_message) + end) + end) + + describe("errors", function() + it("returns nil when passed (no errors to report)", function() + local jres = JunitResult:new(passing("a()")) + eq(nil, jres:errors()) + end) + + it("returns one neotest.Error per failure, with the line scraped from the stack trace", function() + local body = "org.opentest4j.AssertionFailedError: expected true\n" .. "at com.example.Foo.a(Foo.java:42)" + local jres = JunitResult:new(failing("a()", "com.example.Foo", "expected true", body)) + + local errors = jres:errors() + + eq({ { message = "expected true", line = 41 } }, errors) + end) + + it("prefixes the message with the test name when with_name_prefix is true", function() + local jres = JunitResult:new(failing("a()", nil, "expected true", "stack")) + local errors = jres:errors(true) + assert(errors, "errors should not be nil for a failed testcase") + eq("a() -> expected true", errors[1].message) + end) + end) + + describe("output", function() + it("is a single 'Test passed' line when passed", function() + local jres = JunitResult:new(passing("a()")) + eq({ "Test passed\n" }, jres:output()) + end) + + it("includes the raw failure output (stack trace) when failed", function() + local jres = JunitResult:new(failing("a()", nil, "expected true", "stack trace here")) + eq({ "stack trace here", "\n" }, jres:output()) + end) + end) + + describe("result()", function() + it("returns exactly {status, output_lines, errors, short} for a failed testcase", function() + local jres = JunitResult:new(failing("a()", nil, "expected true", "stack")) + + eq({ + status = ResultStatus.failed, + output_lines = jres:output(), + errors = jres:errors(), + short = "expected true", + }, jres:result()) + end) + + it("omits short/errors when passed, but still includes output_lines", function() + local jres = JunitResult:new(passing("a()")) + + eq({ + status = ResultStatus.passed, + output_lines = jres:output(), + }, jres:result()) + end) + end) + + describe("SKIPPED / ERROR (static results, no testcase involved)", function() + it("SKIPPED is a skipped result explaining the test didn't run", function() + eq({ + status = ResultStatus.skipped, + output_lines = { "com.example.Foo#a()", "This test was not executed." }, + }, JunitResult.SKIPPED("com.example.Foo#a()")) + end) + + it("ERROR uses the given output verbatim when provided", function() + eq({ + status = "failed", + output_lines = "boom output", + }, JunitResult.ERROR("com.example.Foo#a()", "boom output")) + end) + + it("ERROR falls back to an explanatory message when no output is given", function() + eq({ + status = "failed", + output_lines = { "com.example.Foo#a()", "This test execution had an unexpected error." }, + }, JunitResult.ERROR("com.example.Foo#a()")) + end) + end) + + describe("merge_results() — combining parameterized-test iterations", function() + it("is PASSED, with every iteration's output concatenated, when all iterations pass", function() + local a = JunitResult:new(passing("a(int)[2]")) + local b = JunitResult:new(passing("a(int)[1]")) + + local data = JunitResult.merge_results({ a, b }) + + eq(ResultStatus.passed, data.status) + eq(nil, data.errors) + -- sorted by name: [1] before [2] + local expected_output = {} + vim.list_extend(expected_output, b:output()) + vim.list_extend(expected_output, a:output()) + eq(expected_output, data.output_lines) + end) + + it("is FAILED as a whole when any single iteration fails", function() + local a = JunitResult:new(passing("a(int)[1]")) + local b = JunitResult:new(failing("a(int)[2]", nil, "expected true", "stack")) + + local data = JunitResult.merge_results({ a, b }) + + eq(ResultStatus.failed, data.status) + eq(1, #data.errors) + eq("a(int)[2] -> expected true", data.errors[1].message) + end) + end) +end) diff --git a/tests/unit/test_junit_status_spec.lua b/tests/unit/test_junit_status_spec.lua new file mode 100644 index 00000000..d9396645 --- /dev/null +++ b/tests/unit/test_junit_status_spec.lua @@ -0,0 +1,55 @@ +---@diagnostic disable: undefined-field +local junit_status = require("neotest-java.model.junit_status") +local ResultStatus = require("neotest.types").ResultStatus +local eq = require("tests.assertions").eq + +describe("junit_status", function() + describe("derive", function() + it("returns PASSED with no failures when the testcase has no failure/error", function() + local status, failures = junit_status.derive({ _attr = { name = "test" } }) + eq(status, ResultStatus.passed) + eq(failures, {}) + end) + + it("returns FAILED for a single failure node with _attr", function() + local testcase = { + failure = { _attr = { message = "boom" }, "stacktrace" }, + } + local status, failures = junit_status.derive(testcase) + eq(status, ResultStatus.failed) + eq(#failures, 1) + eq(failures[1].failure_message, "boom") + end) + + it("returns FAILED for a single error node with _attr", function() + local testcase = { + error = { _attr = { message = "boom" }, "stacktrace" }, + } + local status, failures = junit_status.derive(testcase) + eq(status, ResultStatus.failed) + eq(#failures, 1) + end) + + it("returns FAILED with multiple failures for an array-like failure node (parameterized test)", function() + local testcase = { + failure = { + { _attr = { message = "first" } }, + { _attr = { message = "second" } }, + }, + } + local status, failures = junit_status.derive(testcase) + eq(status, ResultStatus.failed) + eq(#failures, 2) + eq(failures[1].failure_message, "first") + eq(failures[2].failure_message, "second") + end) + + it("returns FAILED for a scalar failure node inside an array-like table", function() + local testcase = { failure = { "boom" } } + local status, failures = junit_status.derive(testcase) + eq(status, ResultStatus.failed) + eq(#failures, 1) + eq(failures[1].failure_message, "boom") + end) + end) +end) diff --git a/tests/unit/test_output_writer_spec.lua b/tests/unit/test_output_writer_spec.lua new file mode 100644 index 00000000..b099c749 --- /dev/null +++ b/tests/unit/test_output_writer_spec.lua @@ -0,0 +1,107 @@ +---@diagnostic disable: undefined-field +local OutputWriter = require("neotest-java.util.output_writer") +local eq = require("tests.assertions").eq + +-- Mirrors the private LINE_SEPARATOR constant in +-- neotest-java.util.output_writer — kept in sync manually since the +-- constant isn't exported (it's an internal formatting detail). +local LINE_SEPARATOR = "=================================\n" + +--- Build an OutputWriter whose I/O is entirely stubbed: `open_file` and +--- `run` never touch a real file or the async scheduler. Returns the +--- writer plus a `written` list you can inspect: each entry is +--- `{ filepath = string, data = string }`, one per `write()` call. +--- @param opts? { tempname_fn?: fun(): string } +--- @return neotest-java.OutputWriter +--- @return { filepath: string, data: string }[] +local function make_writer(opts) + opts = opts or {} + local written = {} + + local counter = 0 + local tempname_fn = opts.tempname_fn or function() + counter = counter + 1 + return "/fake/tmp-" .. counter + end + + local writer = OutputWriter({ + tempname_fn = tempname_fn, + open_file = function(filepath) + return { + write = function(_, data) + table.insert(written, { filepath = filepath, data = data }) + end, + close = function() end, + } + end, + -- run synchronously (instead of nio.run) so tests can assert on + -- `written` immediately after calling write(), with no scheduling. + run = function(fn) + fn() + end, + }) + + return writer, written +end + +describe("OutputWriter", function() + it("returns nil and writes nothing when data is nil", function() + local writer, written = make_writer() + + local filepath = writer.write(nil) + + eq(nil, filepath) + eq({}, written) + end) + + it("returns nil and writes nothing when data is an empty table", function() + local writer, written = make_writer() + + local filepath = writer.write({}) + + eq(nil, filepath) + eq({}, written) + end) + + it("writes a plain string to a new tempfile and returns its path", function() + local writer, written = make_writer({ + tempname_fn = function() + return "/fake/tmp-1" + end, + }) + + local filepath = writer.write("hello world") + + eq("/fake/tmp-1", filepath) + eq({ { filepath = "/fake/tmp-1", data = "hello world" } }, written) + end) + + it("joins an array of lines with the visual separator, in order", function() + local writer, written = make_writer() + + writer.write({ "line one", "line two" }) + + eq("line one" .. LINE_SEPARATOR .. "line two", written[1].data) + end) + + it("flattens nested tables before writing, preserving order", function() + local writer, written = make_writer() + + writer.write({ { "nested one" }, "top level" }) + + eq("nested one" .. LINE_SEPARATOR .. "top level", written[1].data) + end) + + it("uses a fresh tempname for every write() call", function() + local writer, written = make_writer() + + local first = writer.write("a") + local second = writer.write("b") + + assert(first ~= second, "each write() should get its own tempfile: got " .. tostring(first) .. " twice") + eq({ + { filepath = first, data = "a" }, + { filepath = second, data = "b" }, + }, written) + end) +end) diff --git a/tests/unit/test_result_builder_spec.lua b/tests/unit/test_result_builder_spec.lua index 7e24b549..04dcac4f 100644 --- a/tests/unit/test_result_builder_spec.lua +++ b/tests/unit/test_result_builder_spec.lua @@ -24,9 +24,7 @@ local DEFAULT_SPEC = { --- Build a JunitResult from a testcase table shaped the way xml2lua would --- produce it (with `_attr`, optional `failure` / `error` / `system-out` / `system-err`). local function jr(testcase) - return JunitResult:new(testcase, function() - return TEMPNAME - end) + return JunitResult:new(testcase) end --- Build a passing testcase (xml2lua shape).