finish first MVP with test-runner-detection for Go, Mocha.js, Jest.js and ViTest.js

This commit is contained in:
2025-12-23 22:10:47 +01:00
parent 4de8921a42
commit e92c8476c2
8 changed files with 593 additions and 89 deletions

View File

@@ -1,4 +1,5 @@
local config = require("test-samurai.config")
local util = require("test-samurai.util")
local M = {}
@@ -30,19 +31,105 @@ 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
return runner
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 open_float(lines)
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)
end
@@ -58,7 +145,7 @@ local function open_float(lines)
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_option(buf, "bufhidden", "wipe")
vim.api.nvim_buf_set_option(buf, "filetype", "test-samurai-output")
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, initial_lines or {})
local win = vim.api.nvim_open_win(buf, true, {
relative = "editor",
@@ -78,42 +165,59 @@ local function open_float(lines)
state.last_win = win
state.last_buf = buf
return buf, win
end
local function run_cmd(cmd, cwd, on_exit)
if vim.system then
vim.system(cmd, { cwd = cwd, text = true }, function(obj)
local code = obj.code or -1
local stdout = obj.stdout or ""
local stderr = obj.stderr or ""
vim.schedule(function()
on_exit(code, stdout, stderr)
end)
end)
else
local stdout_chunks = {}
local stderr_chunks = {}
vim.fn.jobstart(cmd, {
cwd = cwd,
stdout_buffered = true,
stderr_buffered = true,
on_stdout = function(_, data)
if data then
table.insert(stdout_chunks, table.concat(data, "\n"))
end
end,
on_stderr = function(_, data)
if data then
table.insert(stderr_chunks, table.concat(data, "\n"))
end
end,
on_exit = function(_, code)
local stdout = table.concat(stdout_chunks, "\n")
local stderr = table.concat(stderr_chunks, "\n")
on_exit(code, stdout, stderr)
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)
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
function M.run_nearest()
@@ -153,23 +257,53 @@ function M.run_nearest()
local cmd = command.cmd
local cwd = command.cwd or vim.loop.cwd()
run_cmd(cmd, cwd, function(code, stdout, stderr)
local header = "$ " .. table.concat(cmd, " ")
local lines = { header, "" }
if stdout ~= "" then
local out_lines = vim.split(stdout, "\n", { plain = true })
vim.list_extend(lines, out_lines)
end
if stderr ~= "" then
table.insert(lines, "")
table.insert(lines, "[stderr]")
local err_lines = vim.split(stderr, "\n", { plain = true })
vim.list_extend(lines, err_lines)
end
table.insert(lines, "")
table.insert(lines, "[exit code] " .. tostring(code))
open_float(lines)
end)
local header = "$ " .. table.concat(cmd, " ")
local buf = nil
local has_output = false
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, {})
end
has_output = true
end
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, {})
end
has_output = true
end
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, {})
end
end
append_lines(buf, { "", "[exit code] " .. tostring(code) })
end,
})
end
return M

View File

