From a559c0adba47a03f7ed503d8a5bb98a84335e9df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Cas=C3=ADa?= <31012661+rcasia@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:34:22 +0200 Subject: [PATCH 1/8] chore: start junit_result.lua decomposition From 0dbded10e5821505cdd9ba2e9e41fce7ead55867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Cas=C3=ADa?= <31012661+rcasia@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:36:36 +0200 Subject: [PATCH 2/8] refactor(model): extract junit failure-node parsing --- lua/neotest-java/model/junit_failure.lua | 81 ++++++++++++++++++++++++ lua/neotest-java/model/junit_result.lua | 53 ++-------------- 2 files changed, 86 insertions(+), 48 deletions(-) create mode 100644 lua/neotest-java/model/junit_failure.lua 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..eacf9966 100644 --- a/lua/neotest-java/model/junit_result.lua +++ b/lua/neotest-java/model/junit_result.lua @@ -1,4 +1,5 @@ local nio = require("nio") +local junit_failure = require("neotest-java.model.junit_failure") local FAILED = require("neotest.types").ResultStatus.failed local PASSED = require("neotest.types").ResultStatus.passed @@ -44,43 +45,6 @@ end ---@field tempname? fun(): string 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 - ---@param id string ---@param tempname? fun(): string ---@return neotest.Result @@ -138,15 +102,15 @@ function JunitResult:status() local failures = {} for i, fail in ipairs(failed) do if type(fail) ~= "table" then - return FAILED, { failure_from_node(failed) } + return FAILED, { junit_failure.from_node(failed) } end - failures[i] = failure_from_node(fail) + failures[i] = junit_failure.from_node(fail) end return FAILED, failures end if failed and failed._attr then - return FAILED, { failure_from_node(failed) } + return FAILED, { junit_failure.from_node(failed) } end return PASSED, {} end @@ -160,17 +124,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 From a963e634af605a7dc158aacf96e88852290a67fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Cas=C3=ADa?= <31012661+rcasia@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:37:03 +0200 Subject: [PATCH 3/8] refactor(model): extract result-status derivation --- lua/neotest-java/model/junit_result.lua | 22 ++------------- lua/neotest-java/model/junit_status.lua | 37 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 20 deletions(-) create mode 100644 lua/neotest-java/model/junit_status.lua diff --git a/lua/neotest-java/model/junit_result.lua b/lua/neotest-java/model/junit_result.lua index eacf9966..659206dc 100644 --- a/lua/neotest-java/model/junit_result.lua +++ b/lua/neotest-java/model/junit_result.lua @@ -1,5 +1,6 @@ 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 @@ -93,26 +94,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, { 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, {} + return junit_status.derive(self.testcase) end ---@param with_name_prefix? boolean 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 From de7bff43a9172acdd124889523e6be3048f87084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Cas=C3=ADa?= <31012661+rcasia@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:37:35 +0200 Subject: [PATCH 4/8] refactor(model): share result-building primitives between result() and merge_results() --- lua/neotest-java/model/junit_result.lua | 114 +++++++++++++----------- 1 file changed, 62 insertions(+), 52 deletions(-) diff --git a/lua/neotest-java/model/junit_result.lua b/lua/neotest-java/model/junit_result.lua index 659206dc..d3d7401f 100644 --- a/lua/neotest-java/model/junit_result.lua +++ b/lua/neotest-java/model/junit_result.lua @@ -167,21 +167,37 @@ function JunitResult:output() return output_lines end +--- Builds the final `neotest.Result` table shared by `result()` and +--- `merge_results()`, writing `output_lines` to a temporary file and +--- omitting `short`/`errors` when the status is PASSED. +---@param status neotest.ResultStatus +---@param output_lines string[] +---@param errors neotest.Error[] | nil +---@param short string | nil +---@param tempname? fun(): string +---@return neotest.Result +local function build_neotest_result(status, output_lines, errors, short, tempname) + if status == PASSED then + return { status = status, output = create_file_with_content(output_lines, tempname) } + end + + return { + status = status, + short = short, + errors = errors, + output = create_file_with_content(output_lines, tempname), + } +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() - if status == PASSED then - return { - status = status, - output = create_file_with_content(self:output(), self.tempname), - } - end - - local failure_message = "" - if failures then + local failure_message + if status ~= PASSED then + failure_message = "" for i, failure in ipairs(failures) do failure_message = failure_message .. failure.failure_message if i < #failures then @@ -190,12 +206,7 @@ function JunitResult:result() end end - return { - status = status, - short = failure_message, - errors = self:errors(), - output = create_file_with_content(self:output(), self.tempname), - } + return build_neotest_result(status, self:output(), self:errors(), failure_message, self.tempname) end ---@param results neotest-java.JunitResult[] @@ -218,46 +229,45 @@ function JunitResult.merge_results(results, tempname) :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 build_neotest_result(status, output, errors, short, tempname) end return JunitResult From 49071384f199e506ca898cb7a3248d10229eca66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Cas=C3=ADa?= <31012661+rcasia@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:38:12 +0200 Subject: [PATCH 5/8] test(model): add focused unit tests for junit_failure and junit_status --- tests/unit/test_junit_failure_spec.lua | 75 ++++++++++++++++++++++++++ tests/unit/test_junit_status_spec.lua | 55 +++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 tests/unit/test_junit_failure_spec.lua create mode 100644 tests/unit/test_junit_status_spec.lua 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_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) From 7786434744266706b57acc13ffab18b5b68948be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Cas=C3=ADa?= <31012661+rcasia@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:37:52 +0200 Subject: [PATCH 6/8] refactor(model): separate file-writing from JunitResult's domain logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JunitResult still had a private create_file_with_content function doing real I/O (nio.fn.tempname, io.open, write, close), called directly from :result() and merge_results(). This tangled 'what happened in this test' (domain logic) with 'persist this output for neotest to read' (I/O concern) in the same class. - New neotest-java.util.output_writer: the only module that knows about io.open/nio.fn.tempname now. Function-as-constructor DI, injectable tempname_fn/open_file/run for fully I/O-free unit testing. - model/junit_result.lua is now pure: no nio/io requires at all. :result() and .merge_results() return plain data (status/output_lines/errors/short) instead of a neotest.Result with a file path already written. SKIPPED/ERROR do the same. - core/result_builder.lua composes the final neotest.Result via a new to_neotest_result(data, output_writer) helper, calling output_writer.write(data.output_lines) — it already owned tempname_fn in its deps, so it's the natural place to own this conversion. - core/junit_result_reader.lua no longer threads tempname_fn through to JunitResult:new (JunitResult has no use for it anymore); dropped the now-unused nio require. - Added tests/unit/test_output_writer_spec.lua (new module, fully I/O-free via injected open_file/run) and tests/unit/test_junit_result_spec.lua (JunitResult had no dedicated spec file before this refactor — now covers id/status/errors/output/result/merge_results/SKIPPED/ERROR directly, asserting no 'output' filepath leaks out of the pure data shape). - Updated existing reader/result-builder tests that referenced the removed tempname threading (JunitResult no longer stores .tempname). 203 test cases, 0 fails. luacheck: 0 warnings across 83 files. --- lua/neotest-java/core/junit_result_reader.lua | 20 +-- lua/neotest-java/core/result_builder.lua | 25 ++- lua/neotest-java/model/junit_result.lua | 128 +++++--------- lua/neotest-java/util/output_writer.lua | 69 ++++++++ .../test_junit_result_reader_social_spec.lua | 7 +- tests/unit/test_junit_result_reader_spec.lua | 42 +---- tests/unit/test_junit_result_spec.lua | 158 ++++++++++++++++++ tests/unit/test_output_writer_spec.lua | 103 ++++++++++++ tests/unit/test_result_builder_spec.lua | 4 +- 9 files changed, 408 insertions(+), 148 deletions(-) create mode 100644 lua/neotest-java/util/output_writer.lua create mode 100644 tests/unit/test_junit_result_spec.lua create mode 100644 tests/unit/test_output_writer_spec.lua 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_result.lua b/lua/neotest-java/model/junit_result.lua index d3d7401f..d9587d11 100644 --- a/lua/neotest-java/model/junit_result.lua +++ b/lua/neotest-java/model/junit_result.lua @@ -1,4 +1,3 @@ -local nio = require("nio") local junit_failure = require("neotest-java.model.junit_failure") local junit_status = require("neotest-java.model.junit_status") @@ -6,72 +5,41 @@ 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 = {} +--- @class neotest-java.JunitResultData +--- @field status neotest.ResultStatus +--- @field output_lines 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() @@ -167,53 +135,41 @@ function JunitResult:output() return output_lines end ---- Builds the final `neotest.Result` table shared by `result()` and ---- `merge_results()`, writing `output_lines` to a temporary file and ---- omitting `short`/`errors` when the status is PASSED. ----@param status neotest.ResultStatus ----@param output_lines string[] ----@param errors neotest.Error[] | nil ----@param short string | nil ----@param tempname? fun(): string ----@return neotest.Result -local function build_neotest_result(status, output_lines, errors, short, tempname) +---@return string | nil +local function short_failure_message(status, failures) if status == PASSED then - return { status = status, output = create_file_with_content(output_lines, tempname) } + return nil end - return { - status = status, - short = short, - errors = errors, - output = create_file_with_content(output_lines, tempname), - } + 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 ---- 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 +--- 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() - local failure_message - if status ~= PASSED then - failure_message = "" - 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 - end - end - - return build_neotest_result(status, self:output(), self:errors(), failure_message, self.tempname) + return { + status = status, + output_lines = self:output(), + errors = self:errors(), + 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) @@ -222,7 +178,7 @@ 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) @@ -267,7 +223,7 @@ function JunitResult.merge_results(results, tempname) end) end - return build_neotest_result(status, output, errors, short, tempname) + return { status = status, output_lines = output_lines, errors = errors, short = short } end return JunitResult diff --git a/lua/neotest-java/util/output_writer.lua b/lua/neotest-java/util/output_writer.lua new file mode 100644 index 00000000..8c7b971b --- /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 | string[] | table): 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 | string[] | table + --- @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_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..b133962a --- /dev/null +++ b/tests/unit/test_junit_result_spec.lua @@ -0,0 +1,158 @@ +---@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("delegates to junit_status.derive", function() + local status = (JunitResult:new(passing("a()")):status()) + eq(ResultStatus.passed, status) + end) + end) + + describe("errors", function() + it("returns nil when passed", function() + local jres = JunitResult:new(passing("a()")) + eq(nil, jres:errors()) + end) + + it("returns the failure message and line for a failed testcase", function() + local jres = JunitResult:new(failing("a()", nil, "expected true", "stack")) + local errors = jres:errors() + eq(1, #errors) + eq("expected true", errors[1].message) + end) + + it("prefixes the failure message with the test name when requested", function() + local jres = JunitResult:new(failing("a()", nil, "expected true", "stack")) + local errors = jres:errors(true) + eq("a() -> expected true", errors[1].message) + end) + end) + + describe("output", function() + it("returns a single 'Test passed' line when passed", function() + local jres = JunitResult:new(passing("a()")) + eq({ "Test passed\n" }, jres:output()) + end) + + it("returns the failure output when failed", function() + local jres = JunitResult:new(failing("a()", nil, "expected true", "stack trace here")) + local output = jres:output() + assert( + vim.iter(output):any(function(l) + return l == "stack trace here" + end), + "should include the failure output" + ) + end) + end) + + describe("result — plain data, no I/O", function() + it("returns status/output_lines/errors/short without touching disk", function() + local jres = JunitResult:new(failing("a()", nil, "expected true", "stack")) + + local data = jres:result() + + eq(ResultStatus.failed, data.status) + eq("table", type(data.output_lines)) + eq(1, #data.errors) + eq("expected true", data.short) + -- crucially: no `output` filepath field — that's OutputWriter's job + eq(nil, data.output) + end) + + it("omits short/errors when passed", function() + local jres = JunitResult:new(passing("a()")) + + local data = jres:result() + + eq(ResultStatus.passed, data.status) + eq(nil, data.short) + eq(nil, data.errors) + end) + end) + + describe("SKIPPED / ERROR static results", function() + it("SKIPPED returns skipped status and explanatory output_lines", function() + local data = JunitResult.SKIPPED("com.example.Foo#a()") + eq(ResultStatus.skipped, data.status) + eq({ "com.example.Foo#a()", "This test was not executed." }, data.output_lines) + end) + + it("ERROR returns failed status with the given output when provided", function() + local data = JunitResult.ERROR("com.example.Foo#a()", "boom output") + eq("failed", data.status) + eq("boom output", data.output_lines) + end) + + it("ERROR falls back to explanatory output_lines when no output given", function() + local data = JunitResult.ERROR("com.example.Foo#a()") + eq("failed", data.status) + eq({ "com.example.Foo#a()", "This test execution had an unexpected error." }, data.output_lines) + end) + end) + + describe("merge_results — plain data, no I/O", function() + it("merges multiple parameterized-test iterations into one passed result", function() + local results = { + JunitResult:new(passing("a(int)[2]")), + JunitResult:new(passing("a(int)[1]")), + } + + local data = JunitResult.merge_results(results) + + eq(ResultStatus.passed, data.status) + eq(nil, data.output) + end) + + it("marks the merged result failed if any iteration failed", function() + local results = { + JunitResult:new(passing("a(int)[1]")), + JunitResult:new(failing("a(int)[2]", nil, "expected true", "stack")), + } + + local data = JunitResult.merge_results(results) + + eq(ResultStatus.failed, data.status) + eq(1, #data.errors) + 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..bb7eddf4 --- /dev/null +++ b/tests/unit/test_output_writer_spec.lua @@ -0,0 +1,103 @@ +---@diagnostic disable: undefined-field +local OutputWriter = require("neotest-java.util.output_writer") +local eq = require("tests.assertions").eq + +--- Build an OutputWriter with fully stubbed I/O: no real tempfile, no real +--- disk write. Records every write via `writes` (keyed by filepath). +--- @param writes table +--- @param tempname_fn? fun(): string +local function make_writer(writes, tempname_fn) + local counter = 0 + tempname_fn = tempname_fn or function() + counter = counter + 1 + return "/fake/tmp-" .. counter + end + + local fake_file = {} + local current_path + fake_file.write = function(_, data) + writes[current_path] = (writes[current_path] or "") .. data + end + fake_file.close = function() end + + return OutputWriter({ + tempname_fn = tempname_fn, + open_file = function(filepath, _mode) + current_path = filepath + return fake_file + end, + run = function(fn) + fn() + end, + }) +end + +describe("OutputWriter", function() + it("returns nil and writes nothing when data is nil", function() + local writes = {} + local writer = make_writer(writes) + + local filepath = writer.write(nil) + + eq(nil, filepath) + eq({}, writes) + end) + + it("returns nil and writes nothing when data is an empty table", function() + local writes = {} + local writer = make_writer(writes) + + local filepath = writer.write({}) + + eq(nil, filepath) + eq({}, writes) + end) + + it("writes a plain string to a new tempfile and returns its path", function() + local writes = {} + local writer = make_writer(writes, function() + return "/fake/tmp-1" + end) + + local filepath = writer.write("hello world") + + eq("/fake/tmp-1", filepath) + eq("hello world", writes["/fake/tmp-1"]) + end) + + it("joins an array of lines with the visual separator", function() + local writes = {} + local writer = make_writer(writes, function() + return "/fake/tmp-1" + end) + + writer.write({ "line one", "line two" }) + + assert(writes["/fake/tmp-1"]:find("line one", 1, true) ~= nil, "should contain first line") + assert(writes["/fake/tmp-1"]:find("line two", 1, true) ~= nil, "should contain second line") + end) + + it("flattens nested tables before writing", function() + local writes = {} + local writer = make_writer(writes, function() + return "/fake/tmp-1" + end) + + writer.write({ { "nested one" }, "top level" }) + + assert(writes["/fake/tmp-1"]:find("nested one", 1, true) ~= nil, "should contain nested line") + assert(writes["/fake/tmp-1"]:find("top level", 1, true) ~= nil, "should contain top-level line") + end) + + it("uses a new tempname for every write call", function() + local writes = {} + local writer = make_writer(writes) + + local first = writer.write("a") + local second = writer.write("b") + + assert(first ~= second, "each write should get its own tempfile") + eq("a", writes[first]) + eq("b", writes[second]) + 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). From 12545c3d4414b7e92a6f5e43320b593991f02e4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Cas=C3=ADa?= <31012661+rcasia@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:40:06 +0200 Subject: [PATCH 7/8] fix: relax type annotations flagged by lua-language-server - OutputWriter.write's data param: string | string[] | table -> string | table | nil, matching how it's actually called (nil is a valid no-op input, and output_lines mixes strings and nested tables, which the strict string[] annotation didn't represent). - JunitResultData.output_lines: string[] -> string | string[], since JunitResult.ERROR can set it directly to a raw string (process output). - Added nil-narrowing asserts in test_junit_result_spec.lua before indexing :errors() (typed as possibly nil). --- lua/neotest-java/model/junit_result.lua | 2 +- lua/neotest-java/util/output_writer.lua | 4 ++-- tests/unit/test_junit_result_spec.lua | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lua/neotest-java/model/junit_result.lua b/lua/neotest-java/model/junit_result.lua index d9587d11..86280526 100644 --- a/lua/neotest-java/model/junit_result.lua +++ b/lua/neotest-java/model/junit_result.lua @@ -13,7 +13,7 @@ local JunitResult = {} --- @class neotest-java.JunitResultData --- @field status neotest.ResultStatus ---- @field output_lines string[] +--- @field output_lines string | string[] --- @field errors neotest.Error[] | nil --- @field short string | nil diff --git a/lua/neotest-java/util/output_writer.lua b/lua/neotest-java/util/output_writer.lua index 8c7b971b..5bcc15b8 100644 --- a/lua/neotest-java/util/output_writer.lua +++ b/lua/neotest-java/util/output_writer.lua @@ -24,7 +24,7 @@ local LINE_SEPARATOR = "=================================\n" --- @field run? fun(fn: function) --- @class neotest-java.OutputWriter ---- @field write fun(data: string | string[] | table): string | nil +--- @field write fun(data: string | table | nil): string | nil --- @param deps neotest-java.OutputWriterDeps | nil --- @return neotest-java.OutputWriter @@ -39,7 +39,7 @@ local OutputWriter = function(deps) --- 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 | string[] | table + --- @param data string | table | nil --- @return string | nil filepath write = function(data) if not data then diff --git a/tests/unit/test_junit_result_spec.lua b/tests/unit/test_junit_result_spec.lua index b133962a..1383652e 100644 --- a/tests/unit/test_junit_result_spec.lua +++ b/tests/unit/test_junit_result_spec.lua @@ -56,6 +56,7 @@ describe("JunitResult", function() it("returns the failure message and line for a failed testcase", function() local jres = JunitResult:new(failing("a()", nil, "expected true", "stack")) local errors = jres:errors() + assert(errors, "errors should not be nil for a failed testcase") eq(1, #errors) eq("expected true", errors[1].message) end) @@ -63,6 +64,7 @@ describe("JunitResult", function() it("prefixes the failure message with the test name when requested", 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) From 6faab4c78eadde066fb5c46008cff498c987cb67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Cas=C3=ADa?= <31012661+rcasia@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:55:09 +0200 Subject: [PATCH 8/8] test: make new output_writer/junit_result specs actually verify their claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per feedback that the new tests weren't helpful/understandable, rewrote both test_output_writer_spec.lua and test_junit_result_spec.lua: - test_output_writer_spec.lua: replaced the ad-hoc filepath->content map (which required tracing through a mutable closure to understand) with a plain ordered list of {filepath, data} entries returned directly from make_writer(). 'joins lines with a separator' and 'flattens nested tables' previously only checked that both substrings existed independently, which would pass even without a separator or in the wrong order — now they assert the exact expected concatenated string. - test_junit_result_spec.lua: replaced weak assertions (type(x) == "table", eq(nil, data.output) checking a field that never existed in the first place) with exact whole-table checks against the full expected shape, so each test documents the complete contract instead of a handful of disconnected field checks. Removed the misleading 'delegates to junit_status.derive'/'without touching disk' framings that didn't actually verify what their names claimed; replaced with direct behavioral assertions (PASSED/FAILED + failures content, exact merged output ordering, exact error messages). lua-language-server, stylua, luacheck all clean. Full suite: 204 cases, 0 fails. --- tests/unit/test_junit_result_spec.lua | 127 +++++++++++++------------ tests/unit/test_output_writer_spec.lua | 100 +++++++++---------- 2 files changed, 118 insertions(+), 109 deletions(-) diff --git a/tests/unit/test_junit_result_spec.lua b/tests/unit/test_junit_result_spec.lua index 1383652e..32fd53ce 100644 --- a/tests/unit/test_junit_result_spec.lua +++ b/tests/unit/test_junit_result_spec.lua @@ -41,27 +41,36 @@ describe("JunitResult", function() end) describe("status", function() - it("delegates to junit_status.derive", function() - local status = (JunitResult:new(passing("a()")):status()) + 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", function() + it("returns nil when passed (no errors to report)", function() local jres = JunitResult:new(passing("a()")) eq(nil, jres:errors()) end) - it("returns the failure message and line for a failed testcase", function() - local jres = JunitResult:new(failing("a()", nil, "expected true", "stack")) + 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() - assert(errors, "errors should not be nil for a failed testcase") - eq(1, #errors) - eq("expected true", errors[1].message) + + eq({ { message = "expected true", line = 41 } }, errors) end) - it("prefixes the failure message with the test name when requested", function() + 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") @@ -70,91 +79,87 @@ describe("JunitResult", function() end) describe("output", function() - it("returns a single 'Test passed' line when passed", 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("returns the failure output when failed", function() + it("includes the raw failure output (stack trace) when failed", function() local jres = JunitResult:new(failing("a()", nil, "expected true", "stack trace here")) - local output = jres:output() - assert( - vim.iter(output):any(function(l) - return l == "stack trace here" - end), - "should include the failure output" - ) + eq({ "stack trace here", "\n" }, jres:output()) end) end) - describe("result — plain data, no I/O", function() - it("returns status/output_lines/errors/short without touching disk", function() + 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")) - local data = jres:result() - - eq(ResultStatus.failed, data.status) - eq("table", type(data.output_lines)) - eq(1, #data.errors) - eq("expected true", data.short) - -- crucially: no `output` filepath field — that's OutputWriter's job - eq(nil, data.output) + eq({ + status = ResultStatus.failed, + output_lines = jres:output(), + errors = jres:errors(), + short = "expected true", + }, jres:result()) end) - it("omits short/errors when passed", function() + it("omits short/errors when passed, but still includes output_lines", function() local jres = JunitResult:new(passing("a()")) - local data = jres:result() - - eq(ResultStatus.passed, data.status) - eq(nil, data.short) - eq(nil, data.errors) + eq({ + status = ResultStatus.passed, + output_lines = jres:output(), + }, jres:result()) end) end) - describe("SKIPPED / ERROR static results", function() - it("SKIPPED returns skipped status and explanatory output_lines", function() - local data = JunitResult.SKIPPED("com.example.Foo#a()") - eq(ResultStatus.skipped, data.status) - eq({ "com.example.Foo#a()", "This test was not executed." }, data.output_lines) + 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 returns failed status with the given output when provided", function() - local data = JunitResult.ERROR("com.example.Foo#a()", "boom output") - eq("failed", data.status) - eq("boom output", data.output_lines) + 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 explanatory output_lines when no output given", function() - local data = JunitResult.ERROR("com.example.Foo#a()") - eq("failed", data.status) - eq({ "com.example.Foo#a()", "This test execution had an unexpected error." }, data.output_lines) + 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 — plain data, no I/O", function() - it("merges multiple parameterized-test iterations into one passed result", function() - local results = { - JunitResult:new(passing("a(int)[2]")), - JunitResult:new(passing("a(int)[1]")), - } + 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(results) + local data = JunitResult.merge_results({ a, b }) eq(ResultStatus.passed, data.status) - eq(nil, data.output) + 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("marks the merged result failed if any iteration failed", function() - local results = { - JunitResult:new(passing("a(int)[1]")), - JunitResult:new(failing("a(int)[2]", nil, "expected true", "stack")), - } + 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(results) + 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_output_writer_spec.lua b/tests/unit/test_output_writer_spec.lua index bb7eddf4..b099c749 100644 --- a/tests/unit/test_output_writer_spec.lua +++ b/tests/unit/test_output_writer_spec.lua @@ -2,102 +2,106 @@ local OutputWriter = require("neotest-java.util.output_writer") local eq = require("tests.assertions").eq ---- Build an OutputWriter with fully stubbed I/O: no real tempfile, no real ---- disk write. Records every write via `writes` (keyed by filepath). ---- @param writes table ---- @param tempname_fn? fun(): string -local function make_writer(writes, tempname_fn) +-- 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 - tempname_fn = tempname_fn or function() + local tempname_fn = opts.tempname_fn or function() counter = counter + 1 return "/fake/tmp-" .. counter end - local fake_file = {} - local current_path - fake_file.write = function(_, data) - writes[current_path] = (writes[current_path] or "") .. data - end - fake_file.close = function() end - - return OutputWriter({ + local writer = OutputWriter({ tempname_fn = tempname_fn, - open_file = function(filepath, _mode) - current_path = filepath - return fake_file + 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 writes = {} - local writer = make_writer(writes) + local writer, written = make_writer() local filepath = writer.write(nil) eq(nil, filepath) - eq({}, writes) + eq({}, written) end) it("returns nil and writes nothing when data is an empty table", function() - local writes = {} - local writer = make_writer(writes) + local writer, written = make_writer() local filepath = writer.write({}) eq(nil, filepath) - eq({}, writes) + eq({}, written) end) it("writes a plain string to a new tempfile and returns its path", function() - local writes = {} - local writer = make_writer(writes, function() - return "/fake/tmp-1" - end) + 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("hello world", writes["/fake/tmp-1"]) + eq({ { filepath = "/fake/tmp-1", data = "hello world" } }, written) end) - it("joins an array of lines with the visual separator", function() - local writes = {} - local writer = make_writer(writes, function() - return "/fake/tmp-1" - 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" }) - assert(writes["/fake/tmp-1"]:find("line one", 1, true) ~= nil, "should contain first line") - assert(writes["/fake/tmp-1"]:find("line two", 1, true) ~= nil, "should contain second line") + eq("line one" .. LINE_SEPARATOR .. "line two", written[1].data) end) - it("flattens nested tables before writing", function() - local writes = {} - local writer = make_writer(writes, function() - return "/fake/tmp-1" - end) + it("flattens nested tables before writing, preserving order", function() + local writer, written = make_writer() writer.write({ { "nested one" }, "top level" }) - assert(writes["/fake/tmp-1"]:find("nested one", 1, true) ~= nil, "should contain nested line") - assert(writes["/fake/tmp-1"]:find("top level", 1, true) ~= nil, "should contain top-level line") + eq("nested one" .. LINE_SEPARATOR .. "top level", written[1].data) end) - it("uses a new tempname for every write call", function() - local writes = {} - local writer = make_writer(writes) + 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") - eq("a", writes[first]) - eq("b", writes[second]) + 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)