extend simple test listing to a list-detail-view window

This commit is contained in:
2025-12-27 22:33:46 +01:00
parent 519fc38769
commit 60960322ac
4 changed files with 1108 additions and 20 deletions

View File

@@ -15,11 +15,22 @@ local state = {
last_scope_exit_code = nil, last_scope_exit_code = nil,
last_scope_failures = nil, last_scope_failures = nil,
last_border_kind = "default", 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, autocmds_set = false,
} }
local summary_ns = vim.api.nvim_create_namespace("TestSamuraiSummary") local summary_ns = vim.api.nvim_create_namespace("TestSamuraiSummary")
local result_ns = vim.api.nvim_create_namespace("TestSamuraiResult") 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 function get_hl_fg(name)
local ok, hl = pcall(vim.api.nvim_get_hl, 0, { name = name, link = true }) 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" }, { vim.api.nvim_create_autocmd({ "BufEnter", "WinEnter" }, {
group = group, group = group,
callback = function() callback = function()
if state.last_win and vim.api.nvim_win_is_valid(state.last_win) then if state.detail_opening then
local cur = vim.api.nvim_get_current_win() return
if cur ~= state.last_win then
pcall(vim.api.nvim_win_close, state.last_win, true)
state.last_win = nil
end 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
end, end,
}) })
@@ -111,6 +143,12 @@ function M.setup()
state.last_scope_exit_code = nil state.last_scope_exit_code = nil
state.last_scope_failures = nil state.last_scope_failures = nil
state.last_border_kind = "default" 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 end
function M.reload_runners() function M.reload_runners()
@@ -223,7 +261,7 @@ local function float_geometry()
return width, height, row, col return width, height, row, col
end 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 if not (win and vim.api.nvim_win_is_valid(win)) then
return return
end end
@@ -236,11 +274,46 @@ local function apply_border_kind(win, kind)
end end
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) local function create_output_win(initial_lines)
if state.last_win and vim.api.nvim_win_is_valid(state.last_win) then 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) pcall(vim.api.nvim_win_close, state.last_win, true)
state.last_win = nil state.last_win = nil
end 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 local buf = state.last_buf
if not (buf and vim.api.nvim_buf_is_valid(buf)) then 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 {}) vim.api.nvim_buf_set_lines(buf, 0, -1, false, initial_lines or {})
local width, height, row, col = float_geometry() 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, { local win = vim.api.nvim_open_win(buf, true, {
relative = "editor", relative = "editor",
@@ -265,12 +339,10 @@ local function create_output_win(initial_lines)
}) })
vim.keymap.set("n", "<esc><esc>", function() vim.keymap.set("n", "<esc><esc>", function()
if vim.api.nvim_win_is_valid(win) then close_container()
vim.api.nvim_win_close(win, true) end, { buffer = buf, nowait = true, silent = true })
if state.last_win == win then vim.keymap.set("n", "<cr>", function()
state.last_win = nil M.open_test_output_at_cursor()
end
end
end, { buffer = buf, nowait = true, silent = true }) end, { buffer = buf, nowait = true, silent = true })
state.last_win = win state.last_win = win
@@ -289,8 +361,13 @@ local function reopen_output_win()
vim.api.nvim_set_current_win(state.last_win) vim.api.nvim_set_current_win(state.last_win)
return state.last_buf, state.last_win return state.last_buf, state.last_win
end 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() 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, { local win = vim.api.nvim_open_win(state.last_buf, true, {
relative = "editor", relative = "editor",
@@ -303,12 +380,10 @@ local function reopen_output_win()
}) })
vim.keymap.set("n", "<esc><esc>", function() vim.keymap.set("n", "<esc><esc>", function()
if vim.api.nvim_win_is_valid(win) then close_container()
vim.api.nvim_win_close(win, true) end, { buffer = state.last_buf, nowait = true, silent = true })
if state.last_win == win then vim.keymap.set("n", "<cr>", function()
state.last_win = nil M.open_test_output_at_cursor()
end
end
end, { buffer = state.last_buf, nowait = true, silent = true }) end, { buffer = state.last_buf, nowait = true, silent = true })
state.last_win = win state.last_win = win
@@ -333,6 +408,398 @@ local function append_lines(buf, new_lines)
end end
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", "<esc><esc>", function()
close_container()
end, { buffer = buf, nowait = true, silent = true })
vim.keymap.set("n", "<C-w>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 function run_cmd(cmd, cwd, handlers)
local h = handlers or {} local h = handlers or {}
@@ -383,6 +850,38 @@ local function pick_display(results, key, scope_kind)
return results[key] return results[key]
end 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 function format_results(results, scope_kind)
local lines = {} local lines = {}
local passes = pick_display(results, "passes", scope_kind) local passes = pick_display(results, "passes", scope_kind)
@@ -539,6 +1038,9 @@ end
local function run_command(command, opts) local function run_command(command, opts)
local options = opts or {} 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 command and type(command.cmd) == "table" and #command.cmd > 0 then
if options.save_last ~= false then if options.save_last ~= false then
state.last_command = { state.last_command = {
@@ -568,6 +1070,7 @@ local function run_command(command, opts)
if type(parser) == "function" then if type(parser) == "function" then
parser = { on_complete = parser } parser = { on_complete = parser }
end end
local runner = options.runner
local parser_state = {} local parser_state = {}
parser_state.scope_kind = options.scope_kind parser_state.scope_kind = options.scope_kind
local had_parsed_output = false 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) local start_line = vim.api.nvim_buf_line_count(buf)
append_lines(buf, lines) append_lines(buf, lines)
apply_result_highlights(buf, start_line, lines) apply_result_highlights(buf, start_line, lines)
track_result_lines(start_line, results, options.scope_kind)
end end
run_cmd(cmd, cwd, { run_cmd(cmd, cwd, {
@@ -670,8 +1174,15 @@ local function run_command(command, opts)
if not buf then if not buf then
buf = select(1, create_output_win({ header })) buf = select(1, create_output_win({ header }))
end end
if parser and parser.on_complete then
local output = table.concat(output_lines, "\n") 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 ok_parse, results = pcall(parser.on_complete, output, parser_state) local ok_parse, results = pcall(parser.on_complete, output, parser_state)
if ok_parse then if ok_parse then
handle_parsed(results) handle_parsed(results)

View File

@@ -275,6 +275,36 @@ function runner.parse_results(output)
} }
end 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() function runner.output_parser()
local seen_pass = {} local seen_pass = {}
local seen_fail = {} local seen_fail = {}

View File

@@ -452,6 +452,115 @@ function M.new(opts)
return { passes = passes, failures = failures, skips = skips, display = display } return { passes = passes, failures = failures, skips = skips, display = display }
end 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 function parse_jest_like(output)
local ok, data = pcall(vim.json.decode, output) local ok, data = pcall(vim.json.decode, output)
if ok and type(data) == "table" then if ok and type(data) == "table" then
@@ -648,6 +757,17 @@ function M.new(opts)
return parse_output(output) return parse_output(output)
end 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() function runner.output_parser()
local state = { raw = {}, done = false, saw_stream = false } local state = { raw = {}, done = false, saw_stream = false }
local failures = {} local failures = {}

View File

@@ -1400,3 +1400,430 @@ describe("test-samurai output formatting", function()
assert.is_true(has_fail) assert.is_true(has_fail)
end) end)
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)