@@ -67,9 +67,38 @@ local function find_t_runs(lines, func)
return subtests
end
local function escape_pattern(str)
local escaped = str:gsub("(%W)", "%%%1")
return "^" .. escaped .. "$"
local function build_run_pattern(spec)
local name = spec.test_path or ""
local escaped = name:gsub("(%W)", "%%%1")
if spec.scope == "function" then
return "^" .. escaped .. "$|^" .. escaped .. "/"
else
return "^" .. escaped .. "$"
end
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
function runner.is_test_file(bufnr)
@@ -134,8 +163,9 @@ function runner.find_nearest(bufnr, row, _col)
end
function runner.build_command(spec)
local pattern = escape_pattern(spec.test_path)
local cmd = { "go", "test", "./...", "-run", pattern }
local pattern = build_run_pattern(spec)
local pkg = build_pkg_arg(spec)
local cmd = { "go", "test", "-v", pkg, "-run", pattern }
return {
cmd = cmd,
cwd = spec.cwd,

View File

@@ -28,8 +28,168 @@ local function is_js_test_file(bufnr, filetypes, patterns)
return false
end
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
local jest_config_files = { "jest.config.js", "jest.config.ts" }
local function find_jest_root(file)
if not file or file == "" then
return util.find_root(file, {
"jest.config.js",
"jest.config.ts",
"vitest.config.ts",
"vitest.config.js",
"package.json",
"node_modules",
})
end
local dir = vim.fs.dirname(file)
local found = vim.fs.find(jest_config_files, { path = dir, upward = true })
if found and #found > 0 then
local cfg = found[1]
local sep = package.config:sub(1, 1)
local marker = sep .. "test" .. sep .. ".bin" .. sep
local idx = cfg:find(marker, 1, true)
if idx then
local root = cfg:sub(1, idx - 1)
if root == "" then
root = sep
end
return root
end
return vim.fs.dirname(cfg)
end
return util.find_root(file, {
"jest.config.js",
"jest.config.ts",
"vitest.config.ts",
"vitest.config.js",
"package.json",
"node_modules",
})
end
local function match_test_call(line)
local call, name = line:match("^%s*(it)%s*%(%s*['\"`]([^'\"`]+)['\"`]")
if call then
return call, name
end
call, name = line:match("^%s*(test)%s*%(%s*['\"`]([^'\"`]+)['\"`]")
if call then
return call, name
end
call, name = line:match("^%s*(describe)%s*%(%s*['\"`]([^'\"`]+)['\"`]")
if call and name then
return call, name
end
return nil, nil
end
local function collect_js_structs(lines)
local describes = {}
local tests = {}
for i, line in ipairs(lines) do
local dcall, dname = line:match("^%s*(describe)%s*%(%s*['\"`]([^'\"`]+)['\"`]")
if dcall and dname then
local start0 = i - 1
local end0 = find_block_end(lines, i)
table.insert(describes, {
kind = dcall,
name = dname,
start = start0,
["end"] = end0,
})
else
local tcall, tname = line:match("^%s*(it)%s*%(%s*['\"`]([^'\"`]+)['\"`]")
if not tcall then
tcall, tname = line:match("^%s*(test)%s*%(%s*['\"`]([^'\"`]+)['\"`]")
end
if tcall and tname then
local start0 = i - 1
local end0 = find_block_end(lines, i)
table.insert(tests, {
kind = tcall,
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 call, name = line:match("^%s*(describe)%s*%(%s*['\"`]([^'\"`]+)['\"`]")
if call and name then
table.insert(parts, 1, name)
end
end
return table.concat(parts, " ")
end
local function find_nearest_test(bufnr, row)
local lines = util.get_buf_lines(bufnr)
local describes, tests = collect_js_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)
return {
kind = t.kind,
name = t.name,
full_name = full,
line = t.start,
}
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)
return {
kind = "describe",
name = best_describe.name,
full_name = full,
line = best_describe.start,
}
end
local start = row + 1
if start > #lines then
start = #lines
@@ -38,15 +198,18 @@ local function find_nearest_test(bufnr, row)
end
for i = start, 1, -1 do
local line = lines[i]
local call, name = line:match("^%s*(it|test|describe)%s*%(%s*['"`]([^'"`]+)['"`]")
local call, name = match_test_call(line)
if call and name then
local full = build_full_name(lines, i, name)
return {
kind = call,
name = name,
full_name = full,
line = i - 1,
}
end
end
return nil
end
@@ -82,19 +245,25 @@ function M.new(opts)
return nil, "no test call found"
end
local path = util.get_buf_path(bufnr)
local root = util.find_root(path, {
"jest.config.js",
"jest.config.ts",
"vitest.config.ts",
"vitest.config.js",
"package.json",
"node_modules",
})
local root
if runner.framework == "jest" then
root = find_jest_root(path)
else
root = util.find_root(path, {
"jest.config.js",
"jest.config.ts",
"vitest.config.ts",
"vitest.config.js",
"package.json",
"node_modules",
})
end
return {
file = path,
cwd = root,
framework = runner.framework,
test_name = hit.name,
full_name = hit.full_name,
kind = hit.kind,
}
end
@@ -109,9 +278,10 @@ function M.new(opts)
local function build_mocha(spec)
local cmd = vim.deepcopy(runner.command)
local target = spec.full_name or spec.test_name
table.insert(cmd, "--fgrep")
table.insert(cmd, target)
table.insert(cmd, spec.file)
table.insert(cmd, "--grep")
table.insert(cmd, spec.test_name)
return cmd
end