From 60960322ac87b573449764868417bf24e2c88288 Mon Sep 17 00:00:00 2001 From: "M.Schirmer" Date: Sat, 27 Dec 2025 22:33:46 +0100 Subject: [PATCH] extend simple test listing to a list-detail-view window --- lua/test-samurai/core.lua | 551 +++++++++++++++++++++++++++-- lua/test-samurai/runners/go.lua | 30 ++ lua/test-samurai/runners/js.lua | 120 +++++++ tests/test_samurai_output_spec.lua | 427 ++++++++++++++++++++++ 4 files changed, 1108 insertions(+), 20 deletions(-) diff --git a/lua/test-samurai/core.lua b/lua/test-samurai/core.lua index 000a2a4..efac369 100644 --- a/lua/test-samurai/core.lua +++ b/lua/test-samurai/core.lua @@ -15,11 +15,22 @@ local state = { last_scope_exit_code = nil, last_scope_failures = nil, last_border_kind = "default", + last_test_outputs = {}, + last_result_line_map = {}, + last_raw_output = nil, + last_float = nil, + detail_buf = nil, + detail_win = nil, + detail_opening = false, autocmds_set = false, } local summary_ns = vim.api.nvim_create_namespace("TestSamuraiSummary") local result_ns = vim.api.nvim_create_namespace("TestSamuraiResult") +local detail_ns = vim.api.nvim_create_namespace("TestSamuraiDetailAnsi") +local apply_border_kind +local close_container +local restore_listing_full local function get_hl_fg(name) local ok, hl = pcall(vim.api.nvim_get_hl, 0, { name = name, link = true }) @@ -79,12 +90,33 @@ local function ensure_output_autocmds() vim.api.nvim_create_autocmd({ "BufEnter", "WinEnter" }, { group = group, callback = function() - if state.last_win and vim.api.nvim_win_is_valid(state.last_win) then - local cur = vim.api.nvim_get_current_win() - if cur ~= state.last_win then - pcall(vim.api.nvim_win_close, state.last_win, true) - state.last_win = nil - end + if state.detail_opening then + return + end + local cur = vim.api.nvim_get_current_win() + local keep = (state.last_win and vim.api.nvim_win_is_valid(state.last_win) and cur == state.last_win) + or (state.detail_win and vim.api.nvim_win_is_valid(state.detail_win) and cur == state.detail_win) + if not keep then + close_container() + end + end, + }) + + vim.api.nvim_create_autocmd("WinClosed", { + group = group, + callback = function(args) + local closed = tonumber(args.match) + if not closed then + return + end + if state.last_win and closed == state.last_win then + state.last_win = nil + close_container() + return + end + if state.detail_win and closed == state.detail_win then + state.detail_win = nil + restore_listing_full() end end, }) @@ -111,6 +143,12 @@ function M.setup() state.last_scope_exit_code = nil state.last_scope_failures = nil state.last_border_kind = "default" + state.last_test_outputs = {} + state.last_result_line_map = {} + state.last_raw_output = nil + state.last_float = nil + state.detail_opening = false + state.detail_opening = false end function M.reload_runners() @@ -223,7 +261,7 @@ local function float_geometry() return width, height, row, col end -local function apply_border_kind(win, kind) +apply_border_kind = function(win, kind) if not (win and vim.api.nvim_win_is_valid(win)) then return end @@ -236,11 +274,46 @@ local function apply_border_kind(win, kind) end end +close_container = function() + if state.last_win and vim.api.nvim_win_is_valid(state.last_win) then + pcall(vim.api.nvim_win_close, state.last_win, true) + state.last_win = nil + end + if state.detail_win and vim.api.nvim_win_is_valid(state.detail_win) then + pcall(vim.api.nvim_win_close, state.detail_win, true) + state.detail_win = nil + end +end + +restore_listing_full = function() + if not (state.last_win and vim.api.nvim_win_is_valid(state.last_win)) then + return + end + local width = state.last_float and state.last_float.width or math.floor(vim.o.columns * 0.8) + local height = state.last_float and state.last_float.height or math.floor(vim.o.lines * 0.8) + local row = state.last_float and state.last_float.row or math.floor((vim.o.lines - height) / 2) + local col = state.last_float and state.last_float.col or math.floor((vim.o.columns - width) / 2) + vim.api.nvim_win_set_config(state.last_win, { + relative = "editor", + width = width, + height = height, + row = row, + col = col, + style = "minimal", + border = "rounded", + }) + apply_border_kind(state.last_win, state.last_border_kind) +end + local function create_output_win(initial_lines) if state.last_win and vim.api.nvim_win_is_valid(state.last_win) then pcall(vim.api.nvim_win_close, state.last_win, true) state.last_win = nil end + if state.detail_win and vim.api.nvim_win_is_valid(state.detail_win) then + pcall(vim.api.nvim_win_close, state.detail_win, true) + state.detail_win = nil + end local buf = state.last_buf if not (buf and vim.api.nvim_buf_is_valid(buf)) then @@ -253,6 +326,7 @@ local function create_output_win(initial_lines) vim.api.nvim_buf_set_lines(buf, 0, -1, false, initial_lines or {}) local width, height, row, col = float_geometry() + state.last_float = { width = width, height = height, row = row, col = col } local win = vim.api.nvim_open_win(buf, true, { relative = "editor", @@ -265,12 +339,10 @@ local function create_output_win(initial_lines) }) vim.keymap.set("n", "", function() - if vim.api.nvim_win_is_valid(win) then - vim.api.nvim_win_close(win, true) - if state.last_win == win then - state.last_win = nil - end - end + close_container() + end, { buffer = buf, nowait = true, silent = true }) + vim.keymap.set("n", "", function() + M.open_test_output_at_cursor() end, { buffer = buf, nowait = true, silent = true }) state.last_win = win @@ -289,8 +361,13 @@ local function reopen_output_win() vim.api.nvim_set_current_win(state.last_win) return state.last_buf, state.last_win end + if state.detail_win and vim.api.nvim_win_is_valid(state.detail_win) then + pcall(vim.api.nvim_win_close, state.detail_win, true) + state.detail_win = nil + end local width, height, row, col = float_geometry() + state.last_float = { width = width, height = height, row = row, col = col } local win = vim.api.nvim_open_win(state.last_buf, true, { relative = "editor", @@ -303,12 +380,10 @@ local function reopen_output_win() }) vim.keymap.set("n", "", function() - if vim.api.nvim_win_is_valid(win) then - vim.api.nvim_win_close(win, true) - if state.last_win == win then - state.last_win = nil - end - end + close_container() + end, { buffer = state.last_buf, nowait = true, silent = true }) + vim.keymap.set("n", "", function() + M.open_test_output_at_cursor() end, { buffer = state.last_buf, nowait = true, silent = true }) state.last_win = win @@ -333,6 +408,398 @@ local function append_lines(buf, new_lines) end end +local function normalize_output_lines(lines) + if type(lines) ~= "table" then + return {} + end + local out = {} + for _, line in ipairs(lines) do + if line ~= nil then + table.insert(out, line) + end + end + return out +end + +local function ansi_color_to_rgb(idx) + local basic = { + [0] = 0x000000, + [1] = 0x800000, + [2] = 0x008000, + [3] = 0x808000, + [4] = 0x000080, + [5] = 0x800080, + [6] = 0x008080, + [7] = 0xC0C0C0, + [8] = 0x808080, + [9] = 0xFF0000, + [10] = 0x00FF00, + [11] = 0xFFFF00, + [12] = 0x0000FF, + [13] = 0xFF00FF, + [14] = 0x00FFFF, + [15] = 0xFFFFFF, + } + if basic[idx] then + return basic[idx] + end + if idx >= 16 and idx <= 231 then + local v = idx - 16 + local r = math.floor(v / 36) + local g = math.floor((v % 36) / 6) + local b = v % 6 + local function comp(n) + if n == 0 then + return 0 + end + return 55 + (n * 40) + end + return comp(r) * 0x10000 + comp(g) * 0x100 + comp(b) + end + if idx >= 232 and idx <= 255 then + local shade = 8 + (idx - 232) * 10 + return shade * 0x10000 + shade * 0x100 + shade + end + return nil +end + +local function build_ansi_hl(style, cache) + local fg = style.fg + local bg = style.bg + if style.reverse then + fg, bg = bg, fg + end + local key = table.concat({ + tostring(fg or ""), + tostring(bg or ""), + style.bold and "b" or "", + style.italic and "i" or "", + style.underline and "u" or "", + }, "|") + if key == "||||" then + return nil + end + if cache[key] then + return cache[key] + end + local name = "TestSamuraiAnsi_" .. tostring(#cache + 1) + cache[key] = name + local opts = {} + if fg then + opts.fg = fg + end + if bg then + opts.bg = bg + end + if style.bold then + opts.bold = true + end + if style.italic then + opts.italic = true + end + if style.underline then + opts.underline = true + end + pcall(vim.api.nvim_set_hl, 0, name, opts) + return name +end + +local function parse_ansi_lines(lines) + local clean_lines = {} + local highlights = {} + local style = {} + local cache = {} + + local function apply_sgr(params) + if #params == 0 then + params = { 0 } + end + local i = 1 + while i <= #params do + local p = params[i] + if p == 0 then + style = {} + elseif p == 1 then + style.bold = true + elseif p == 2 then + style.dim = true + elseif p == 3 then + style.italic = true + elseif p == 4 then + style.underline = true + elseif p == 7 then + style.reverse = true + elseif p == 22 then + style.bold = nil + style.dim = nil + elseif p == 23 then + style.italic = nil + elseif p == 24 then + style.underline = nil + elseif p == 27 then + style.reverse = nil + elseif p == 39 then + style.fg = nil + elseif p == 49 then + style.bg = nil + elseif p >= 30 and p <= 37 then + style.fg = ansi_color_to_rgb(p - 30) + elseif p >= 90 and p <= 97 then + style.fg = ansi_color_to_rgb(p - 90 + 8) + elseif p >= 40 and p <= 47 then + style.bg = ansi_color_to_rgb(p - 40) + elseif p >= 100 and p <= 107 then + style.bg = ansi_color_to_rgb(p - 100 + 8) + elseif p == 38 or p == 48 then + local is_fg = (p == 38) + local mode = params[i + 1] + if mode == 5 then + local idx = params[i + 2] + if idx then + local rgb = ansi_color_to_rgb(idx) + if is_fg then + style.fg = rgb + else + style.bg = rgb + end + end + i = i + 2 + elseif mode == 2 then + local r = params[i + 2] + local g = params[i + 3] + local b = params[i + 4] + if r and g and b then + local rgb = r * 0x10000 + g * 0x100 + b + if is_fg then + style.fg = rgb + else + style.bg = rgb + end + end + i = i + 4 + end + end + i = i + 1 + end + end + + for lnum, line in ipairs(lines or {}) do + local clean = {} + local hls = {} + local pos = 1 + local col = 0 + while true do + local s, e = line:find("\27%[[0-9;]*m", pos) + if not s then + local chunk = line:sub(pos) + if chunk ~= "" then + table.insert(clean, chunk) + local hl = build_ansi_hl(style, cache) + if hl then + table.insert(hls, { col, col + #chunk, hl }) + end + col = col + #chunk + end + break + end + local chunk = line:sub(pos, s - 1) + if chunk ~= "" then + table.insert(clean, chunk) + local hl = build_ansi_hl(style, cache) + if hl then + table.insert(hls, { col, col + #chunk, hl }) + end + col = col + #chunk + end + local params = {} + local seq = line:sub(s + 2, e - 1) + for part in seq:gmatch("[^;]+") do + local num = tonumber(part) + if num then + table.insert(params, num) + end + end + apply_sgr(params) + pos = e + 1 + end + clean_lines[lnum] = table.concat(clean) + highlights[lnum] = hls + end + + return clean_lines, highlights +end + +local function apply_detail_highlights(buf, highlights) + if not (buf and vim.api.nvim_buf_is_valid(buf)) then + return + end + vim.api.nvim_buf_clear_namespace(buf, detail_ns, 0, -1) + for lnum, entries in ipairs(highlights or {}) do + for _, entry in ipairs(entries or {}) do + local start_col = entry[1] + local end_col = entry[2] + local hl = entry[3] + if hl then + vim.api.nvim_buf_add_highlight(buf, detail_ns, hl, lnum - 1, start_col, end_col) + end + end + end +end + +local function parse_go_output_from_raw(output) + local out = {} + if not output or output == "" then + return out + end + for line in output:gmatch("[^\n]+") do + local ok, data = pcall(vim.json.decode, line) + if ok and type(data) == "table" and data.Action == "output" and data.Test and data.Output then + if not out[data.Test] then + out[data.Test] = {} + end + local lines = vim.split(data.Output, "\n", { plain = true }) + if #lines > 0 and lines[#lines] == "" then + table.remove(lines, #lines) + end + for _, item in ipairs(lines) do + table.insert(out[data.Test], item) + end + end + end + return out +end + +local function ensure_detail_buf(lines) + local buf = state.detail_buf + if not (buf and vim.api.nvim_buf_is_valid(buf)) then + buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_option(buf, "bufhidden", "hide") + vim.api.nvim_buf_set_option(buf, "buftype", "nofile") + vim.api.nvim_buf_set_option(buf, "swapfile", false) + vim.api.nvim_buf_set_option(buf, "filetype", "test-samurai-output") + state.detail_buf = buf + vim.keymap.set("n", "", function() + close_container() + end, { buffer = buf, nowait = true, silent = true }) + vim.keymap.set("n", "h", function() + M.focus_listing() + end, { buffer = buf, nowait = true, silent = true }) + end + local clean_lines, highlights = parse_ansi_lines(normalize_output_lines(lines)) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, clean_lines) + apply_detail_highlights(buf, highlights) + return buf +end + +local function open_detail_split(lines) + local buf = ensure_detail_buf(lines) + local base = state.last_float or {} + local width = base.width or math.floor(vim.o.columns * 0.8) + local height = base.height or math.floor(vim.o.lines * 0.8) + local row = base.row or math.floor((vim.o.lines - height) / 2) + local col = base.col or math.floor((vim.o.columns - width) / 2) + local left_width = math.max(1, math.floor(width * 0.2)) + local right_width = width - left_width + if right_width < 1 then + right_width = 1 + left_width = math.max(1, width - right_width) + end + + if state.last_win and vim.api.nvim_win_is_valid(state.last_win) then + vim.api.nvim_win_set_config(state.last_win, { + relative = "editor", + width = left_width, + height = height, + row = row, + col = col, + style = "minimal", + border = "rounded", + }) + end + + local right_cfg = { + relative = "editor", + width = right_width, + height = height, + row = row, + col = col + left_width, + style = "minimal", + border = "rounded", + } + + local right = state.detail_win + if right and vim.api.nvim_win_is_valid(right) then + vim.api.nvim_win_set_buf(right, buf) + vim.api.nvim_win_set_config(right, right_cfg) + else + state.detail_opening = true + right = vim.api.nvim_open_win(buf, true, right_cfg) + state.detail_win = right + state.detail_opening = false + end + vim.api.nvim_set_current_win(right) +end + +function M.open_test_output_at_cursor() + local cursor = vim.api.nvim_win_get_cursor(0) + local line = cursor[1] + local test_name = state.last_result_line_map[line] + if not test_name then + local text = vim.api.nvim_get_current_line() + test_name = text:match("^%[%s*[%u]+%s*%]%s*%-%s*(.+)$") + end + if not test_name then + vim.notify("[test-samurai] No test output for this line", vim.log.levels.WARN) + return + end + local function resolve_output(name) + local output = state.last_test_outputs[name] + if output then + return name, output + end + local matches = {} + for full, lines in pairs(state.last_test_outputs) do + if full == name or full:sub(-#name) == name then + table.insert(matches, { name = full, lines = lines }) + end + end + if #matches == 1 then + return matches[1].name, matches[1].lines + end + return name, nil + end + local resolved_name, output = resolve_output(test_name) + test_name = resolved_name + if (type(output) ~= "table" or #output == 0) and state.last_raw_output and state.last_runner then + local parser = state.last_runner.parse_test_output + if type(parser) == "function" then + local ok_parse, parsed = pcall(parser, state.last_raw_output) + if ok_parse and type(parsed) == "table" then + state.last_test_outputs = parsed + test_name, output = resolve_output(test_name) + end + end + end + if (type(output) ~= "table" or #output == 0) and state.last_raw_output then + local parsed = parse_go_output_from_raw(state.last_raw_output) + if type(parsed) == "table" and next(parsed) ~= nil then + state.last_test_outputs = parsed + test_name, output = resolve_output(test_name) + end + end + if type(output) ~= "table" or #output == 0 then + vim.notify("[test-samurai] No output captured for " .. test_name, vim.log.levels.WARN) + return + end + open_detail_split(output) +end + +function M.focus_listing() + if state.last_win and vim.api.nvim_win_is_valid(state.last_win) then + vim.api.nvim_set_current_win(state.last_win) + end +end + local function run_cmd(cmd, cwd, handlers) local h = handlers or {} @@ -383,6 +850,38 @@ local function pick_display(results, key, scope_kind) return results[key] end +local function track_result_lines(start_line, results, scope_kind) + if not results then + return + end + local entries = {} + local function append_kind(kind) + local display = pick_display(results, kind, scope_kind) + if type(display) ~= "table" then + return + end + local full = results[kind] + for i, name in ipairs(display) do + local full_name = nil + if type(full) == "table" then + full_name = full[i] + end + if not full_name or full_name == "" then + full_name = name + end + table.insert(entries, full_name) + end + end + append_kind("passes") + append_kind("skips") + append_kind("failures") + for i, name in ipairs(entries) do + if name and name ~= "" then + state.last_result_line_map[start_line + i] = name + end + end +end + local function format_results(results, scope_kind) local lines = {} local passes = pick_display(results, "passes", scope_kind) @@ -539,6 +1038,9 @@ end local function run_command(command, opts) local options = opts or {} + state.last_test_outputs = {} + state.last_result_line_map = {} + state.last_raw_output = nil if command and type(command.cmd) == "table" and #command.cmd > 0 then if options.save_last ~= false then state.last_command = { @@ -568,6 +1070,7 @@ local function run_command(command, opts) if type(parser) == "function" then parser = { on_complete = parser } end + local runner = options.runner local parser_state = {} parser_state.scope_kind = options.scope_kind local had_parsed_output = false @@ -628,6 +1131,7 @@ local function run_command(command, opts) local start_line = vim.api.nvim_buf_line_count(buf) append_lines(buf, lines) apply_result_highlights(buf, start_line, lines) + track_result_lines(start_line, results, options.scope_kind) end run_cmd(cmd, cwd, { @@ -670,8 +1174,15 @@ local function run_command(command, opts) if not buf then buf = select(1, create_output_win({ header })) end + local output = table.concat(output_lines, "\n") + state.last_raw_output = output + if runner and type(runner.parse_test_output) == "function" then + local ok_parse, parsed = pcall(runner.parse_test_output, output) + if ok_parse and type(parsed) == "table" then + state.last_test_outputs = parsed + end + end if parser and parser.on_complete then - local output = table.concat(output_lines, "\n") local ok_parse, results = pcall(parser.on_complete, output, parser_state) if ok_parse then handle_parsed(results) diff --git a/lua/test-samurai/runners/go.lua b/lua/test-samurai/runners/go.lua index 60ecda6..eb1d735 100644 --- a/lua/test-samurai/runners/go.lua +++ b/lua/test-samurai/runners/go.lua @@ -275,6 +275,36 @@ function runner.parse_results(output) } end +local function split_output_lines(text) + if not text or text == "" then + return {} + end + local lines = vim.split(text, "\n", { plain = true }) + if #lines > 0 and lines[#lines] == "" then + table.remove(lines, #lines) + end + return lines +end + +function runner.parse_test_output(output) + local out = {} + if not output or output == "" then + return out + end + for line in output:gmatch("[^\n]+") do + local ok, data = pcall(vim.json.decode, line) + if ok and type(data) == "table" and data.Action == "output" and data.Test and data.Output then + if not out[data.Test] then + out[data.Test] = {} + end + for _, item in ipairs(split_output_lines(data.Output)) do + table.insert(out[data.Test], item) + end + end + end + return out +end + function runner.output_parser() local seen_pass = {} local seen_fail = {} diff --git a/lua/test-samurai/runners/js.lua b/lua/test-samurai/runners/js.lua index 3998a38..00d0c3a 100644 --- a/lua/test-samurai/runners/js.lua +++ b/lua/test-samurai/runners/js.lua @@ -452,6 +452,115 @@ function M.new(opts) return { passes = passes, failures = failures, skips = skips, display = display } end + local function split_output_lines(text) + if not text or text == "" then + return {} + end + local lines = vim.split(text, "\n", { plain = true }) + if #lines > 0 and lines[#lines] == "" then + table.remove(lines, #lines) + end + return lines + end + + local function append_output(out, name, text) + if not name or name == "" or not text or text == "" then + return + end + if not out[name] then + out[name] = {} + end + for _, line in ipairs(split_output_lines(text)) do + table.insert(out[name], line) + end + end + + local function decode_json_from_output(output) + local ok, data = pcall(vim.json.decode, output) + if ok and type(data) == "table" then + return data + end + if not output or output == "" then + return nil + end + local start_idx = output:find("{", 1, true) + if not start_idx then + return nil + end + local end_idx = nil + for i = #output, start_idx, -1 do + if output:sub(i, i) == "}" then + end_idx = i + break + end + end + if not end_idx then + return nil + end + local candidate = output:sub(start_idx, end_idx) + local ok2, data2 = pcall(vim.json.decode, candidate) + if ok2 and type(data2) == "table" then + return data2 + end + return nil + end + + local function collect_jest_failure_output(data) + local out = {} + for _, result in ipairs(data.testResults or {}) do + for _, assertion in ipairs(result.assertionResults or {}) do + if assertion.status == "failed" then + local name = assertion.fullName or assertion.title + for _, msg in ipairs(assertion.failureMessages or {}) do + append_output(out, name, msg) + end + end + end + end + return out + end + + local function collect_mocha_failure_output(output) + local out = {} + local ok, data = pcall(vim.json.decode, output) + if ok and type(data) == "table" then + for _, failure in ipairs(data.failures or {}) do + local name = failure.fullTitle or failure.title + local err = failure.err or failure + local message = nil + if type(err) == "table" then + message = err.stack or err.message + elseif type(err) == "string" then + message = err + end + append_output(out, name, message) + end + return out + end + for line in (output or ""):gmatch("[^\n]+") do + local ok_line, entry = pcall(vim.json.decode, line) + if ok_line and type(entry) == "table" then + local event = entry.event or entry[1] or entry["1"] + local payload = entry + if not entry.event then + payload = entry[2] or entry["2"] or {} + end + if event == "fail" then + local name = payload.fullTitle or payload.title + local err = payload.err or payload + local message = nil + if type(err) == "table" then + message = err.stack or err.message + elseif type(err) == "string" then + message = err + end + append_output(out, name, message) + end + end + end + return out + end + local function parse_jest_like(output) local ok, data = pcall(vim.json.decode, output) if ok and type(data) == "table" then @@ -648,6 +757,17 @@ function M.new(opts) return parse_output(output) end + function runner.parse_test_output(output) + if runner.framework == "mocha" then + return collect_mocha_failure_output(output) + end + local data = decode_json_from_output(output) + if not data then + return {} + end + return collect_jest_failure_output(data) + end + function runner.output_parser() local state = { raw = {}, done = false, saw_stream = false } local failures = {} diff --git a/tests/test_samurai_output_spec.lua b/tests/test_samurai_output_spec.lua index 612a61d..96a2dfb 100644 --- a/tests/test_samurai_output_spec.lua +++ b/tests/test_samurai_output_spec.lua @@ -1400,3 +1400,430 @@ describe("test-samurai output formatting", function() assert.is_true(has_fail) end) end) + +describe("test-samurai output detail view", function() + before_each(function() + test_samurai.setup() + end) + + local function find_float_wins() + local wins = vim.api.nvim_tabpage_list_wins(0) + local out = {} + for _, win in ipairs(wins) do + local cfg = vim.api.nvim_win_get_config(win) + if cfg.relative ~= "" then + table.insert(out, win) + end + end + return out + end + + local function find_non_float_win() + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + local cfg = vim.api.nvim_win_get_config(win) + if cfg.relative == "" then + return win + end + end + return nil + end + + it("opens failing test output in a right vsplit", function() + local orig_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(_cmd, opts) + if opts and opts.on_stdout then + opts.on_stdout(1, { + vim.json.encode({ Action = "output", Test = "TestFoo/Sub", Output = "=== RUN TestFoo/Sub\n" }), + vim.json.encode({ Action = "output", Test = "TestFoo/Sub", Output = " foo_test.go:10: expected\n" }), + vim.json.encode({ Action = "fail", Test = "TestFoo/Sub" }), + }, nil) + end + if opts and opts.on_exit then + opts.on_exit(1, 1, nil) + end + return 1 + end + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/output_detail_test.go") + vim.bo[bufnr].filetype = "go" + vim.api.nvim_set_current_buf(bufnr) + + test_samurai.test_file() + + local out_buf = vim.api.nvim_get_current_buf() + local lines = vim.api.nvim_buf_get_lines(out_buf, 0, -1, false) + local target = nil + for i, line in ipairs(lines) do + if line:match("^%[ FAIL %] %- ") then + target = i + break + end + end + assert.is_not_nil(target) + vim.api.nvim_win_set_cursor(0, { target, 0 }) + + local output_win = vim.api.nvim_get_current_win() + core.open_test_output_at_cursor() + local wins = find_float_wins() + assert.equals(2, #wins) + local right = nil + for _, win in ipairs(wins) do + if win ~= output_win then + right = win + break + end + end + assert.is_not_nil(right) + local left_cfg = vim.api.nvim_win_get_config(output_win) + local right_cfg = vim.api.nvim_win_get_config(right) + assert.equals(left_cfg.row, right_cfg.row) + assert.equals(left_cfg.height, right_cfg.height) + assert.equals(left_cfg.col + left_cfg.width, right_cfg.col) + local total_width = left_cfg.width + right_cfg.width + local expected_left = math.floor(total_width * 0.2) + assert.is_true(math.abs(left_cfg.width - expected_left) <= 1) + local right_buf = vim.api.nvim_win_get_buf(right) + local detail = vim.api.nvim_buf_get_lines(right_buf, 0, -1, false) + assert.are.same({ + "=== RUN TestFoo/Sub", + " foo_test.go:10: expected", + }, detail) + + vim.fn.jobstart = orig_jobstart + end) + + it("reuses an existing right split for test output", function() + local orig_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(_cmd, opts) + if opts and opts.on_stdout then + opts.on_stdout(1, { + vim.json.encode({ Action = "output", Test = "TestFoo/Sub", Output = "=== RUN TestFoo/Sub\n" }), + vim.json.encode({ Action = "output", Test = "TestFoo/Sub", Output = " foo_test.go:10: expected\n" }), + vim.json.encode({ Action = "fail", Test = "TestFoo/Sub" }), + }, nil) + end + if opts and opts.on_exit then + opts.on_exit(1, 1, nil) + end + return 1 + end + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/output_detail_reuse_test.go") + vim.bo[bufnr].filetype = "go" + vim.api.nvim_set_current_buf(bufnr) + + test_samurai.test_file() + local output_win = vim.api.nvim_get_current_win() + local out_buf = vim.api.nvim_get_current_buf() + local lines = vim.api.nvim_buf_get_lines(out_buf, 0, -1, false) + local target = nil + for i, line in ipairs(lines) do + if line:match("^%[ FAIL %] %- ") then + target = i + break + end + end + assert.is_not_nil(target) + vim.api.nvim_win_set_cursor(0, { target, 0 }) + + local output_win = vim.api.nvim_get_current_win() + core.open_test_output_at_cursor() + + local wins = find_float_wins() + local right = nil + for _, win in ipairs(wins) do + if win ~= output_win then + right = win + break + end + end + assert.is_not_nil(right) + local right_buf = vim.api.nvim_win_get_buf(right) + vim.api.nvim_buf_set_lines(right_buf, 0, -1, false, { "old output" }) + + vim.api.nvim_set_current_win(output_win) + core.open_test_output_at_cursor() + + local updated = find_float_wins() + local updated_right = nil + for _, win in ipairs(updated) do + if win ~= output_win then + updated_right = win + break + end + end + assert.equals(right, updated_right) + local detail = vim.api.nvim_buf_get_lines(right_buf, 0, -1, false) + assert.are.same({ + "=== RUN TestFoo/Sub", + " foo_test.go:10: expected", + }, detail) + + vim.fn.jobstart = orig_jobstart + end) + + it("translates ANSI codes into highlights for detail output", function() + local orig_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(_cmd, opts) + if opts and opts.on_stdout then + opts.on_stdout(1, { + vim.json.encode({ Action = "output", Test = "TestAnsi/Sub", Output = "\27[31mFAIL\27[0m bad\n" }), + vim.json.encode({ Action = "fail", Test = "TestAnsi/Sub" }), + }, nil) + end + if opts and opts.on_exit then + opts.on_exit(1, 1, nil) + end + return 1 + end + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/output_detail_ansi_test.go") + vim.bo[bufnr].filetype = "go" + vim.api.nvim_set_current_buf(bufnr) + + test_samurai.test_file() + local out_buf = vim.api.nvim_get_current_buf() + local lines = vim.api.nvim_buf_get_lines(out_buf, 0, -1, false) + local target = nil + for i, line in ipairs(lines) do + if line:match("^%[ FAIL %] %- ") then + target = i + break + end + end + assert.is_not_nil(target) + vim.api.nvim_win_set_cursor(0, { target, 0 }) + + local output_win = vim.api.nvim_get_current_win() + core.open_test_output_at_cursor() + + local wins = find_float_wins() + local right = nil + for _, win in ipairs(wins) do + if win ~= output_win then + right = win + break + end + end + assert.is_not_nil(right) + local right_buf = vim.api.nvim_win_get_buf(right) + local detail = vim.api.nvim_buf_get_lines(right_buf, 0, -1, false) + assert.are.same({ "FAIL bad" }, detail) + + local ns = vim.api.nvim_get_namespaces()["TestSamuraiDetailAnsi"] + assert.is_not_nil(ns) + local marks = vim.api.nvim_buf_get_extmarks(right_buf, ns, 0, -1, { details = true }) + assert.is_true(#marks > 0) + assert.is_not_nil(marks[1][4].hl_group) + + vim.fn.jobstart = orig_jobstart + end) + + it("closes detail float and restores listing width", function() + local orig_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(_cmd, opts) + if opts and opts.on_stdout then + opts.on_stdout(1, { + vim.json.encode({ Action = "output", Test = "TestFoo/Sub", Output = "=== RUN TestFoo/Sub\n" }), + vim.json.encode({ Action = "fail", Test = "TestFoo/Sub" }), + }, nil) + end + if opts and opts.on_exit then + opts.on_exit(1, 1, nil) + end + return 1 + end + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/output_detail_close_test.go") + vim.bo[bufnr].filetype = "go" + vim.api.nvim_set_current_buf(bufnr) + + test_samurai.test_file() + local out_buf = vim.api.nvim_get_current_buf() + local lines = vim.api.nvim_buf_get_lines(out_buf, 0, -1, false) + local target = nil + for i, line in ipairs(lines) do + if line:match("^%[ FAIL %] %- ") then + target = i + break + end + end + assert.is_not_nil(target) + vim.api.nvim_win_set_cursor(0, { target, 0 }) + + local output_win = vim.api.nvim_get_current_win() + core.open_test_output_at_cursor() + + local wins = find_float_wins() + assert.equals(2, #wins) + local right = nil + for _, win in ipairs(wins) do + if win ~= output_win then + right = win + break + end + end + assert.is_not_nil(right) + local left_cfg = vim.api.nvim_win_get_config(output_win) + local right_cfg = vim.api.nvim_win_get_config(right) + local total_width = left_cfg.width + right_cfg.width + + vim.api.nvim_win_close(right, true) + + local remaining = find_float_wins() + assert.equals(1, #remaining) + local cfg = vim.api.nvim_win_get_config(remaining[1]) + assert.equals(total_width, cfg.width) + + vim.fn.jobstart = orig_jobstart + end) + + it("closes container when listing float is closed", function() + local orig_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(_cmd, opts) + if opts and opts.on_stdout then + opts.on_stdout(1, { + vim.json.encode({ Action = "output", Test = "TestFoo/Sub", Output = "=== RUN TestFoo/Sub\n" }), + vim.json.encode({ Action = "fail", Test = "TestFoo/Sub" }), + }, nil) + end + if opts and opts.on_exit then + opts.on_exit(1, 1, nil) + end + return 1 + end + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/output_detail_close_listing_test.go") + vim.bo[bufnr].filetype = "go" + vim.api.nvim_set_current_buf(bufnr) + + test_samurai.test_file() + local out_buf = vim.api.nvim_get_current_buf() + local lines = vim.api.nvim_buf_get_lines(out_buf, 0, -1, false) + local target = nil + for i, line in ipairs(lines) do + if line:match("^%[ FAIL %] %- ") then + target = i + break + end + end + assert.is_not_nil(target) + vim.api.nvim_win_set_cursor(0, { target, 0 }) + + local output_win = vim.api.nvim_get_current_win() + core.open_test_output_at_cursor() + + vim.api.nvim_win_close(output_win, true) + + local remaining = find_float_wins() + assert.equals(0, #remaining) + + vim.fn.jobstart = orig_jobstart + end) + + it("closes container when navigating out of floats", function() + local orig_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(_cmd, opts) + if opts and opts.on_stdout then + opts.on_stdout(1, { + vim.json.encode({ Action = "output", Test = "TestFoo/Sub", Output = "=== RUN TestFoo/Sub\n" }), + vim.json.encode({ Action = "fail", Test = "TestFoo/Sub" }), + }, nil) + end + if opts and opts.on_exit then + opts.on_exit(1, 1, nil) + end + return 1 + end + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/output_detail_nav_close_test.go") + vim.bo[bufnr].filetype = "go" + vim.api.nvim_set_current_buf(bufnr) + + test_samurai.test_file() + local out_buf = vim.api.nvim_get_current_buf() + local lines = vim.api.nvim_buf_get_lines(out_buf, 0, -1, false) + local target = nil + for i, line in ipairs(lines) do + if line:match("^%[ FAIL %] %- ") then + target = i + break + end + end + assert.is_not_nil(target) + vim.api.nvim_win_set_cursor(0, { target, 0 }) + + core.open_test_output_at_cursor() + + local non_float = find_non_float_win() + assert.is_not_nil(non_float) + vim.api.nvim_set_current_win(non_float) + + local remaining = find_float_wins() + assert.equals(0, #remaining) + + vim.fn.jobstart = orig_jobstart + end) + + it("keeps container open when focusing listing from detail", function() + local orig_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(_cmd, opts) + if opts and opts.on_stdout then + opts.on_stdout(1, { + vim.json.encode({ Action = "output", Test = "TestFoo/Sub", Output = "=== RUN TestFoo/Sub\n" }), + vim.json.encode({ Action = "fail", Test = "TestFoo/Sub" }), + }, nil) + end + if opts and opts.on_exit then + opts.on_exit(1, 1, nil) + end + return 1 + end + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/output_detail_focus_listing_test.go") + vim.bo[bufnr].filetype = "go" + vim.api.nvim_set_current_buf(bufnr) + + test_samurai.test_file() + local out_buf = vim.api.nvim_get_current_buf() + local lines = vim.api.nvim_buf_get_lines(out_buf, 0, -1, false) + local target = nil + for i, line in ipairs(lines) do + if line:match("^%[ FAIL %] %- ") then + target = i + break + end + end + assert.is_not_nil(target) + vim.api.nvim_win_set_cursor(0, { target, 0 }) + + local output_win = vim.api.nvim_get_current_win() + core.open_test_output_at_cursor() + + local wins = find_float_wins() + local detail_win = nil + for _, win in ipairs(wins) do + if win ~= output_win then + detail_win = win + break + end + end + assert.is_not_nil(detail_win) + vim.api.nvim_set_current_win(detail_win) + + core.focus_listing() + + assert.equals(output_win, vim.api.nvim_get_current_win()) + local remaining = find_float_wins() + assert.equals(2, #remaining) + + vim.fn.jobstart = orig_jobstart + end) +end)