local config = require("test-samurai.config") local util = require("test-samurai.util") local M = {} local state = { runners = {}, last_win = nil, last_buf = nil, last_command = nil, last_runner = nil, last_scope_command = nil, last_scope_runner = nil, last_scope_kind = nil, last_scope_exit_code = nil, last_scope_failures = nil, autocmds_set = false, } local function load_runners() state.runners = {} local opts = config.get() local mods = opts.runner_modules or {} for _, mod in ipairs(mods) do local ok, runner = pcall(require, mod) if ok and type(runner) == "table" then table.insert(state.runners, runner) else vim.notify("[test-samurai] Failed to load runner " .. mod, vim.log.levels.WARN) end end end local function ensure_output_autocmds() if state.autocmds_set then return end local group = vim.api.nvim_create_augroup("TestSamuraiOutput", { clear = true }) 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 end end, }) state.autocmds_set = true end function M.setup() load_runners() ensure_output_autocmds() state.last_command = nil state.last_runner = nil state.last_scope_command = nil state.last_scope_runner = nil state.last_scope_kind = nil state.last_scope_exit_code = nil state.last_scope_failures = nil end function M.reload_runners() load_runners() end local function detect_js_framework(file) local root = util.find_root(file, { "package.json", "node_modules" }) if not root or root == "" then return nil end local pkg_path = vim.fs.joinpath(root, "package.json") local stat = vim.loop.fs_stat(pkg_path) if not stat or stat.type ~= "file" then return nil end local ok_read, lines = pcall(vim.fn.readfile, pkg_path) if not ok_read or type(lines) ~= "table" then return nil end local json = table.concat(lines, "\n") local ok_json, pkg = pcall(vim.json.decode, json) if not ok_json or type(pkg) ~= "table" then return nil end local present = {} local function scan_section(section) if type(section) ~= "table" then return end for name, _ in pairs(section) do if name == "mocha" or name == "jest" or name == "vitest" then present[name] = true end end end scan_section(pkg.dependencies) scan_section(pkg.devDependencies) if next(present) == nil then return nil end return present end function M.get_runner_for_buf(bufnr) local path = util.get_buf_path(bufnr) local candidates = {} for _, runner in ipairs(state.runners) do if type(runner.is_test_file) == "function" then local ok, is_test = pcall(runner.is_test_file, bufnr) if ok and is_test then table.insert(candidates, runner) end end end if #candidates == 1 then return candidates[1] elseif #candidates > 1 then local frameworks = nil if path and path ~= "" then frameworks = detect_js_framework(path) end if frameworks then for _, runner in ipairs(candidates) do if runner.framework and frameworks[runner.framework] then return runner end end end return candidates[1] end if not path or path == "" then return nil end if path:sub(-8) == "_test.go" then local ok, go = pcall(require, "test-samurai.runners.go") if ok and type(go) == "table" then return go end end if path:find(".test.", 1, true) or path:find(".spec.", 1, true) then local ok, jsjest = pcall(require, "test-samurai.runners.js-jest") if ok and type(jsjest) == "table" then return jsjest end end return nil end local function float_geometry() local width = math.floor(vim.o.columns * 0.8) local height = math.floor(vim.o.lines * 0.8) local row = math.floor((vim.o.lines - height) / 2) local col = math.floor((vim.o.columns - width) / 2) return width, height, row, col 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 local buf = state.last_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, "filetype", "test-samurai-output") state.last_buf = buf end vim.api.nvim_buf_set_lines(buf, 0, -1, false, initial_lines or {}) local width, height, row, col = float_geometry() local win = vim.api.nvim_open_win(buf, true, { relative = "editor", width = width, height = height, row = row, col = col, style = "minimal", border = "rounded", }) 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 end, { buffer = buf, nowait = true, silent = true }) state.last_win = win state.last_buf = buf return buf, win end local function reopen_output_win() if not (state.last_buf and vim.api.nvim_buf_is_valid(state.last_buf)) then return nil, nil end if state.last_win and vim.api.nvim_win_is_valid(state.last_win) then vim.api.nvim_set_current_win(state.last_win) return state.last_buf, state.last_win end local width, height, row, col = float_geometry() local win = vim.api.nvim_open_win(state.last_buf, true, { relative = "editor", width = width, height = height, row = row, col = col, style = "minimal", border = "rounded", }) 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 end, { buffer = state.last_buf, nowait = true, silent = true }) state.last_win = win return state.last_buf, win end local function append_lines(buf, new_lines) if not (buf and vim.api.nvim_buf_is_valid(buf)) then return end if not new_lines or #new_lines == 0 then return end local existing = vim.api.nvim_buf_line_count(buf) vim.api.nvim_buf_set_lines(buf, existing, existing, false, new_lines) if state.last_win and vim.api.nvim_win_is_valid(state.last_win) then local total = vim.api.nvim_buf_line_count(buf) vim.api.nvim_win_set_cursor(state.last_win, { total, 0 }) end end local function run_cmd(cmd, cwd, handlers) local h = handlers or {} if h.on_start then pcall(h.on_start) end local function handle_chunk(fn, data) if not fn or not data then return end local lines = {} for _, line in ipairs(data) do if line ~= nil and line ~= "" then table.insert(lines, line) end end if #lines > 0 then fn(lines) end end vim.fn.jobstart(cmd, { cwd = cwd, stdout_buffered = false, stderr_buffered = false, on_stdout = function(_, data, _) handle_chunk(h.on_stdout, data) end, on_stderr = function(_, data, _) handle_chunk(h.on_stderr, data) end, on_exit = function(_, code, _) if h.on_exit then pcall(h.on_exit, code or 0) end end, }) end local function pick_display(results, key, scope_kind) if scope_kind == "all" then return results[key] end if type(results.display) == "table" and type(results.display[key]) == "table" then return results.display[key] end return results[key] end local function format_results(results, scope_kind) local lines = {} local passes = pick_display(results, "passes", scope_kind) if type(passes) == "table" then for _, title in ipairs(passes) do table.insert(lines, "[ PASS ] - " .. title) end end local skips = pick_display(results, "skips", scope_kind) if type(skips) == "table" then for _, title in ipairs(skips) do table.insert(lines, "[ SKIP ] - " .. title) end end local failures = pick_display(results, "failures", scope_kind) if type(failures) == "table" then for _, title in ipairs(failures) do table.insert(lines, "[ FAIL ] - " .. title) end end return lines end local function run_command(command, opts) local options = opts or {} if command and type(command.cmd) == "table" and #command.cmd > 0 then if options.save_last ~= false then state.last_command = { cmd = vim.deepcopy(command.cmd), cwd = command.cwd, } state.last_runner = options.runner end if options.track_scope then state.last_scope_command = { cmd = vim.deepcopy(command.cmd), cwd = command.cwd, } state.last_scope_runner = options.runner state.last_scope_kind = options.scope_kind state.last_scope_exit_code = nil state.last_scope_failures = nil end end local cmd = command.cmd local cwd = command.cwd or vim.loop.cwd() local header = "$ " .. table.concat(cmd, " ") local buf = nil local has_output = false local parser = options.output_parser if type(parser) == "function" then parser = { on_complete = parser } end local parser_state = {} parser_state.scope_kind = options.scope_kind local had_parsed_output = false local output_lines = {} local function collect_output(lines) if not lines or #lines == 0 then return end for _, line in ipairs(lines) do if line ~= nil then table.insert(output_lines, line) end end end local function ensure_output_started() if not buf then buf = select(1, create_output_win({ header, "" })) end if not has_output then local cur = vim.api.nvim_buf_get_lines(buf, 0, -1, false) if #cur > 0 and cur[#cur] == "[running...]" then vim.api.nvim_buf_set_lines(buf, #cur - 1, #cur, false, {}) end has_output = true end end local function handle_parsed(results) if not results then return end had_parsed_output = true if options.track_scope then if results.failures_all ~= nil then state.last_scope_failures = results.failures_all elseif results.failures ~= nil then state.last_scope_failures = results.failures end end local lines = format_results(results, options.scope_kind) if #lines == 0 then return end ensure_output_started() append_lines(buf, lines) end run_cmd(cmd, cwd, { on_start = function() buf = select(1, create_output_win({ header, "", "[running...]" })) end, on_stdout = function(lines) if parser then collect_output(lines) if parser.on_line then for _, line in ipairs(lines or {}) do local ok_parse, results = pcall(parser.on_line, line, parser_state) if ok_parse then handle_parsed(results) end end end return end ensure_output_started() append_lines(buf, lines) end, on_stderr = function(lines) if parser then collect_output(lines) if parser.on_line then for _, line in ipairs(lines or {}) do local ok_parse, results = pcall(parser.on_line, line, parser_state) if ok_parse then handle_parsed(results) end end end return end ensure_output_started() append_lines(buf, lines) end, on_exit = function(code) if not buf then buf = select(1, create_output_win({ header })) 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) end end if parser then if not had_parsed_output and #output_lines > 0 then ensure_output_started() append_lines(buf, output_lines) elseif not has_output then ensure_output_started() end append_lines(buf, { "", "[exit code] " .. tostring(code) }) else if not has_output then local cur = vim.api.nvim_buf_get_lines(buf, 0, -1, false) if #cur > 0 and cur[#cur] == "[running...]" then vim.api.nvim_buf_set_lines(buf, #cur - 1, #cur, false, {}) end end append_lines(buf, { "", "[exit code] " .. tostring(code) }) end if options.track_scope then state.last_scope_exit_code = code end end, }) end function M.run_last() if not (state.last_command and type(state.last_command.cmd) == "table") then vim.notify("[test-samurai] No previous test command", vim.log.levels.WARN) return end local command = { cmd = vim.deepcopy(state.last_command.cmd), cwd = state.last_command.cwd, } local runner = state.last_runner local parser = nil if runner then parser = runner.output_parser if type(parser) == "function" then parser = parser() end end run_command(command, { runner = runner, output_parser = parser or (runner and runner.parse_results), }) end function M.run_nearest() local bufnr = vim.api.nvim_get_current_buf() local pos = vim.api.nvim_win_get_cursor(0) local row = pos[1] - 1 local col = pos[2] local runner = M.get_runner_for_buf(bufnr) if not runner then vim.notify("[test-samurai] No runner for this file", vim.log.levels.WARN) return end if type(runner.find_nearest) ~= "function" or type(runner.build_command) ~= "function" then vim.notify("[test-samurai] Runner missing methods", vim.log.levels.ERROR) return end local ok, spec_or_err = pcall(runner.find_nearest, bufnr, row, col) if not ok or not spec_or_err then local msg = "[test-samurai] No test found" if type(spec_or_err) == "string" then msg = "[test-samurai] " .. spec_or_err end vim.notify(msg, vim.log.levels.WARN) return end local spec = spec_or_err local ok_cmd, command = pcall(runner.build_command, spec) if not ok_cmd or not command or type(command.cmd) ~= "table" or #command.cmd == 0 then vim.notify("[test-samurai] Runner failed to build command", vim.log.levels.ERROR) return end local parser = runner.output_parser if type(parser) == "function" then parser = parser() end run_command(command, { track_scope = true, runner = runner, scope_kind = "nearest", output_parser = parser or runner.parse_results, }) end function M.run_file() local bufnr = vim.api.nvim_get_current_buf() local runner = M.get_runner_for_buf(bufnr) if not runner then vim.notify("[test-samurai] No runner for this file", vim.log.levels.WARN) return end if type(runner.build_file_command) ~= "function" then vim.notify("[test-samurai] Runner does not support file-level execution", vim.log.levels.WARN) return end local ok_cmd, command = pcall(runner.build_file_command, bufnr) if not ok_cmd or not command or type(command.cmd) ~= "table" or #command.cmd == 0 then vim.notify("[test-samurai] Runner failed to build file command", vim.log.levels.ERROR) return end local parser = runner.output_parser if type(parser) == "function" then parser = parser() end run_command(command, { track_scope = true, runner = runner, scope_kind = "file", output_parser = parser or runner.parse_results, }) end function M.run_all() local bufnr = vim.api.nvim_get_current_buf() local runner = M.get_runner_for_buf(bufnr) if not runner then vim.notify("[test-samurai] No runner for this file", vim.log.levels.WARN) return end if type(runner.build_all_command) ~= "function" then vim.notify("[test-samurai] Runner does not support project-level execution", vim.log.levels.WARN) return end local ok_cmd, command = pcall(runner.build_all_command, bufnr) if not ok_cmd or not command or type(command.cmd) ~= "table" or #command.cmd == 0 then vim.notify("[test-samurai] Runner failed to build all-tests command", vim.log.levels.ERROR) return end local parser = runner.output_parser if type(parser) == "function" then parser = parser() end run_command(command, { track_scope = true, runner = runner, scope_kind = "all", output_parser = parser or runner.parse_results, }) end local function build_failed_only_command() if not (state.last_scope_command and type(state.last_scope_command.cmd) == "table") then return nil, "[test-samurai] No previous scoped test command" end local failures = state.last_scope_failures or {} if #failures == 0 then return nil, nil end local runner = state.last_scope_runner if not runner or type(runner.build_failed_command) ~= "function" then local name = runner and runner.name or "unknown" return nil, "[test-samurai] Runner does not support failed-only: " .. name end local ok_build, command = pcall(runner.build_failed_command, state.last_scope_command, failures, state.last_scope_kind) if not ok_build or not command or type(command.cmd) ~= "table" or #command.cmd == 0 then return nil, "[test-samurai] Runner failed to build failed-only command" end return command, nil end function M.run_failed_only() if not (state.last_scope_command and type(state.last_scope_command.cmd) == "table") then vim.notify("[test-samurai] No previous scoped test command", vim.log.levels.WARN) return end local command, err = build_failed_only_command() if not command then if not err then M.run_last() return end vim.notify(err, vim.log.levels.WARN) return end local runner = state.last_scope_runner local parser = nil if runner then parser = runner.output_parser if type(parser) == "function" then parser = parser() end end run_command(command, { save_last = false, output_parser = parser or (runner and runner.parse_results), }) end function M.show_output() if not (state.last_buf and vim.api.nvim_buf_is_valid(state.last_buf)) then vim.notify("[test-samurai] No previous output", vim.log.levels.WARN) return end reopen_output_win() end return M