691 lines
18 KiB
Lua
691 lines
18 KiB
Lua
local util = require("test-samurai.util")
|
|
|
|
local M = {}
|
|
|
|
local default_filetypes = {
|
|
javascript = true,
|
|
javascriptreact = true,
|
|
typescript = true,
|
|
typescriptreact = true,
|
|
}
|
|
|
|
local default_patterns = { ".test.", ".spec." }
|
|
|
|
local function is_js_test_file(bufnr, filetypes, patterns)
|
|
local ft = vim.bo[bufnr].filetype
|
|
if not filetypes[ft] then
|
|
return false
|
|
end
|
|
local path = util.get_buf_path(bufnr)
|
|
if not path or path == "" then
|
|
return false
|
|
end
|
|
for _, pat in ipairs(patterns) do
|
|
if path:find(pat, 1, true) then
|
|
return true
|
|
end
|
|
end
|
|
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
|
|
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
|
|
elseif start < 1 then
|
|
start = 1
|
|
end
|
|
for i = start, 1, -1 do
|
|
local line = lines[i]
|
|
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
|
|
|
|
function M.new(opts)
|
|
local cfg = opts or {}
|
|
local runner = {}
|
|
|
|
runner.name = cfg.name or "js"
|
|
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
|
|
for _, ft in ipairs(cfg.filetypes) do
|
|
runner.filetypes[ft] = true
|
|
end
|
|
else
|
|
runner.filetypes = vim.deepcopy(default_filetypes)
|
|
end
|
|
|
|
runner.patterns = cfg.patterns or default_patterns
|
|
|
|
function runner.is_test_file(bufnr)
|
|
return is_js_test_file(bufnr, runner.filetypes, runner.patterns)
|
|
end
|
|
|
|
function runner.find_nearest(bufnr, row, _col)
|
|
if not runner.is_test_file(bufnr) then
|
|
return nil, "not a JS/TS test file"
|
|
end
|
|
local hit = find_nearest_test(bufnr, row)
|
|
if not hit then
|
|
return nil, "no test call found"
|
|
end
|
|
local path = util.get_buf_path(bufnr)
|
|
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
|
|
|
|
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)
|
|
return cmd
|
|
end
|
|
|
|
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)
|
|
table.insert(cmd, spec.file)
|
|
return cmd
|
|
end
|
|
|
|
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)
|
|
return cmd
|
|
end
|
|
|
|
function runner.build_command(spec)
|
|
local fw = runner.framework
|
|
local cmd
|
|
if fw == "jest" then
|
|
cmd = build_jest(spec)
|
|
elseif fw == "mocha" then
|
|
cmd = build_mocha(spec)
|
|
elseif fw == "vitest" then
|
|
cmd = build_vitest(spec)
|
|
else
|
|
cmd = vim.deepcopy(runner.command)
|
|
table.insert(cmd, spec.file)
|
|
end
|
|
return {
|
|
cmd = cmd,
|
|
cwd = spec.cwd,
|
|
}
|
|
end
|
|
|
|
function runner.build_file_command(bufnr)
|
|
local path = util.get_buf_path(bufnr)
|
|
if not path or path == "" then
|
|
return nil
|
|
end
|
|
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
|
|
local cmd = vim.deepcopy(runner.command)
|
|
append_args(cmd, runner.json_args)
|
|
table.insert(cmd, path)
|
|
return {
|
|
cmd = cmd,
|
|
cwd = root,
|
|
}
|
|
end
|
|
|
|
function runner.build_all_command(bufnr)
|
|
local path = util.get_buf_path(bufnr)
|
|
local root
|
|
if path and path ~= "" then
|
|
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
|
|
end
|
|
if not root or root == "" then
|
|
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
|
|
return {
|
|
cmd = cmd,
|
|
cwd = root,
|
|
}
|
|
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
|
|
local passes = {}
|
|
local failures = {}
|
|
local skips = {}
|
|
for line in (output or ""):gmatch("[^\n]+") do
|
|
local ok_line, entry = pcall(vim.json.decode, line)
|
|
if ok_line and type(entry) == "table" then
|
|
local event = entry.event or entry[1] or entry["1"]
|
|
local payload = entry
|
|
if not entry.event then
|
|
payload = entry[2] or entry["2"] or {}
|
|
end
|
|
local title = payload.fullTitle or payload.title
|
|
if event == "pass" and title then
|
|
table.insert(passes, title)
|
|
elseif event == "fail" and title then
|
|
table.insert(failures, title)
|
|
elseif event == "pending" and title then
|
|
table.insert(skips, title)
|
|
end
|
|
end
|
|
end
|
|
if #passes == 0 and #failures == 0 and #skips == 0 then
|
|
return nil
|
|
end
|
|
return { passes = passes, failures = failures, skips = skips }
|
|
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, saw_stream = 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" then
|
|
local event = data.event
|
|
local payload = data
|
|
if not event then
|
|
event = data[1] or data["1"]
|
|
payload = data[2] or data["2"] or {}
|
|
end
|
|
if event == "pass" and payload.fullTitle then
|
|
state.saw_stream = true
|
|
return {
|
|
passes = { payload.fullTitle },
|
|
failures = {},
|
|
skips = {},
|
|
failures_all = vim.deepcopy(failures),
|
|
}
|
|
elseif event == "fail" and payload.fullTitle then
|
|
state.saw_stream = true
|
|
table.insert(failures, payload.fullTitle)
|
|
return {
|
|
passes = {},
|
|
failures = { payload.fullTitle },
|
|
skips = {},
|
|
failures_all = vim.deepcopy(failures),
|
|
}
|
|
elseif event == "pending" and payload.fullTitle then
|
|
state.saw_stream = true
|
|
table.insert(skips, payload.fullTitle)
|
|
return {
|
|
passes = {},
|
|
failures = {},
|
|
skips = { payload.fullTitle },
|
|
failures_all = vim.deepcopy(failures),
|
|
}
|
|
elseif event == "start" or event == "end" then
|
|
state.saw_stream = true
|
|
return {
|
|
passes = {},
|
|
failures = {},
|
|
skips = {},
|
|
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 or state.saw_stream 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
|
|
if #failures == 1 then
|
|
table.insert(cmd, "--fgrep")
|
|
table.insert(cmd, failures[1])
|
|
else
|
|
table.insert(cmd, "--grep")
|
|
table.insert(cmd, pattern)
|
|
end
|
|
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
|
|
|
|
return M
|