diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml new file mode 100644 index 0000000..761f55a --- /dev/null +++ b/.gitea/workflows/tests.yml @@ -0,0 +1,28 @@ +name: tests + +on: + push: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Neovim + run: | + sudo apt-get update + sudo apt-get install -y snapd + sudo snap install nvim --classic + + - name: Install plenary.nvim + run: | + mkdir -p "$HOME/.local/share/nvim/site/pack/packer/start" + git clone --depth 1 https://github.com/nvim-lua/plenary.nvim \ + "$HOME/.local/share/nvim/site/pack/packer/start/plenary.nvim" + + - name: Run tests + run: | + nvim --version + bash run_test.sh diff --git a/AGENTS.md b/AGENTS.md index 4edb07d..99e13b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,3 +137,7 @@ return runner - parse_test_output implementiert - collect_failed_locations implementiert - command_spec `{ cmd, cwd }` korrekt zurückgegeben + +## Repository-Hinweis + +- `README.md` bei Änderungen am Runner immer mit aktualisieren. diff --git a/README.md b/README.md index 39204de..3fe27c3 100644 --- a/README.md +++ b/README.md @@ -53,3 +53,10 @@ Main plugin: https://gitea.mschirmer.com/m13r/test-samurai.nvim ## Usage Use the standard `test-samurai.nvim` commands (e.g. `TSamNearest`, `TSamFile`, `TSamAll`, `TSamFailedOnly`). + +## Notes + +- Subtests (`t.Run`) are reported as separate entries via `go test -json -v`. +- Subtest selection uses anchored patterns (`^Parent$/^Sub$`) to avoid matching the same subtest name in other tests. +- `go test` cannot scope execution to a single file; if two tests in the same package share the same test and subtest names, both will run. +- Result lists are ordered so that parent tests appear before their subtests within each status. diff --git a/lua/test-samurai-go-runner/init.lua b/lua/test-samurai-go-runner/init.lua index 99d0d1b..9bb8f68 100644 --- a/lua/test-samurai-go-runner/init.lua +++ b/lua/test-samurai-go-runner/init.lua @@ -3,65 +3,330 @@ local runner = { framework = "go", } -local function is_test_name(name) - return name:match("^Test%w+") or name:match("^Example%w+") or name:match("^Benchmark%w+") +local function get_buf_path(bufnr) + return vim.api.nvim_buf_get_name(bufnr) end -local function escape_regex(text) - if vim and vim.pesc then - return vim.pesc(text) - end - return text:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") +local function get_buf_lines(bufnr) + return vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) end -local function find_go_mod_root(start_dir) - if not start_dir or start_dir == "" then - start_dir = vim.fn.getcwd() - end - local start_path = vim.fn.fnamemodify(start_dir, ":p") - local mod_path = vim.fn.findfile("go.mod", start_path .. ";") - if mod_path == "" then +local function find_root(path, markers) + if not path or path == "" then return nil end - return vim.fn.fnamemodify(mod_path, ":p:h") + local dir = vim.fs.dirname(path) + if not dir or dir == "" then + return nil + end + local found = vim.fs.find(markers, { path = dir, upward = true }) + if not found or not found[1] then + return nil + end + return vim.fs.dirname(found[1]) end -local function collect_results(output) - local passes = {} - local failures = {} - local skips = {} - local seen = { passes = {}, failures = {}, skips = {} } - - for line in output:gmatch("[^\n]+") do - local name = line:match("^%-%-%- PASS:%s+(%S+)") - if name and not seen.passes[name] then - seen.passes[name] = true - passes[#passes + 1] = name +local function find_block_end(lines, start_idx) + local depth = 0 + local started = false + for i = start_idx, #lines do + local line = lines[i] + for j = 1, #line do + local ch = line:sub(j, j) + if ch == "{" then + depth = depth + 1 + started = true + elseif ch == "}" then + if started then + depth = depth - 1 + if depth == 0 then + return i - 1 + end + end + end end + end + return #lines - 1 +end - name = line:match("^%-%-%- FAIL:%s+(%S+)") - if name and not seen.failures[name] then - seen.failures[name] = true - failures[#failures + 1] = name +local function find_test_functions(lines) + local funcs = {} + for i, line in ipairs(lines) do + local name = line:match("^%s*func%s+([%w_]+)%s*%(") + if not name then + name = line:match("^%s*func%s+%([^)]-%)%s+([%w_]+)%s*%(") end + if name and line:find("%*testing%.T") then + local start_0 = i - 1 + local end_0 = find_block_end(lines, i) + table.insert(funcs, { + name = name, + start = start_0, + ["end"] = end_0, + }) + end + end + return funcs +end - name = line:match("^%-%-%- SKIP:%s+(%S+)") - if name and not seen.skips[name] then - seen.skips[name] = true - skips[#skips + 1] = name +local function find_t_runs(lines, func) + local subtests = {} + for i = func.start + 1, func["end"] do + local line = lines[i + 1] + if line then + local name = line:match("t%.Run%(%s*['\"]([^'\"]+)['\"]") + if name then + local start_idx = i + 1 + local end_0 = find_block_end(lines, start_idx) + table.insert(subtests, { + name = name, + start = start_idx - 1, + ["end"] = end_0, + }) + end + end + end + return subtests +end + +local function escape_go_regex(text) + text = text or "" + return (text:gsub("([\\.^$|()%%[%]{}*+?%-])", "\\\\%1")) +end + +local function build_run_pattern(spec) + local name = spec.test_path or spec.test_name or "" + if spec.scope == "function" then + local escaped = escape_go_regex(name) + return "^" .. escaped .. "$" + end + + local parts = vim.split(name, "/", { plain = true }) + local anchored = {} + for _, part in ipairs(parts) do + anchored[#anchored + 1] = "^" .. escape_go_regex(part) .. "$" + end + return table.concat(anchored, "/") +end + +local function build_pkg_arg(spec) + local file = spec.file + local cwd = spec.cwd + if not file or not cwd or file == "" or cwd == "" then + return "./..." + end + + local dir = vim.fs.dirname(file) + if dir == cwd then + return "./" + end + + if file:sub(1, #cwd) ~= cwd then + return "./..." + end + + local rel = dir:sub(#cwd + 2) + if not rel or rel == "" then + return "./" + end + + 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 + +local function order_by_root(names) + local roots = {} + local seen_root = {} + local buckets = {} + + for _, name in ipairs(names) do + local root = name:match("^[^/]+") or name + if not seen_root[root] then + seen_root[root] = true + table.insert(roots, root) + end + buckets[root] = buckets[root] or { main = nil, subs = {} } + if name == root then + buckets[root].main = name + else + table.insert(buckets[root].subs, name) end end - return passes, failures, skips + local ordered = {} + for _, root in ipairs(roots) do + local bucket = buckets[root] + if bucket.main then + table.insert(ordered, bucket.main) + end + for _, sub in ipairs(bucket.subs) do + table.insert(ordered, sub) + end + end + + return ordered +end + +local function order_with_display(names, display_map) + local ordered = order_by_root(names) + local display = {} + for _, name in ipairs(ordered) do + display[#display + 1] = display_map[name] or name + end + return ordered, display +end + +local function display_name(name) + if not name or name == "" then + return name + end + if name:find("/", 1, true) then + return name + end + return name +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 normalize_go_name(name) + if not name or name == "" then + return nil + end + return (name:gsub("%s+", "_")) +end + +local function add_location(target, key, file, line, label) + if not key or key == "" or not file or file == "" or not line then + return + end + local text = label or key + if not target[key] then + target[key] = {} + end + table.insert(target[key], { + filename = file, + lnum = line, + col = 1, + text = text, + }) +end + +local function collect_file_locations(file, target) + local ok, lines = pcall(vim.fn.readfile, file) + if not ok or type(lines) ~= "table" then + return + end + local funcs = find_test_functions(lines) + for _, fn in ipairs(funcs) do + add_location(target, fn.name, file, fn.start + 1, fn.name) + local normalized = normalize_go_name(fn.name) + if normalized and normalized ~= fn.name then + add_location(target, normalized, file, fn.start + 1, fn.name) + end + for _, sub in ipairs(find_t_runs(lines, fn)) do + local full = fn.name .. "/" .. sub.name + add_location(target, full, file, sub.start + 1, full) + local normalized_full = normalize_go_name(full) + if normalized_full and normalized_full ~= full then + add_location(target, normalized_full, file, sub.start + 1, full) + end + end + end +end + +local function collect_go_test_files(root) + if not root or root == "" then + root = vim.loop.cwd() + end + local files = vim.fn.globpath(root, "**/*_test.go", false, true) + if type(files) ~= "table" then + return {} + end + return files end function runner.is_test_file(bufnr) - local name = vim.api.nvim_buf_get_name(bufnr) - return name:sub(-8) == "_test.go" + local path = get_buf_path(bufnr) + if not path or path == "" then + return false + end + return path:sub(-8) == "_test.go" end function runner.find_nearest(bufnr, row, _col) - local line_count = vim.api.nvim_buf_line_count(bufnr) + if not runner.is_test_file(bufnr) then + return nil, "not a Go test file" + end + + local lines = get_buf_lines(bufnr) + local funcs = find_test_functions(lines) + + local current + for _, f in ipairs(funcs) do + if row >= f.start and row <= f["end"] then + current = f + break + end + end + + local path = get_buf_path(bufnr) + local root = find_root(path, { "go.mod", ".git" }) + local cwd = root or vim.fs.dirname(path) + + if current then + local subtests = find_t_runs(lines, current) + local inside_sub + for _, sub in ipairs(subtests) do + if row >= sub.start and row <= sub["end"] then + inside_sub = sub + break + end + end + + if inside_sub then + local full = current.name .. "/" .. inside_sub.name + return { + file = path, + cwd = cwd, + test_path = full, + test_name = full, + scope = "subtest", + func = current.name, + subtest = inside_sub.name, + } + end + + return { + file = path, + cwd = cwd, + test_path = current.name, + test_name = current.name, + scope = "function", + func = current.name, + } + end + + local line_count = #lines local idx = row or (line_count - 1) if idx < 0 then idx = 0 @@ -69,7 +334,6 @@ function runner.find_nearest(bufnr, row, _col) idx = line_count - 1 end - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, line_count, false) for i = idx + 1, 1, -1 do local line = lines[i] local name = line:match("^%s*func%s+(Test%w+)%s*%(") @@ -82,14 +346,13 @@ function runner.find_nearest(bufnr, row, _col) name = line:match("^%s*func%s+(Benchmark%w+)%s*%(") kind = "benchmark" end - if name and is_test_name(name) then - local file = vim.api.nvim_buf_get_name(bufnr) - local cwd = vim.fn.fnamemodify(file, ":p:h") + if name then return { - file = file, + file = path, cwd = cwd, + test_path = name, test_name = name, - full_name = name, + scope = "function", kind = kind, } end @@ -99,31 +362,69 @@ function runner.find_nearest(bufnr, row, _col) end function runner.build_command(spec) - local name = spec.full_name or spec.test_name if spec.kind == "benchmark" then return { - cmd = { "go", "test", "-run", "^$", "-bench", "^" .. escape_regex(name) .. "$" }, + cmd = { "go", "test", "-json", "-v", "-run", "^$", "-bench", "^" .. escape_go_regex(spec.test_path) .. "$" }, cwd = spec.cwd, } end + local pattern = build_run_pattern(spec) + local target = build_pkg_arg(spec) return { - cmd = { "go", "test", "-run", "^" .. escape_regex(name) .. "$" }, + cmd = { "go", "test", "-json", "-v", target, "-run", pattern }, cwd = spec.cwd, } end function runner.build_file_command(bufnr) - local file = vim.api.nvim_buf_get_name(bufnr) - local cwd = vim.fn.fnamemodify(file, ":p:h") - return { cmd = { "go", "test" }, cwd = cwd } + local path = get_buf_path(bufnr) + if not path or path == "" then + return nil + end + local root = find_root(path, { "go.mod", ".git" }) + if not root or root == "" then + root = vim.loop.cwd() + end + local spec = { file = path, cwd = root } + local pkg = build_pkg_arg(spec) + local cmd = { "go", "test", "-json", "-v", pkg } + local lines = get_buf_lines(bufnr) + local funcs = find_test_functions(lines) + local names = {} + for _, fn in ipairs(funcs) do + table.insert(names, fn.name) + end + names = collect_unique(names) + if #names > 0 then + local pattern_parts = {} + for _, name in ipairs(names) do + table.insert(pattern_parts, escape_go_regex(name)) + end + local pattern = "^(" .. table.concat(pattern_parts, "|") .. ")$" + table.insert(cmd, "-run") + table.insert(cmd, pattern) + end + return { + cmd = cmd, + cwd = root, + } end function runner.build_all_command(bufnr) - local file = vim.api.nvim_buf_get_name(bufnr) - local cwd = vim.fn.fnamemodify(file, ":p:h") - local root = find_go_mod_root(cwd) or cwd - return { cmd = { "go", "test", "./..." }, cwd = root } + local path = get_buf_path(bufnr) + local root + if path and path ~= "" then + root = find_root(path, { "go.mod", ".git" }) + end + if not root or root == "" then + root = vim.loop.cwd() + end + local cmd = { "go", "test", "-json", "-v", "./..." } + return { + cmd = cmd, + cwd = root, + } end function runner.build_failed_command(last_command, failures, _scope_kind) @@ -131,67 +432,151 @@ function runner.build_failed_command(last_command, failures, _scope_kind) if last_command and last_command.cmd then return { cmd = last_command.cmd, cwd = last_command.cwd } end - return { cmd = { "go", "test" } } + return { cmd = { "go", "test", "-json", "-v" } } end - local escaped = {} - for _, name in ipairs(failures) do - escaped[#escaped + 1] = escape_regex(name) + local pattern_parts = {} + for _, name in ipairs(failures or {}) do + if name and name ~= "" then + local spec = { test_path = name, scope = name:find("/", 1, true) and "subtest" or "function" } + table.insert(pattern_parts, build_run_pattern(spec)) + end end + local pattern = "(" .. table.concat(pattern_parts, "|") .. ")" + + local cmd = {} + local skip_next = false + for _, arg in ipairs(last_command and last_command.cmd or {}) do + if skip_next then + skip_next = false + elseif arg == "-run" then + skip_next = true + else + table.insert(cmd, arg) + end + end + if #cmd == 0 then + cmd = { "go", "test", "-json", "-v" } + end + table.insert(cmd, "-run") + table.insert(cmd, pattern) - local pattern = "^(" .. table.concat(escaped, "|") .. ")$" return { - cmd = { "go", "test", "-run", pattern }, + cmd = cmd, cwd = last_command and last_command.cwd or nil, } end function runner.parse_results(output) - local passes, failures, skips = collect_results(output) - return { passes = passes, failures = failures, skips = skips } + if not output or output == "" then + return { passes = {}, failures = {}, skips = {}, display = { passes = {}, failures = {}, skips = {} } } + end + local passes = {} + local failures = {} + local skips = {} + local display = { passes = {}, failures = {}, skips = {} } + local pass_display = {} + local fail_display = {} + local skip_display = {} + local seen_pass = {} + local seen_fail = {} + local seen_skip = {} + 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 + if not seen_pass[data.Test] then + seen_pass[data.Test] = true + table.insert(passes, data.Test) + pass_display[data.Test] = display_name(data.Test) + end + elseif data.Action == "fail" then + if not seen_fail[data.Test] then + seen_fail[data.Test] = true + table.insert(failures, data.Test) + fail_display[data.Test] = display_name(data.Test) + end + elseif data.Action == "skip" then + if not seen_skip[data.Test] then + seen_skip[data.Test] = true + table.insert(skips, data.Test) + skip_display[data.Test] = display_name(data.Test) + end + end + end + end + end + passes = collect_unique(passes) + failures = collect_unique(failures) + skips = collect_unique(skips) + + passes, display.passes = order_with_display(passes, pass_display) + failures, display.failures = order_with_display(failures, fail_display) + skips, display.skips = order_with_display(skips, skip_display) + + return { passes = passes, failures = failures, skips = skips, display = display } end function runner.parse_test_output(output) - local results = {} - local current = nil - + local out = {} + if not output or output == "" then + return out + end for line in output:gmatch("[^\n]+") do - local name = line:match("^=== RUN%s+(%S+)") - if name then - current = name - results[current] = results[current] or {} - elseif line:match("^%-%-%- %u+:%s+%S+") then - current = nil - elseif current then - results[current] = results[current] or {} - results[current][#results[current] + 1] = line + 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 results + return out end -function runner.collect_failed_locations(failures, _command, _scope_kind) +function runner.collect_failed_locations(failures, command, scope_kind) + if type(failures) ~= "table" or #failures == 0 then + return {} + end + local files = {} + if scope_kind == "all" then + files = collect_go_test_files(command and command.cwd or nil) + elseif command and command.file then + files = { command.file } + end + if #files == 0 then + return {} + end + local locations = {} + for _, file in ipairs(files) do + collect_file_locations(file, locations) + end local items = {} - if not failures then - return items - end - - for _, failure in ipairs(failures) do - local filename, lnum, col = failure:match("([^:%s]+%.go):(%d+):(%d+)") - if not filename then - filename, lnum = failure:match("([^:%s]+%.go):(%d+)") - end - if filename and lnum then - items[#items + 1] = { - filename = filename, - lnum = tonumber(lnum), - col = tonumber(col) or 1, - text = failure, - } + local seen = {} + local function add_locations(name, locs) + for _, loc in ipairs(locs or {}) do + local key = string.format("%s:%d:%s", loc.filename or "", loc.lnum or 0, loc.text or name or "") + if not seen[key] then + seen[key] = true + table.insert(items, loc) + end + end + end + for _, name in ipairs(failures) do + local direct = locations[name] + if direct then + add_locations(name, direct) + elseif not name:find("/", 1, true) then + for full, locs in pairs(locations) do + if full:sub(-#name - 1) == "/" .. name then + add_locations(full, locs) + end + end end end - return items end diff --git a/run_test.sh b/run_test.sh new file mode 100755 index 0000000..b68cb9d --- /dev/null +++ b/run_test.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +nvim --headless -u tests/minimal_init.lua -c "PlenaryBustedDirectory tests" -c qa diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua new file mode 100644 index 0000000..f1bfe85 --- /dev/null +++ b/tests/minimal_init.lua @@ -0,0 +1,15 @@ +vim.cmd("set rtp^=.") + +local data_path = vim.fn.stdpath("data") +local plenary_paths = { + data_path .. "/site/pack/packer/start/plenary.nvim", + data_path .. "/lazy/plenary.nvim", +} + +for _, path in ipairs(plenary_paths) do + if vim.fn.isdirectory(path) == 1 then + vim.cmd("set rtp^=" .. path) + end +end + +vim.cmd("runtime! plugin/plenary.vim") diff --git a/tests/test_go_runner_spec.lua b/tests/test_go_runner_spec.lua new file mode 100644 index 0000000..ea11c55 --- /dev/null +++ b/tests/test_go_runner_spec.lua @@ -0,0 +1,226 @@ +local runner = require("test-samurai-go-runner") + +describe("test-samurai-go-runner", function() + it("detects Go test files by suffix", function() + local bufnr1 = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr1, "/tmp/go_suffix_test.go") + assert.is_true(runner.is_test_file(bufnr1)) + + local bufnr2 = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr2, "/tmp/go_main.go") + assert.is_false(runner.is_test_file(bufnr2)) + end) + + it("finds subtest when cursor is inside t.Run block", function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/go_subtest_test.go") + local lines = { + "package main", + "import \"testing\"", + "", + "func TestFoo(t *testing.T) {", + " t.Run(\"first\", func(t *testing.T) {", + " -- inside first", + " })", + "", + " t.Run(\"second\", func(t *testing.T) {", + " -- inside second", + " })", + "}", + } + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + + local orig_fs_find = vim.fs.find + vim.fs.find = function(_, _) + return { "/tmp/go.mod" } + end + + local row_inside_first = 5 + local spec, err = runner.find_nearest(bufnr, row_inside_first, 0) + + vim.fs.find = orig_fs_find + + assert.is_nil(err) + assert.is_not_nil(spec) + assert.equals("TestFoo/first", spec.test_path) + assert.equals("subtest", spec.scope) + assert.is_true(spec.file:match("go_subtest_test%.go$") ~= nil) + assert.is_true(spec.cwd:match("/tmp$") ~= nil) + end) + + it("falls back to whole test function when between subtests", function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/go_between_test.go") + local lines = { + "package main", + "import \"testing\"", + "", + "func TestFoo(t *testing.T) {", + " t.Run(\"first\", func(t *testing.T) {", + " -- inside first", + " })", + "", + " t.Run(\"second\", func(t *testing.T) {", + " -- inside second", + " })", + "}", + } + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + + local orig_fs_find = vim.fs.find + vim.fs.find = function(_, _) + return { "/tmp/go.mod" } + end + + local row_between = 7 + local spec, err = runner.find_nearest(bufnr, row_between, 0) + + vim.fs.find = orig_fs_find + + assert.is_nil(err) + assert.is_not_nil(spec) + assert.equals("TestFoo", spec.test_path) + assert.equals("function", spec.scope) + assert.is_true(spec.file:match("go_between_test%.go$") ~= nil) + assert.is_true(spec.cwd:match("/tmp$") ~= nil) + end) + + it("build_command uses current package and correct run pattern", function() + local spec_sub = { + file = "/tmp/project/pkg/foo_test.go", + cwd = "/tmp/project", + test_path = "TestFoo/first", + scope = "subtest", + } + + local cmd_spec_sub = runner.build_command(spec_sub) + assert.are.same( + { "go", "test", "-json", "-v", "./pkg", "-run", "^TestFoo$/^first$" }, + cmd_spec_sub.cmd + ) + + local spec_func = { + file = "/tmp/project/foo_test.go", + cwd = "/tmp/project", + test_path = "TestFoo", + scope = "function", + } + + local cmd_spec_func = runner.build_command(spec_func) + assert.are.same( + { "go", "test", "-json", "-v", "./", "-run", "^TestFoo$" }, + cmd_spec_func.cmd + ) + end) + + it("build_file_command uses exact test names from current file", function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/project/get_test.go") + local lines = { + "package main", + "import \"testing\"", + "", + "func TestHandleGet(t *testing.T) {", + " t.Run(\"returns_200\", func(t *testing.T) {", + " -- inside test", + " })", + "}", + } + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + + local orig_fs_find = vim.fs.find + vim.fs.find = function(_, _) + return { "/tmp/project/go.mod" } + end + + local cmd_spec = runner.build_file_command(bufnr) + + vim.fs.find = orig_fs_find + + assert.are.same( + { "go", "test", "-json", "-v", "./", "-run", "^(TestHandleGet)$" }, + cmd_spec.cmd + ) + assert.equals("/tmp/project", cmd_spec.cwd) + end) + + it("parse_results reports subtests and display names", function() + local output = table.concat({ + vim.json.encode({ Action = "run", Test = "TestFoo" }), + vim.json.encode({ Action = "pass", Test = "TestFoo/first" }), + vim.json.encode({ Action = "fail", Test = "TestFoo/second" }), + vim.json.encode({ Action = "skip", Test = "TestFoo/third" }), + }, "\n") + + local results = runner.parse_results(output) + assert.are.same({ "TestFoo/first" }, results.passes) + assert.are.same({ "TestFoo/second" }, results.failures) + assert.are.same({ "TestFoo/third" }, results.skips) + assert.are.same({ "TestFoo/first" }, results.display.passes) + assert.are.same({ "TestFoo/second" }, results.display.failures) + assert.are.same({ "TestFoo/third" }, results.display.skips) + end) + + it("orders parent tests before subtests within each status", function() + local output = table.concat({ + vim.json.encode({ Action = "fail", Test = "TestHandleGet/returns_200" }), + vim.json.encode({ Action = "fail", Test = "TestHandleGet" }), + vim.json.encode({ Action = "pass", Test = "TestOther/alpha" }), + vim.json.encode({ Action = "pass", Test = "TestOther" }), + }, "\n") + + local results = runner.parse_results(output) + assert.are.same({ "TestHandleGet", "TestHandleGet/returns_200" }, results.failures) + assert.are.same({ "TestOther", "TestOther/alpha" }, results.passes) + assert.are.same({ "TestHandleGet", "TestHandleGet/returns_200" }, results.display.failures) + assert.are.same({ "TestOther", "TestOther/alpha" }, results.display.passes) + end) + + it("parse_test_output groups output per test", function() + local output = table.concat({ + vim.json.encode({ Action = "output", Test = "TestFoo", Output = "line1\n" }), + vim.json.encode({ Action = "output", Test = "TestFoo", Output = "line2\n" }), + vim.json.encode({ Action = "output", Test = "TestFoo/first", Output = "sub1\n" }), + }, "\n") + + local results = runner.parse_test_output(output) + assert.are.same({ "line1", "line2" }, results["TestFoo"]) + assert.are.same({ "sub1" }, results["TestFoo/first"]) + end) + + it("build_failed_command narrows to failed tests", function() + local last_command = { + cmd = { "go", "test", "-json", "-v", "./", "-run", "^TestFoo($|/)" }, + cwd = "/tmp/project", + } + local failures = { "TestFoo/first", "TestBar" } + + local cmd_spec = runner.build_failed_command(last_command, failures, "file") + assert.are.same( + { "go", "test", "-json", "-v", "./", "-run", "(^TestFoo$/^first$|^TestBar$)" }, + cmd_spec.cmd + ) + end) + + it("collect_failed_locations finds subtest positions", function() + local temp_dir = vim.fn.tempname() + vim.fn.mkdir(temp_dir, "p") + local file = temp_dir .. "/sample_test.go" + local lines = { + "package main", + "import \"testing\"", + "", + "func TestFoo(t *testing.T) {", + " t.Run(\"first\", func(t *testing.T) {", + " -- inside test", + " })", + "}", + } + vim.fn.writefile(lines, file) + + local items = runner.collect_failed_locations({ "TestFoo/first" }, { cwd = temp_dir }, "all") + assert.is_true(#items > 0) + assert.equals(file, items[1].filename) + assert.equals(5, items[1].lnum) + end) +end)