From 2a1bac55b2db811bbbe6bb5e2663ea077e02e0ff Mon Sep 17 00:00:00 2001 From: "M.Schirmer" Date: Thu, 8 Jan 2026 08:56:13 +0100 Subject: [PATCH] fix listing for errors that occurs within beforeEach --- README.md | 12 ++- lua/test-samurai-mocha-runner/init.lua | 117 ++++++++++++++++++++++++- scripts/mocha-ndjson-reporter.cjs | 42 +++++++++ tests/test_mocha_runner_spec.lua | 105 +++++++++++++++++++++- 4 files changed, 270 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3988983..a8a7506 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Mocha.js runner for the test-samurai Neovim plugin. - Supports nearest, file, all, and failed-only commands. - Streams results via a custom NDJSON reporter. - Provides quickfix locations and per-test output. +- Captures hook failures (before/after/beforeEach/afterEach) as failed entries with output. - Uses `--grep` with escaped patterns to match titles safely, even when running through `npm test`. - Executes tests via `npx mocha` with the bundled UI + reporter so projects need no extra setup. - `TSamAll` runs `test/**/*.test.js` by default. @@ -35,11 +36,12 @@ which emits one JSON object per line: ``` { - "event": "run-begin" | "run-end" | "suite" | "test", + "event": "run-begin" | "run-end" | "suite" | "test" | "hook", "status": "passed" | "failed" | "pending" | "skipped", "title": "Test or suite title", "fullTitle": "Mocha full title", "titlePath": ["Parent", "Child", "Test"], + "hookType": "beforeEach" | "afterEach" | "beforeAll" | "afterAll", "file": "/path/to/test.js", "location": { "file": "/path/to/test.js", "line": 10, "column": 5 }, "output": ["stdout line 1", "stdout line 2"], @@ -70,6 +72,14 @@ require("test-samurai-mocha-runner").setup({ }) ``` +Enable debug logging (writes raw parser input lines): + +```lua +require("test-samurai-mocha-runner").setup({ + debug_log_path = "/tmp/tsam-mocha.log", +}) +``` + ## Development Run tests: diff --git a/lua/test-samurai-mocha-runner/init.lua b/lua/test-samurai-mocha-runner/init.lua index e2f5b11..6711cbf 100644 --- a/lua/test-samurai-mocha-runner/init.lua +++ b/lua/test-samurai-mocha-runner/init.lua @@ -5,6 +5,7 @@ local runner = { local default_config = { all_test_glob = "test/**/*.test.js", + debug_log_path = nil, } local config = vim.deepcopy(default_config) @@ -18,6 +19,18 @@ function runner.setup(opts) return config end +local function debug_log_line(label, line) + if not config or not config.debug_log_path then + return + end + local ok, handle = pcall(io.open, config.debug_log_path, "a") + if not ok or not handle then + return + end + handle:write(string.format("%s %s\n", label, line or "")) + handle:close() +end + local function runner_root() local source = debug.getinfo(1, "S").source if source:sub(1, 1) == "@" then @@ -357,8 +370,24 @@ local function build_full_name_from_payload(payload) return title, payload.fullTitle or title end +local function build_hook_name_from_payload(payload) + local hook_type = payload.hookType or payload.hook_type or payload.title or "hook" + local hook_label = tostring(hook_type) + local path = normalize_title_path(payload) + if path then + return hook_label .. ": " .. table.concat(path, "/"), hook_label .. " " .. table.concat(path, " ") + end + if payload.fullTitle and payload.fullTitle ~= "" then + return hook_label .. ": " .. payload.fullTitle, hook_label .. " " .. payload.fullTitle + end + if payload.title and payload.title ~= "" then + return hook_label .. ": " .. payload.title, hook_label .. " " .. payload.title + end + return hook_label, hook_label +end + local function normalize_location(location) - if not location then + if not location or type(location) ~= "table" then return nil end local filename = location.file or location.filename @@ -439,9 +468,80 @@ local function record_ndjson_result(state, status, payload) return nil end +local function record_hook_failure(state, payload) + local full_name, mocha_title = build_hook_name_from_payload(payload or {}) + if not full_name then + return nil + end + state.mocha_titles[full_name] = mocha_title + + if not state.seen.failures[full_name] then + state.seen.failures[full_name] = true + table.insert(state.results.failures, full_name) + end + + local location = normalize_location(payload.location) + local output_lines = output_lines_from_payload(payload) + local lines = {} + append_lines(lines, output_lines) + append_lines(lines, build_error_lines(payload.error)) + if #lines > 0 then + state.outputs[full_name] = lines + end + + if not location and payload.error and payload.error.stack then + location = extract_location(payload.error.stack) + end + if location then + location.text = (payload.error and payload.error.message) or "hook failure" + state.locations[full_name] = location + end + + if not state.outputs[full_name] or #state.outputs[full_name] == 0 then + state.outputs[full_name] = { "failed" } + end + + return full_name +end + +local function decode_ndjson_payload(line) + if not line or line == "" then + return nil + end + local trimmed = vim.trim(line) + if trimmed == "" then + return nil + end + local ok, payload = pcall(vim.json.decode, trimmed) + if ok and type(payload) == "table" then + return payload + end + local start = trimmed:find("{", 1, true) + if not start then + return nil + end + local last = nil + for i = #trimmed, 1, -1 do + if trimmed:sub(i, i) == "}" then + last = i + break + end + end + if not last or last <= start then + return nil + end + local candidate = trimmed:sub(start, last) + ok, payload = pcall(vim.json.decode, candidate) + if ok and type(payload) == "table" then + return payload + end + return nil +end + local function parse_ndjson_line(line, state) - local ok, payload = pcall(vim.json.decode, line) - if not ok or type(payload) ~= "table" then + debug_log_line("on_line_raw", line) + local payload = decode_ndjson_payload(line) + if not payload then return nil end if payload.event == "test" then @@ -449,6 +549,11 @@ local function parse_ndjson_line(line, state) if name then return { event = payload.status, name = name } end + elseif payload.event == "hook" and payload.status == "failed" then + local name = record_hook_failure(state, payload) + if name then + return { event = "failed", name = name } + end elseif payload.event == "suite" then if payload.status == "skipped" or payload.status == "pending" then local name = record_ndjson_result(state, payload.status, payload) @@ -678,11 +783,15 @@ function runner.output_parser() return results end, on_complete = function(output, state) + debug_log_line("on_complete_raw", output) state = ensure_state(state or {}) local parsed = parse_ndjson_output(output) runner._last_locations = parsed.locations runner._last_mocha_titles = parsed.mocha_titles - return nil + if #state.results.passes > 0 or #state.results.failures > 0 or #state.results.skips > 0 then + return nil + end + return parsed.results end, } end diff --git a/scripts/mocha-ndjson-reporter.cjs b/scripts/mocha-ndjson-reporter.cjs index 8498952..5c9cc09 100644 --- a/scripts/mocha-ndjson-reporter.cjs +++ b/scripts/mocha-ndjson-reporter.cjs @@ -14,6 +14,7 @@ const { EVENT_TEST_PASS, EVENT_TEST_FAIL, EVENT_TEST_PENDING, + EVENT_HOOK_FAIL, } = Mocha.Runner.constants; function serializeError(err) { @@ -81,6 +82,10 @@ class NdjsonReporter { this.emit(this.testPayload('failed', test, serializeError(err))); }) + .on(EVENT_HOOK_FAIL, (hook, err) => { + this.emit(this.hookPayload('failed', hook, serializeError(err))); + }) + .on(EVENT_TEST_PENDING, (test) => { // Mocha "pending" includes: // - TODO tests (no function) @@ -169,6 +174,43 @@ class NdjsonReporter { return payload; } + hookPayload(status, hook, errorObj) { + const currentTest = hook && hook.ctx && hook.ctx.currentTest ? hook.ctx.currentTest : null; + const hookType = hook && hook.type ? hook.type : 'hook'; + const titlePath = + (currentTest && safeTitlePath(currentTest)) || + (hook && hook.parent && safeTitlePath(hook.parent)) || + safeTitlePath(hook); + const fullTitle = + (currentTest && typeof currentTest.fullTitle === 'function' && currentTest.fullTitle()) || + (hook && hook.title) || + null; + const file = + (hook && hook.file) || + (currentTest && currentTest.file) || + (hook && hook.parent && hook.parent.file) || + null; + const location = + (currentTest && currentTest._akLocation) || + (hook && hook._akLocation) || + null; + + const payload = { + event: 'hook', + status, + hookType, + title: hook && hook.title ? hook.title : null, + fullTitle, + titlePath, + file, + location, + }; + + if (status === 'failed') payload.error = errorObj; + + return payload; + } + emit(obj) { this.originalStdoutWrite(JSON.stringify(obj) + '\n'); } diff --git a/tests/test_mocha_runner_spec.lua b/tests/test_mocha_runner_spec.lua index 1711bb6..6c473ba 100644 --- a/tests/test_mocha_runner_spec.lua +++ b/tests/test_mocha_runner_spec.lua @@ -267,6 +267,92 @@ describe("test-samurai-mocha-runner", function() assert.equals(2, skip_items[1].col) end) + it("captures hook failures in results and output", function() + local output = vim.json.encode({ + event = "hook", + status = "failed", + hookType = "beforeEach", + titlePath = { "Math", "adds" }, + fullTitle = "Math adds", + file = "/tmp/math.spec.js", + error = { + message = "boom", + stack = "Error: boom\n at Context. (/tmp/math.spec.js:5:3)", + }, + }) + + local results = runner.parse_results(output) + assert.are.same({ "beforeEach: Math/adds" }, results.failures) + + local outputs = runner.parse_test_output(output) + assert.is_table(outputs["beforeEach: Math/adds"]) + assert.is_true(table.concat(outputs["beforeEach: Math/adds"], "\n"):match("boom") ~= nil) + + local items = runner.collect_failed_locations(results.failures) + assert.are.same({ + { filename = "/tmp/math.spec.js", lnum = 5, col = 3, text = "boom" }, + }, items) + end) + + it("parses mocha hook failures emitted as test events", function() + local output = vim.json.encode({ + event = "test", + status = "failed", + title = "\"before each\" hook for \"findAllInquiries()\"", + fullTitle = "BlacklistRepository... \"before each\" hook for \"findAllInquiries()\"", + titlePath = { "BlacklistRepository...", "\"before each\" hook for \"findAllInquiries()\"" }, + file = "/tmp/BlacklistRepository.test.js", + location = nil, + error = { + message = "missing dbName", + stack = "Error: missing dbName\n at Context. (/tmp/BlacklistRepository.test.js:10:25)", + }, + }) + + local results = runner.parse_results(output) + assert.are.same({ + "BlacklistRepository.../\"before each\" hook for \"findAllInquiries()\"", + }, results.failures) + + local outputs = runner.parse_test_output(output) + assert.is_true(table.concat(outputs[results.failures[1]], "\n"):match("missing dbName") ~= nil) + + local items = runner.collect_failed_locations(results.failures) + assert.are.same({ + { filename = "/tmp/BlacklistRepository.test.js", lnum = 10, col = 25, text = "missing dbName" }, + }, items) + end) + + it("handles null location fields in ndjson output", function() + local output = [[{"event":"test","status":"failed","title":"\"before each\" hook for \"findAllInquiries()\"","fullTitle":"BlacklistRepository... \"before each\" hook for \"findAllInquiries()\"","titlePath":["BlacklistRepository...","\"before each\" hook for \"findAllInquiries()\""],"file":"/tmp/BlacklistRepository.test.js","location":null,"error":{"name":"Error","message":"missing dbName","stack":"Error: missing dbName\n at Context. (/tmp/BlacklistRepository.test.js:10:25)"}}]] + local results = runner.parse_results(output) + assert.are.same({ + "BlacklistRepository.../\"before each\" hook for \"findAllInquiries()\"", + }, results.failures) + end) + + it("writes debug logs when configured", function() + local log_path = vim.fn.tempname() + runner.setup({ debug_log_path = log_path }) + local parser = runner.output_parser() + local state = {} + local line = vim.json.encode({ + event = "test", + status = "passed", + title = "adds", + fullTitle = "Math adds", + titlePath = { "Math", "adds" }, + file = "/tmp/math.spec.js", + location = { file = "/tmp/math.spec.js", line = 3, column = 2 }, + }) + parser.on_line(line, state) + parser.on_complete(line, state) + local lines = vim.fn.readfile(log_path) + assert.is_true(#lines >= 2) + assert.is_true(lines[1]:match("on_line_raw") ~= nil) + assert.is_true(lines[2]:match("on_complete_raw") ~= nil) + end) + it("builds failed-only command with grep pattern", function() runner._last_mocha_titles = { ["Math/adds"] = "Math adds", @@ -334,7 +420,7 @@ describe("test-samurai-mocha-runner", function() assert.equals("skipped", outputs["Math/skips"][1]) end) - it("does not return results on complete", function() + it("returns results on complete when no streaming state exists", function() local parser = runner.output_parser() local state = {} local output = vim.json.encode({ @@ -347,6 +433,23 @@ describe("test-samurai-mocha-runner", function() location = { file = "/tmp/math.spec.js", line = 3, column = 2 }, }) local results = parser.on_complete(output, state) + assert.are.same({ "Math/adds" }, results.passes) + end) + + it("does not return results on complete when streaming already handled", function() + local parser = runner.output_parser() + local state = {} + local line = vim.json.encode({ + event = "test", + status = "passed", + title = "adds", + fullTitle = "Math adds", + titlePath = { "Math", "adds" }, + file = "/tmp/math.spec.js", + location = { file = "/tmp/math.spec.js", line = 3, column = 2 }, + }) + parser.on_line(line, state) + local results = parser.on_complete(line, state) assert.is_nil(results) end) end)