diff --git a/lua/test-samurai/config.lua b/lua/test-samurai/config.lua index d3538cb..e13eddd 100644 --- a/lua/test-samurai/config.lua +++ b/lua/test-samurai/config.lua @@ -6,7 +6,6 @@ local defaults = { "test-samurai.runners.js-jest", "test-samurai.runners.js-mocha", "test-samurai.runners.js-vitest", - "test-samurai.runners.lua-plenary", }, } diff --git a/lua/test-samurai/core.lua b/lua/test-samurai/core.lua index d8b02bc..4dec1a6 100644 --- a/lua/test-samurai/core.lua +++ b/lua/test-samurai/core.lua @@ -8,6 +8,11 @@ local state = { last_win = nil, last_buf = nil, last_command = 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, } @@ -52,6 +57,11 @@ function M.setup() load_runners() ensure_output_autocmds() state.last_command = 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() @@ -299,12 +309,45 @@ local function run_cmd(cmd, cwd, handlers) }) end -local function run_command(command) +local function format_results(results) + local lines = {} + if type(results.passes) == "table" then + for _, title in ipairs(results.passes) do + table.insert(lines, "[ PASS ] - " .. title) + end + end + if type(results.skips) == "table" then + for _, title in ipairs(results.skips) do + table.insert(lines, "[ SKIP ] - " .. title) + end + end + if type(results.failures) == "table" then + for _, title in ipairs(results.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 - state.last_command = { - cmd = vim.deepcopy(command.cmd), - cwd = command.cwd, - } + if options.save_last ~= false then + state.last_command = { + cmd = vim.deepcopy(command.cmd), + cwd = command.cwd, + } + 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() @@ -312,48 +355,126 @@ local function run_command(command) 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 = {} + 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 + local lines = format_results(results) + if #lines == 0 then + return + end + ensure_output_started() + append_lines(buf, lines) + 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 + end run_cmd(cmd, cwd, { on_start = function() buf = select(1, create_output_win({ header, "", "[running...]" })) end, on_stdout = function(lines) - 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, {}) + 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 - has_output = true + return end + ensure_output_started() append_lines(buf, lines) end, on_stderr = function(lines) - 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, {}) + 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 - has_output = true + 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 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, {}) + 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 - append_lines(buf, { "", "[exit code] " .. tostring(code) }) + 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 @@ -405,7 +526,16 @@ function M.run_nearest() return end - run_command(command) + 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() @@ -428,7 +558,16 @@ function M.run_file() return end - run_command(command) + 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() @@ -451,7 +590,59 @@ function M.run_all() return end - run_command(command) + 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 + + run_command(command, { save_last = false }) end function M.show_output() diff --git a/lua/test-samurai/init.lua b/lua/test-samurai/init.lua index 8342807..d5246da 100644 --- a/lua/test-samurai/init.lua +++ b/lua/test-samurai/init.lua @@ -24,6 +24,10 @@ function M.test_last() core.run_last() end +function M.test_failed_only() + core.run_failed_only() +end + function M.show_output() core.show_output() end diff --git a/lua/test-samurai/runners/go.lua b/lua/test-samurai/runners/go.lua index 6a31517..0896616 100644 --- a/lua/test-samurai/runners/go.lua +++ b/lua/test-samurai/runners/go.lua @@ -106,6 +106,18 @@ local function build_pkg_arg(spec) return "./" .. rel end +local function collect_unique(list) + local out = {} + local seen = {} + for _, item in ipairs(list) do + if item and item ~= "" and not seen[item] then + seen[item] = true + table.insert(out, item) + end + end + return out +end + function runner.is_test_file(bufnr) local path = util.get_buf_path(bufnr) if not path or path == "" then @@ -170,7 +182,7 @@ end function runner.build_command(spec) local pattern = build_run_pattern(spec) local pkg = build_pkg_arg(spec) - local cmd = { "go", "test", "-v", pkg, "-run", pattern } + local cmd = { "go", "test", "-json", pkg, "-run", pattern } return { cmd = cmd, cwd = spec.cwd, @@ -188,7 +200,7 @@ function runner.build_file_command(bufnr) end local spec = { file = path, cwd = root } local pkg = build_pkg_arg(spec) - local cmd = { "go", "test", "-v", pkg } + local cmd = { "go", "test", "-json", pkg } return { cmd = cmd, cwd = root, @@ -204,11 +216,125 @@ function runner.build_all_command(bufnr) if not root or root == "" then root = vim.loop.cwd() end - local cmd = { "go", "test", "-v", "./..." } + local cmd = { "go", "test", "-json", "./..." } return { cmd = cmd, cwd = root, } end +function runner.parse_results(output) + if not output or output == "" then + return { passes = {}, failures = {}, skips = {} } + end + local passes = {} + local failures = {} + local skips = {} + for line in output:gmatch("[^\n]+") do + local ok, data = pcall(vim.json.decode, line) + if ok and type(data) == "table" then + if data.Test and data.Test ~= "" then + if data.Action == "pass" then + table.insert(passes, data.Test) + elseif data.Action == "fail" then + table.insert(failures, data.Test) + elseif data.Action == "skip" then + table.insert(skips, data.Test) + end + end + end + end + return { + passes = collect_unique(passes), + failures = collect_unique(failures), + skips = collect_unique(skips), + } +end + +function runner.output_parser() + local seen_pass = {} + local seen_fail = {} + local failures = {} + local passes = {} + local skips = {} + + return { + on_line = function(line, _state) + local ok, data = pcall(vim.json.decode, line) + if not ok or type(data) ~= "table" then + return nil + end + local name = data.Test + if not name or name == "" then + return nil + end + if data.Action == "pass" and not seen_pass[name] then + seen_pass[name] = true + table.insert(passes, name) + return { + passes = { name }, + failures = {}, + skips = {}, + failures_all = vim.deepcopy(failures), + } + elseif data.Action == "fail" and not seen_fail[name] then + seen_fail[name] = true + table.insert(failures, name) + return { + passes = {}, + failures = { name }, + skips = {}, + failures_all = vim.deepcopy(failures), + } + elseif data.Action == "skip" and not seen_pass[name] then + seen_pass[name] = true + table.insert(skips, name) + return { + passes = {}, + failures = {}, + skips = { name }, + failures_all = vim.deepcopy(failures), + } + end + return nil + end, + on_complete = function(_output, _state) + return nil + end, + } +end + +function runner.build_failed_command(last_command, failures, _scope_kind) + if not last_command or type(last_command.cmd) ~= "table" then + return nil + end + local pattern_parts = {} + for _, name in ipairs(failures or {}) do + table.insert(pattern_parts, escape_go_regex(name)) + end + if #pattern_parts == 0 then + return nil + end + local pattern = "^(" .. table.concat(pattern_parts, "|") .. ")$" + + local cmd = {} + local skip_next = false + for _, arg in ipairs(last_command.cmd) do + if skip_next then + skip_next = false + elseif arg == "-run" then + skip_next = true + else + table.insert(cmd, arg) + end + end + table.insert(cmd, "-run") + table.insert(cmd, pattern) + + return { + cmd = cmd, + cwd = last_command.cwd, + } +end + return runner diff --git a/lua/test-samurai/runners/js-jest.lua b/lua/test-samurai/runners/js-jest.lua index 4aa0cba..6959ba2 100644 --- a/lua/test-samurai/runners/js-jest.lua +++ b/lua/test-samurai/runners/js-jest.lua @@ -4,4 +4,5 @@ return js.new({ name = "js-jest", framework = "jest", command = { "npx", "jest" }, + json_args = { "--json" }, }) diff --git a/lua/test-samurai/runners/js-mocha.lua b/lua/test-samurai/runners/js-mocha.lua index 8664f3e..d9813bf 100644 --- a/lua/test-samurai/runners/js-mocha.lua +++ b/lua/test-samurai/runners/js-mocha.lua @@ -5,4 +5,5 @@ return js.new({ framework = "mocha", command = { "npx", "mocha" }, all_glob = "test/**/*.test.js", + json_args = { "--reporter", "json-stream" }, }) diff --git a/lua/test-samurai/runners/js-vitest.lua b/lua/test-samurai/runners/js-vitest.lua index d56b206..4d17b7f 100644 --- a/lua/test-samurai/runners/js-vitest.lua +++ b/lua/test-samurai/runners/js-vitest.lua @@ -4,4 +4,5 @@ return js.new({ name = "js-vitest", framework = "vitest", command = { "npx", "vitest" }, + json_args = { "--reporter", "tap-flat" }, }) diff --git a/lua/test-samurai/runners/js.lua b/lua/test-samurai/runners/js.lua index f471e78..e864da3 100644 --- a/lua/test-samurai/runners/js.lua +++ b/lua/test-samurai/runners/js.lua @@ -28,6 +28,48 @@ local function is_js_test_file(bufnr, filetypes, patterns) return false end +local function append_args(cmd, args) + if not args or #args == 0 then + return + end + for _, arg in ipairs(args) do + table.insert(cmd, arg) + end +end + +local function escape_regex(s) + s = s or "" + return (s:gsub("([\\.^$|()%%[%]{}*+?%-])", "\\\\%1")) +end + +local function build_pattern(names) + local parts = {} + for _, name in ipairs(names or {}) do + if name and name ~= "" then + table.insert(parts, escape_regex(name)) + end + end + if #parts == 0 then + return nil + end + return table.concat(parts, "|") +end + +local function find_test_file_arg(cmd) + if not cmd then + return nil + end + for i = #cmd, 1, -1 do + local arg = cmd[i] + if type(arg) == "string" and arg:sub(1, 1) ~= "-" then + if arg:match("%.test%.[jt]sx?$") or arg:match("%.spec%.[jt]sx?$") then + return arg + end + end + end + return nil +end + local function find_block_end(lines, start_idx) local depth = 0 local started = false @@ -221,6 +263,8 @@ function M.new(opts) runner.framework = cfg.framework or "jest" runner.command = cfg.command or { "npx", runner.framework } runner.all_glob = cfg.all_glob + runner.json_args = cfg.json_args or {} + runner.failed_only_flag = cfg.failed_only_flag runner.filetypes = {} if cfg.filetypes then @@ -271,6 +315,7 @@ function M.new(opts) local function build_jest(spec) local cmd = vim.deepcopy(runner.command) + append_args(cmd, runner.json_args) table.insert(cmd, spec.file) table.insert(cmd, "-t") table.insert(cmd, spec.test_name) @@ -279,6 +324,7 @@ function M.new(opts) local function build_mocha(spec) local cmd = vim.deepcopy(runner.command) + append_args(cmd, runner.json_args) local target = spec.full_name or spec.test_name table.insert(cmd, "--fgrep") table.insert(cmd, target) @@ -288,6 +334,7 @@ function M.new(opts) local function build_vitest(spec) local cmd = vim.deepcopy(runner.command) + append_args(cmd, runner.json_args) table.insert(cmd, spec.file) table.insert(cmd, "-t") table.insert(cmd, spec.test_name) @@ -332,6 +379,7 @@ function M.new(opts) }) end local cmd = vim.deepcopy(runner.command) + append_args(cmd, runner.json_args) table.insert(cmd, path) return { cmd = cmd, @@ -360,6 +408,7 @@ function M.new(opts) root = vim.loop.cwd() end local cmd = vim.deepcopy(runner.command) + append_args(cmd, runner.json_args) if runner.framework == "mocha" and runner.all_glob then table.insert(cmd, runner.all_glob) end @@ -369,6 +418,226 @@ function M.new(opts) } end + local function parse_jest_like(output) + local ok, data = pcall(vim.json.decode, output) + if not ok or type(data) ~= "table" then + return nil + end + local passes = {} + local failures = {} + local skips = {} + for _, result in ipairs(data.testResults or {}) do + for _, assertion in ipairs(result.assertionResults or {}) do + local title = assertion.fullName or assertion.title + if assertion.status == "passed" and title then + table.insert(passes, title) + elseif assertion.status == "failed" and title then + table.insert(failures, title) + elseif (assertion.status == "pending" or assertion.status == "skipped" or assertion.status == "todo") + and title then + table.insert(skips, title) + end + end + end + return { passes = passes, failures = failures, skips = skips } + end + + local function parse_mocha(output) + local ok, data = pcall(vim.json.decode, output) + if not ok or type(data) ~= "table" then + return nil + end + local passes = {} + local failures = {} + local skips = {} + if type(data.tests) == "table" then + for _, test in ipairs(data.tests) do + local title = test.fullTitle or test.title + if test.state == "passed" and title then + table.insert(passes, title) + elseif test.state == "failed" and title then + table.insert(failures, title) + elseif test.state == "pending" and title then + table.insert(skips, title) + end + end + elseif type(data.passes) == "table" or type(data.failures) == "table" then + for _, test in ipairs(data.passes or {}) do + local title = test.fullTitle or test.title + if title then + table.insert(passes, title) + end + end + for _, test in ipairs(data.failures or {}) do + local title = test.fullTitle or test.title + if title then + table.insert(failures, title) + end + end + for _, test in ipairs(data.pending or {}) do + local title = test.fullTitle or test.title + if title then + table.insert(skips, title) + end + end + end + return { passes = passes, failures = failures, skips = skips } + end + + local function parse_output(output) + if runner.framework == "mocha" then + return parse_mocha(output) + end + return parse_jest_like(output) + end + + function runner.parse_results(output) + return parse_output(output) + end + + function runner.output_parser() + local state = { raw = {}, done = false } + local failures = {} + local skips = {} + return { + on_line = function(line, _state) + if state.done then + return nil + end + if runner.framework == "mocha" and runner.json_args then + local uses_stream = false + for i = 1, #runner.json_args - 1 do + if runner.json_args[i] == "--reporter" and runner.json_args[i + 1] == "json-stream" then + uses_stream = true + break + end + end + if uses_stream then + local ok, data = pcall(vim.json.decode, line) + if ok and type(data) == "table" and data.event then + if data.event == "pass" and data.fullTitle then + return { + passes = { data.fullTitle }, + failures = {}, + skips = {}, + failures_all = vim.deepcopy(failures), + } + elseif data.event == "fail" and data.fullTitle then + table.insert(failures, data.fullTitle) + return { + passes = {}, + failures = { data.fullTitle }, + skips = {}, + failures_all = vim.deepcopy(failures), + } + elseif data.event == "pending" and data.fullTitle then + table.insert(skips, data.fullTitle) + return { + passes = {}, + failures = {}, + skips = { data.fullTitle }, + failures_all = vim.deepcopy(failures), + } + end + end + return nil + end + end + if runner.framework == "vitest" and runner.json_args then + local uses_tap = false + for i = 1, #runner.json_args - 1 do + if runner.json_args[i] == "--reporter" and (runner.json_args[i + 1] == "tap" or runner.json_args[i + 1] == "tap-flat") then + uses_tap = true + break + end + end + if uses_tap then + local ok_title = line:match("^ok%s+%d+%s+%-%s+(.+)") + if ok_title then + local skip_title = ok_title:match("^(.-)%s+#%s+SKIP.*$") + if skip_title then + skip_title = skip_title:gsub("%s+$", "") + table.insert(skips, skip_title) + return { + passes = {}, + failures = {}, + skips = { skip_title }, + failures_all = vim.deepcopy(failures), + } + end + ok_title = ok_title:gsub("%s+#%s+time=.*$", "") + return { + passes = { ok_title }, + failures = {}, + skips = {}, + failures_all = vim.deepcopy(failures), + } + end + local fail_title = line:match("^not ok%s+%d+%s+%-%s+(.+)") + if fail_title then + fail_title = fail_title:gsub("%s+#%s+time=.*$", "") + table.insert(failures, fail_title) + return { + passes = {}, + failures = { fail_title }, + skips = {}, + failures_all = vim.deepcopy(failures), + } + end + return nil + end + end + table.insert(state.raw, line) + local output = table.concat(state.raw, "\n") + local results = parse_output(output) + if results then + state.done = true + end + return results + end, + on_complete = function(output, _state) + if state.done then + return nil + end + local results = parse_output(output) + if results then + state.done = true + end + return results + end, + } + end + + function runner.build_failed_command(last_command, failures, scope_kind) + local pattern = build_pattern(failures) + if not pattern then + return nil + end + + local cmd = vim.deepcopy(runner.command) + append_args(cmd, runner.json_args) + + if runner.framework == "mocha" then + table.insert(cmd, "--grep") + table.insert(cmd, pattern) + else + table.insert(cmd, "-t") + table.insert(cmd, pattern) + end + + if scope_kind ~= "all" and last_command then + local file = find_test_file_arg(last_command.cmd) + if file then + table.insert(cmd, file) + end + end + + return { + cmd = cmd, + cwd = last_command and last_command.cwd or nil, + } + end + return runner end diff --git a/lua/test-samurai/runners/lua-plenary.lua b/lua/test-samurai/runners/lua-plenary.lua deleted file mode 100644 index d3eeecb..0000000 --- a/lua/test-samurai/runners/lua-plenary.lua +++ /dev/null @@ -1,240 +0,0 @@ -local util = require("test-samurai.util") - -local runner = { - name = "lua-plenary", - framework = "plenary", -} - -local function is_lua_test_path(path) - if not path or path == "" then - return false - end - if not path:match("%.lua$") then - return false - end - if path:match("_spec%.lua$") then - return true - end - if path:match("/tests/") then - return true - end - return false -end - -function runner.is_test_file(bufnr) - local path = util.get_buf_path(bufnr) - return is_lua_test_path(path) -end - -local function find_repo_root(file) - local root = util.find_root(file, { ".git", "tests", "lua", "plugin" }) - if not root or root == "" then - root = vim.loop.cwd() - end - return root -end - -local function minimal_init_for(root) - return vim.fs.joinpath(root, "tests", "minimal_init.lua") -end - -local function escape_for_ex_double_quotes(s) - s = s or "" - s = s:gsub("\\", "\\\\") - s = s:gsub('"', '\\"') - return s -end - -local function count_keyword(line, kw) - local c = 0 - local pat = "%f[%w]" .. kw .. "%f[%W]" - for _ in line:gmatch(pat) do - c = c + 1 - end - return c -end - -local function find_end_lua_block(lines, start_idx) - local depth = 0 - local started = false - for i = start_idx, #lines do - local line = lines[i] - local fnc = count_keyword(line, "function") - local endc = count_keyword(line, "end") - if fnc > 0 then - depth = depth + fnc - started = true - end - if started and endc > 0 then - depth = depth - endc - if depth <= 0 then - return i - 1 - end - end - end - return #lines - 1 -end - -local function collect_lua_structs(lines) - local describes = {} - local tests = {} - - for i, line in ipairs(lines) do - local dname = line:match("^%s*describe%s*%(%s*['\"`]([^'\"`]+)['\"`]") - if dname then - local start0 = i - 1 - local end0 = find_end_lua_block(lines, i) - table.insert(describes, { kind = "describe", name = dname, start = start0, ["end"] = end0 }) - else - local tname = line:match("^%s*it%s*%(%s*['\"`]([^'\"`]+)['\"`]") - if tname then - local start0 = i - 1 - local end0 = find_end_lua_block(lines, i) - table.insert(tests, { kind = "it", name = tname, start = start0, ["end"] = end0 }) - end - end - end - - return describes, tests -end - -local function build_full_name(lines, idx, leaf_name) - local parts = { leaf_name } - for i = idx - 1, 1, -1 do - local line = lines[i] - local dname = line:match("^%s*describe%s*%(%s*['\"`]([^'\"`]+)['\"`]") - if dname then - table.insert(parts, 1, dname) - end - end - return table.concat(parts, " ") -end - -function runner.find_nearest(bufnr, row, _col) - if not runner.is_test_file(bufnr) then - return nil, "not a lua test file" - end - - local lines = util.get_buf_lines(bufnr) - local describes, tests = collect_lua_structs(lines) - - for _, t in ipairs(tests) do - if row >= t.start and row <= t["end"] then - local full = build_full_name(lines, t.start + 1, t.name) - local file = util.get_buf_path(bufnr) - local root = find_repo_root(file) - return { file = file, cwd = root, test_name = t.name, full_name = full, kind = "it" } - end - end - - local best_describe = nil - for _, d in ipairs(describes) do - if row >= d.start and row <= d["end"] then - if not best_describe or d.start >= best_describe.start then - best_describe = d - end - end - end - if best_describe then - local full = build_full_name(lines, best_describe.start + 1, best_describe.name) - local file = util.get_buf_path(bufnr) - local root = find_repo_root(file) - return { file = file, cwd = root, test_name = best_describe.name, full_name = full, kind = "describe" } - end - - local start = row + 1 - if start > #lines then - start = #lines - elseif start < 1 then - start = 1 - end - for i = start, 1, -1 do - local line = lines[i] - local tname = line:match("^%s*it%s*%(%s*['\"`]([^'\"`]+)['\"`]") - if tname then - local full = build_full_name(lines, i, tname) - local file = util.get_buf_path(bufnr) - local root = find_repo_root(file) - return { file = file, cwd = root, test_name = tname, full_name = full, kind = "it" } - end - local dname = line:match("^%s*describe%s*%(%s*['\"`]([^'\"`]+)['\"`]") - if dname then - local full = build_full_name(lines, i, dname) - local file = util.get_buf_path(bufnr) - local root = find_repo_root(file) - return { file = file, cwd = root, test_name = dname, full_name = full, kind = "describe" } - end - end - - return nil, "no test call found" -end - -local function ex_plenary_busted_file(file, filter_name) - local ex = "PlenaryBustedFile " .. vim.fn.fnameescape(file) - if filter_name and filter_name ~= "" then - local f = escape_for_ex_double_quotes(filter_name) - ex = ex .. ' { busted_args = { "--filter", "' .. f .. '" } }' - end - return ex -end - -function runner.build_command(spec) - local root = spec and spec.cwd or vim.loop.cwd() - local minit = minimal_init_for(root) - - local cmd = { - "nvim", - "--headless", - "-u", - minit, - "-c", - ex_plenary_busted_file(spec.file, spec.test_name), - "-c", - "qa", - } - - return { cmd = cmd, cwd = root } -end - -function runner.build_file_command(bufnr) - local file = util.get_buf_path(bufnr) - if not file or file == "" then - return nil - end - local root = find_repo_root(file) - local minit = minimal_init_for(root) - - local cmd = { - "nvim", - "--headless", - "-u", - minit, - "-c", - ex_plenary_busted_file(file, nil), - "-c", - "qa", - } - - return { cmd = cmd, cwd = root } -end - -function runner.build_all_command(bufnr) - local file = util.get_buf_path(bufnr) - local root = find_repo_root(file) - local minit = minimal_init_for(root) - - local cmd = { - "nvim", - "--headless", - "-u", - minit, - "-c", - "PlenaryBustedDirectory tests", - "-c", - "qa", - } - - return { cmd = cmd, cwd = root } -end - -return runner diff --git a/plugin/test-samurai.lua b/plugin/test-samurai.lua index a7e634f..98b8e15 100644 --- a/plugin/test-samurai.lua +++ b/plugin/test-samurai.lua @@ -23,6 +23,10 @@ vim.api.nvim_create_user_command("TSamLast", function() require("test-samurai").test_last() end, { desc = "test-samurai: rerun last test command" }) +vim.api.nvim_create_user_command("TSamFailedOnly", function() + require("test-samurai").test_failed_only() +end, { desc = "test-samurai: rerun failed tests from last scoped run" }) + vim.keymap.set("n", "tn", function() require("test-samurai").test_nearest() end, { desc = "test-samurai: run nearest test" }) @@ -42,3 +46,7 @@ end, { desc = "test-samurai: run all tests in project (per runner)" }) vim.keymap.set("n", "tl", function() require("test-samurai").test_last() end, { desc = "test-samurai: rerun last test command" }) + +vim.keymap.set("n", "te", function() + require("test-samurai").test_failed_only() +end, { desc = "test-samurai: rerun failed tests from last scoped run" }) diff --git a/tests/test_samurai_commands_spec.lua b/tests/test_samurai_commands_spec.lua new file mode 100644 index 0000000..fb39566 --- /dev/null +++ b/tests/test_samurai_commands_spec.lua @@ -0,0 +1,15 @@ +describe("test-samurai commands", function() + before_each(function() + vim.g.loaded_test_samurai_plugin = nil + vim.cmd("runtime plugin/test-samurai.lua") + end) + + it("registers TSamFailedOnly command", function() + assert.equals(2, vim.fn.exists(":TSamFailedOnly")) + end) + + it("registers te keymap", function() + local map = vim.fn.maparg("te", "n") + assert.is_true(map ~= nil and map ~= "") + end) +end) diff --git a/tests/test_samurai_failed_only_spec.lua b/tests/test_samurai_failed_only_spec.lua new file mode 100644 index 0000000..9d3fdf1 --- /dev/null +++ b/tests/test_samurai_failed_only_spec.lua @@ -0,0 +1,225 @@ +local test_samurai = require("test-samurai") +local core = require("test-samurai.core") + +local function mkbuf(path, ft, lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, path) + vim.bo[bufnr].filetype = ft + if lines then + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + end + return bufnr +end + +local function stub_jobstart(opts_config) + local calls = {} + local orig = vim.fn.jobstart + local idx = 0 + local config = opts_config or {} + vim.fn.jobstart = function(cmd, opts) + idx = idx + 1 + table.insert(calls, { cmd = cmd, opts = opts }) + local code = 0 + if type(config.exit_codes) == "table" then + code = config.exit_codes[idx] or 0 + elseif type(config.exit_codes) == "number" then + code = config.exit_codes + end + local out = config.stdout and config.stdout[idx] or nil + if out and opts and opts.on_stdout then + if type(out) == "string" then + out = { out } + end + opts.on_stdout(1, out, nil) + end + local err = config.stderr and config.stderr[idx] or nil + if err and opts and opts.on_stderr then + if type(err) == "string" then + err = { err } + end + opts.on_stderr(1, err, nil) + end + if opts and opts.on_exit then + opts.on_exit(1, code, nil) + end + return 1 + end + return calls, orig +end + +describe("TSamFailedOnly", function() + before_each(function() + test_samurai.setup() + end) + + it("reruns failed jest tests with --onlyFailures", function() + local json = vim.json.encode({ + testResults = { + { + assertionResults = { + { status = "passed", title = "inner 1", fullName = "outer inner 1" }, + { status = "failed", title = "inner 2", fullName = "outer inner 2" }, + }, + }, + }, + }) + + local calls, orig_jobstart = stub_jobstart({ + exit_codes = { 1, 0 }, + stdout = { { json } }, + }) + + local bufnr = mkbuf("/tmp/project/foo_failed_only.test.ts", "typescript", { + 'describe("outer", function() {', + ' it("inner 1", function() {', + " -- inside 1", + " })", + "", + ' it("inner 2", function() {', + " -- inside 2", + " })", + "})", + }) + + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 7, 0 }) + + core.run_nearest() + core.run_failed_only() + + vim.fn.jobstart = orig_jobstart + + assert.equals(2, #calls) + assert.are.same( + { "npx", "jest", "--json", "/tmp/project/foo_failed_only.test.ts", "-t", "inner 2" }, + calls[1].cmd + ) + assert.are.same( + { "npx", "jest", "--json", "-t", "outer inner 2", "/tmp/project/foo_failed_only.test.ts" }, + calls[2].cmd + ) + end) + + it("falls back to TSamLast when last run had no failures", function() + local json = vim.json.encode({ + testResults = { + { + assertionResults = { + { status = "passed", title = "inner 1", fullName = "outer inner 1" }, + }, + }, + }, + }) + + local calls, orig_jobstart = stub_jobstart({ + exit_codes = { 0, 0 }, + stdout = { { json } }, + }) + + local bufnr = mkbuf("/tmp/project/foo_failed_only_pass.test.ts", "typescript", { + 'describe("outer", function() {', + ' it("inner 1", function() {', + " -- inside 1", + " })", + "", + ' it("inner 2", function() {', + " -- inside 2", + " })", + "})", + }) + + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 7, 0 }) + + core.run_nearest() + core.run_failed_only() + + vim.fn.jobstart = orig_jobstart + + assert.equals(2, #calls) + assert.are.same(calls[1].cmd, calls[2].cmd) + end) + + it("reruns failed go tests with -run regex", function() + local json_lines = { + vim.json.encode({ Action = "fail", Test = "TestFoo/first" }), + vim.json.encode({ Action = "fail", Test = "TestBar" }), + } + + local calls, orig_jobstart = stub_jobstart({ + exit_codes = { 1, 0 }, + stdout = { json_lines }, + }) + + local bufnr = mkbuf("/tmp/project/foo_failed_only_test.go", "go", { + "package main", + "import \"testing\"", + "", + "func TestFoo(t *testing.T) {", + " t.Run(\"first\", func(t *testing.T) {", + " -- inside first", + " })", + "}", + "", + "func TestBar(t *testing.T) {", + " -- inside bar", + "}", + }) + + vim.api.nvim_set_current_buf(bufnr) + + core.run_all() + core.run_failed_only() + + vim.fn.jobstart = orig_jobstart + + assert.equals(2, #calls) + assert.are.same({ "go", "test", "-json", "./..." }, calls[1].cmd) + assert.are.same({ "go", "test", "-json", "./...", "-run", "^(TestFoo/first|TestBar)$" }, calls[2].cmd) + end) + + it("does not affect TSamLast history", function() + local json = vim.json.encode({ + testResults = { + { + assertionResults = { + { status = "failed", title = "inner 2", fullName = "outer inner 2" }, + }, + }, + }, + }) + + local calls, orig_jobstart = stub_jobstart({ + exit_codes = { 1, 1, 1 }, + stdout = { { json } }, + }) + + local bufnr = mkbuf("/tmp/project/foo_failed_only_last.test.ts", "typescript", { + 'describe("outer", function() {', + ' it("inner 1", function() {', + " -- inside 1", + " })", + "", + ' it("inner 2", function() {', + " -- inside 2", + " })", + "})", + }) + + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 7, 0 }) + + core.run_nearest() + core.run_failed_only() + core.run_last() + + vim.fn.jobstart = orig_jobstart + + assert.equals(3, #calls) + assert.are.same(calls[1].cmd, calls[3].cmd) + assert.are.same( + { "npx", "jest", "--json", "-t", "outer inner 2", "/tmp/project/foo_failed_only_last.test.ts" }, + calls[2].cmd + ) + end) +end) diff --git a/tests/test_samurai_go_spec.lua b/tests/test_samurai_go_spec.lua index ef3629c..8e1048a 100644 --- a/tests/test_samurai_go_spec.lua +++ b/tests/test_samurai_go_spec.lua @@ -95,7 +95,7 @@ describe("test-samurai go runner", function() local cmd_spec_sub = go_runner.build_command(spec_sub) assert.are.same( - { "go", "test", "-v", "./pkg", "-run", "^TestFoo/first$" }, + { "go", "test", "-json", "./pkg", "-run", "^TestFoo/first$" }, cmd_spec_sub.cmd ) @@ -108,7 +108,7 @@ describe("test-samurai go runner", function() local cmd_spec_func = go_runner.build_command(spec_func) assert.are.same( - { "go", "test", "-v", "./", "-run", "^TestFoo($|/)" }, + { "go", "test", "-json", "./", "-run", "^TestFoo($|/)" }, cmd_spec_func.cmd ) end) diff --git a/tests/test_samurai_js_spec.lua b/tests/test_samurai_js_spec.lua index a4c4f84..14b5d4c 100644 --- a/tests/test_samurai_js_spec.lua +++ b/tests/test_samurai_js_spec.lua @@ -1,5 +1,6 @@ local jest = require("test-samurai.runners.js-jest") local mocha = require("test-samurai.runners.js-mocha") +local vitest = require("test-samurai.runners.js-vitest") local util = require("test-samurai.util") describe("test-samurai js runner (jest)", function() @@ -51,7 +52,7 @@ describe("test-samurai js runner (jest)", function() assert.is_true(spec.cwd:match("tmp$") ~= nil) local cmd_spec = jest.build_command(spec) - assert.are.same({ "npx", "jest", spec.file, "-t", "inner 2" }, cmd_spec.cmd) + assert.are.same({ "npx", "jest", "--json", spec.file, "-t", "inner 2" }, cmd_spec.cmd) end) it("returns describe block when cursor is between it() calls", function() @@ -133,7 +134,7 @@ describe("test-samurai js runner (mocha)", function() local cmd_spec = mocha.build_command(spec) assert.are.same( - { "npx", "mocha", "--fgrep", "outer inner 2", spec.file }, + { "npx", "mocha", "--reporter", "json-stream", "--fgrep", "outer inner 2", spec.file }, cmd_spec.cmd ) assert.equals("/tmp/project", cmd_spec.cwd) @@ -154,7 +155,47 @@ describe("test-samurai js runner (mocha)", function() util.find_root = orig_find_root assert.are.same( - { "npx", "mocha", "test/**/*.test.js" }, + { "npx", "mocha", "--reporter", "json-stream", "test/**/*.test.js" }, + cmd_spec.cmd + ) + assert.equals("/tmp/project", cmd_spec.cwd) + end) +end) + +describe("test-samurai js runner (vitest)", function() + it("builds vitest command with tap-flat reporter", function() + local spec = { + file = "/tmp/project/test/foo_nearest.test.ts", + cwd = "/tmp/project", + test_name = "inner 2", + full_name = "outer inner 2", + } + + local cmd_spec = vitest.build_command(spec) + + assert.are.same( + { "npx", "vitest", "--reporter", "tap-flat", spec.file, "-t", "inner 2" }, + cmd_spec.cmd + ) + assert.equals("/tmp/project", cmd_spec.cwd) + end) + + it("builds vitest all command with tap-flat reporter", function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/project/test/foo_all.test.ts") + vim.bo[bufnr].filetype = "typescript" + + local orig_find_root = util.find_root + util.find_root = function(path, markers) + return "/tmp/project" + end + + local cmd_spec = vitest.build_all_command(bufnr) + + util.find_root = orig_find_root + + assert.are.same( + { "npx", "vitest", "--reporter", "tap-flat" }, cmd_spec.cmd ) assert.equals("/tmp/project", cmd_spec.cwd) diff --git a/tests/test_samurai_last_spec.lua b/tests/test_samurai_last_spec.lua index 3f5a7f4..334a0d9 100644 --- a/tests/test_samurai_last_spec.lua +++ b/tests/test_samurai_last_spec.lua @@ -49,7 +49,7 @@ describe("TSamLast", function() vim.fn.jobstart = orig_jobstart assert.equals(2, #calls) - assert.are.same({ "go", "test", "-v", "./", "-run", "^TestFoo/first$" }, calls[1].cmd) + assert.are.same({ "go", "test", "-json", "./", "-run", "^TestFoo/first$" }, calls[1].cmd) assert.are.same(calls[1].cmd, calls[2].cmd) assert.equals(calls[1].opts.cwd, calls[2].opts.cwd) end) @@ -79,48 +79,10 @@ describe("TSamLast", function() assert.equals(2, #calls) assert.are.same( - { "npx", "jest", "/tmp/project/foo_last.test.ts", "-t", "inner 2" }, + { "npx", "jest", "--json", "/tmp/project/foo_last.test.ts", "-t", "inner 2" }, calls[1].cmd ) assert.are.same(calls[1].cmd, calls[2].cmd) assert.equals(calls[1].opts.cwd, calls[2].opts.cwd) end) - - it("reruns last Lua command", function() - local calls, orig_jobstart = capture_jobstart() - - local bufnr = mkbuf("/tmp/project/foo_last_spec.lua", "lua", { - "describe('outer', function()", - " it('inner 1', function()", - " local x = 1", - " end)", - "", - " it('inner 2', function()", - " local y = 2", - " end)", - "end)", - }) - - vim.api.nvim_set_current_buf(bufnr) - vim.api.nvim_win_set_cursor(0, { 7, 0 }) - - core.run_nearest() - core.run_last() - - vim.fn.jobstart = orig_jobstart - - assert.equals(2, #calls) - assert.are.same({ - "nvim", - "--headless", - "-u", - "/tmp/project/tests/minimal_init.lua", - "-c", - 'PlenaryBustedFile /tmp/project/foo_last_spec.lua { busted_args = { "--filter", "inner 2" } }', - "-c", - "qa", - }, calls[1].cmd) - assert.are.same(calls[1].cmd, calls[2].cmd) - assert.equals(calls[1].opts.cwd, calls[2].opts.cwd) - end) end) diff --git a/tests/test_samurai_lua_spec.lua b/tests/test_samurai_lua_spec.lua deleted file mode 100644 index 25286d2..0000000 --- a/tests/test_samurai_lua_spec.lua +++ /dev/null @@ -1,130 +0,0 @@ -local test_samurai = require("test-samurai") -local lua_runner = require("test-samurai.runners.lua-plenary") -local util = require("test-samurai.util") - -local function mkbuf(path, ft, lines) - local bufnr = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_name(bufnr, path) - vim.bo[bufnr].filetype = ft - if lines then - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) - end - return bufnr -end - -describe("test-samurai lua runner (plenary)", function() - it("detects lua spec files by suffix", function() - local bufnr = mkbuf("/tmp/test_samurai_lua_spec_unique_1_spec.lua", "lua") - assert.is_true(lua_runner.is_test_file(bufnr)) - end) - - it("finds nearest it() when cursor is inside it block and builds filtered command", function() - local bufnr = mkbuf("/tmp/project/tests/test_samurai_lua_nearest_unique_2_spec.lua", "lua", { - "describe('outer', function()", - " it('inner 1', function()", - " local x = 1", - " end)", - "", - " it('inner 2', function()", - " local y = 2", - " end)", - "end)", - }) - - local orig_find_root = util.find_root - util.find_root = function() - return "/tmp/project" - end - - local spec, err = lua_runner.find_nearest(bufnr, 6, 0) - - util.find_root = orig_find_root - - assert.is_nil(err) - assert.is_not_nil(spec) - assert.equals("inner 2", spec.test_name) - assert.equals("outer inner 2", spec.full_name) - assert.equals("/tmp/project", spec.cwd) - - local cmd_spec = lua_runner.build_command(spec) - assert.equals("/tmp/project", cmd_spec.cwd) - assert.are.same({ - "nvim", - "--headless", - "-u", - "/tmp/project/tests/minimal_init.lua", - "-c", - 'PlenaryBustedFile ' .. spec.file .. ' { busted_args = { "--filter", "inner 2" } }', - "-c", - "qa", - }, cmd_spec.cmd) - end) - - it("returns describe block when cursor is between it() calls", function() - local bufnr = mkbuf("/tmp/project/tests/test_samurai_lua_between_unique_3_spec.lua", "lua", { - "describe('outer', function()", - " it('inner 1', function()", - " local x = 1", - " end)", - "", - " it('inner 2', function()", - " local y = 2", - " end)", - "end)", - }) - - local orig_find_root = util.find_root - util.find_root = function() - return "/tmp/project" - end - - local spec, err = lua_runner.find_nearest(bufnr, 4, 0) - - util.find_root = orig_find_root - - assert.is_nil(err) - assert.is_not_nil(spec) - assert.equals("outer", spec.test_name) - assert.equals("outer", spec.full_name) - assert.equals("describe", spec.kind) - end) - - it("builds all command via PlenaryBustedDirectory", function() - local bufnr = mkbuf("/tmp/project/tests/test_samurai_core_spec_unique_4_spec.lua", "lua") - - local orig_find_root = util.find_root - util.find_root = function() - return "/tmp/project" - end - - local cmd_spec = lua_runner.build_all_command(bufnr) - - util.find_root = orig_find_root - - assert.are.same({ - "nvim", - "--headless", - "-u", - "/tmp/project/tests/minimal_init.lua", - "-c", - "PlenaryBustedDirectory tests", - "-c", - "qa", - }, cmd_spec.cmd) - assert.equals("/tmp/project", cmd_spec.cwd) - end) - - it("core selects lua runner for *_spec.lua buffers", function() - test_samurai.setup({ - runner_modules = { - "test-samurai.runners.lua-plenary", - }, - }) - - local bufnr = mkbuf("/tmp/project/tests/test_samurai_core_spec_unique_5_spec.lua", "lua") - - local runner = require("test-samurai.core").get_runner_for_buf(bufnr) - assert.is_not_nil(runner) - assert.equals("lua-plenary", runner.name) - end) -end) diff --git a/tests/test_samurai_output_spec.lua b/tests/test_samurai_output_spec.lua index d61b48e..abf5e5c 100644 --- a/tests/test_samurai_output_spec.lua +++ b/tests/test_samurai_output_spec.lua @@ -76,4 +76,157 @@ describe("test-samurai public API", function() assert.is_true(called) end) + + it("delegates test_failed_only to core.run_failed_only", function() + local called = false + local orig = core.run_failed_only + + core.run_failed_only = function() + called = true + end + + test_samurai.test_failed_only() + + core.run_failed_only = orig + + assert.is_true(called) + end) +end) + +describe("test-samurai output formatting", function() + before_each(function() + test_samurai.setup() + end) + + it("formats JSON output as PASS/FAIL lines", function() + local json = vim.json.encode({ + testResults = { + { + assertionResults = { + { status = "passed", title = "inner 1", fullName = "outer inner 1" }, + { status = "skipped", title = "inner skip", fullName = "outer inner skip" }, + { status = "failed", title = "inner 2", fullName = "outer inner 2" }, + }, + }, + }, + }) + + local orig_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(_cmd, opts) + if opts and opts.on_stdout then + opts.on_stdout(1, { json }, 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_format.test.ts") + vim.bo[bufnr].filetype = "typescript" + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { + 'describe("outer", function() {', + ' it("inner 1", function() {', + " -- inside 1", + " })", + "", + ' it("inner 2", function() {', + " -- inside 2", + " })", + "})", + }) + + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 7, 0 }) + + core.run_nearest() + + local out_buf = vim.api.nvim_get_current_buf() + local lines = vim.api.nvim_buf_get_lines(out_buf, 0, -1, false) + + vim.fn.jobstart = orig_jobstart + + local has_pass = false + local has_skip = false + local has_fail = false + for _, line in ipairs(lines) do + if line == "[ PASS ] - outer inner 1" then + has_pass = true + elseif line == "[ SKIP ] - outer inner skip" then + has_skip = true + elseif line == "[ FAIL ] - outer inner 2" then + has_fail = true + end + end + assert.is_true(has_pass) + assert.is_true(has_skip) + assert.is_true(has_fail) + end) + + it("formats TAP output as PASS/FAIL lines", function() + test_samurai.setup({ + runner_modules = { + "test-samurai.runners.js-vitest", + }, + }) + + local orig_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(_cmd, opts) + if opts and opts.on_stdout then + opts.on_stdout(1, { + "TAP version 13", + "1..2", + "ok 1 - outer inner 1 # time=1.00ms", + "ok 2 - outer inner skip # SKIP not now", + "not ok 2 - outer inner 2 # time=2.00ms", + }, 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_format.tap.test.ts") + vim.bo[bufnr].filetype = "typescript" + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { + 'describe("outer", function() {', + ' it("inner 1", function() {', + " -- inside 1", + " })", + "", + ' it("inner 2", function() {', + " -- inside 2", + " })", + "})", + }) + + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 7, 0 }) + + core.run_nearest() + + local out_buf = vim.api.nvim_get_current_buf() + local lines = vim.api.nvim_buf_get_lines(out_buf, 0, -1, false) + + vim.fn.jobstart = orig_jobstart + + local has_pass = false + local has_skip = false + local has_fail = false + for _, line in ipairs(lines) do + if line == "[ PASS ] - outer inner 1" then + has_pass = true + elseif line == "[ SKIP ] - outer inner skip" then + has_skip = true + elseif line == "[ FAIL ] - outer inner 2" then + has_fail = true + end + end + assert.is_true(has_pass) + assert.is_true(has_skip) + assert.is_true(has_fail) + end) end)