1242 lines
35 KiB
Lua
1242 lines
35 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 build_full_name
|
||
local shorten_js_name
|
||
local normalize_js_name
|
||
|
||
local function add_location(target, name, file, line)
|
||
if not name or name == "" or not file or file == "" or not line then
|
||
return
|
||
end
|
||
if not target[name] then
|
||
target[name] = {}
|
||
end
|
||
table.insert(target[name], {
|
||
filename = file,
|
||
lnum = line,
|
||
col = 1,
|
||
text = name,
|
||
})
|
||
end
|
||
|
||
local function collect_js_locations(lines, file, target)
|
||
local describes, tests = collect_js_structs(lines)
|
||
for _, t in ipairs(tests) do
|
||
local full = build_full_name(lines, t.start + 1, t.name)
|
||
add_location(target, full, file, t.start + 1)
|
||
add_location(target, t.name, file, t.start + 1)
|
||
local short = shorten_js_name and shorten_js_name(full) or nil
|
||
if short and short ~= full then
|
||
add_location(target, short, file, t.start + 1)
|
||
end
|
||
end
|
||
for _, d in ipairs(describes) do
|
||
local full = build_full_name(lines, d.start + 1, d.name)
|
||
add_location(target, full, file, d.start + 1)
|
||
add_location(target, d.name, file, d.start + 1)
|
||
local short = shorten_js_name and shorten_js_name(full) or nil
|
||
if short and short ~= full then
|
||
add_location(target, short, file, d.start + 1)
|
||
end
|
||
end
|
||
end
|
||
|
||
local function collect_js_test_files(root, patterns)
|
||
if not root or root == "" then
|
||
root = vim.loop.cwd()
|
||
end
|
||
local files = {}
|
||
local seen = {}
|
||
for _, pat in ipairs(patterns or {}) do
|
||
local glob = "**/*" .. pat .. "*"
|
||
local hits = vim.fn.globpath(root, glob, false, true)
|
||
if type(hits) == "table" then
|
||
for _, file in ipairs(hits) do
|
||
if not seen[file] then
|
||
seen[file] = true
|
||
table.insert(files, file)
|
||
end
|
||
end
|
||
end
|
||
end
|
||
return files
|
||
end
|
||
|
||
build_full_name = function(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_results(data)
|
||
if type(data) ~= "table" then
|
||
return nil
|
||
end
|
||
local passes = {}
|
||
local failures = {}
|
||
local skips = {}
|
||
local display = { passes = {}, failures = {}, skips = {} }
|
||
for _, result in ipairs(data.testResults or {}) do
|
||
for _, assertion in ipairs(result.assertionResults or {}) do
|
||
local full = assertion.fullName or assertion.title
|
||
local short = assertion.title or assertion.fullName
|
||
if assertion.status == "passed" and full then
|
||
table.insert(passes, full)
|
||
if short then
|
||
table.insert(display.passes, short)
|
||
end
|
||
elseif assertion.status == "failed" and full then
|
||
table.insert(failures, full)
|
||
if short then
|
||
table.insert(display.failures, short)
|
||
end
|
||
elseif (assertion.status == "pending" or assertion.status == "skipped" or assertion.status == "todo")
|
||
and full then
|
||
table.insert(skips, full)
|
||
if short then
|
||
table.insert(display.skips, short)
|
||
end
|
||
end
|
||
end
|
||
end
|
||
return { passes = passes, failures = failures, skips = skips, display = display }
|
||
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 append_output(out, name, text)
|
||
if not name or name == "" or not text or text == "" then
|
||
return
|
||
end
|
||
if not out[name] then
|
||
out[name] = {}
|
||
end
|
||
for _, line in ipairs(split_output_lines(text)) do
|
||
table.insert(out[name], line)
|
||
end
|
||
end
|
||
|
||
local function decode_json_from_output(output)
|
||
local ok, data = pcall(vim.json.decode, output)
|
||
if ok and type(data) == "table" then
|
||
return data
|
||
end
|
||
if not output or output == "" then
|
||
return nil
|
||
end
|
||
local start_idx = output:find("{", 1, true)
|
||
if not start_idx then
|
||
return nil
|
||
end
|
||
local end_idx = nil
|
||
for i = #output, start_idx, -1 do
|
||
if output:sub(i, i) == "}" then
|
||
end_idx = i
|
||
break
|
||
end
|
||
end
|
||
if not end_idx then
|
||
return nil
|
||
end
|
||
local candidate = output:sub(start_idx, end_idx)
|
||
local ok2, data2 = pcall(vim.json.decode, candidate)
|
||
if ok2 and type(data2) == "table" then
|
||
return data2
|
||
end
|
||
return nil
|
||
end
|
||
|
||
local function collect_jest_failure_output(data)
|
||
local out = {}
|
||
for _, result in ipairs(data.testResults or {}) do
|
||
for _, assertion in ipairs(result.assertionResults or {}) do
|
||
if assertion.status == "failed" then
|
||
local name = assertion.fullName or assertion.title
|
||
for _, msg in ipairs(assertion.failureMessages or {}) do
|
||
append_output(out, name, msg)
|
||
end
|
||
end
|
||
end
|
||
end
|
||
return out
|
||
end
|
||
|
||
local function collect_mocha_failure_output(output)
|
||
local out = {}
|
||
local ok, data = pcall(vim.json.decode, output)
|
||
if ok and type(data) == "table" then
|
||
for _, failure in ipairs(data.failures or {}) do
|
||
local name = failure.fullTitle or failure.title
|
||
local err = failure.err or failure
|
||
local message = nil
|
||
if type(err) == "table" then
|
||
message = err.stack or err.message
|
||
elseif type(err) == "string" then
|
||
message = err
|
||
end
|
||
append_output(out, name, message)
|
||
end
|
||
return out
|
||
end
|
||
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
|
||
if event == "fail" then
|
||
local name = payload.fullTitle or payload.title
|
||
local err = payload.err or payload
|
||
local message = nil
|
||
if type(err) == "table" then
|
||
message = err.stack or err.message
|
||
elseif type(err) == "string" then
|
||
message = err
|
||
end
|
||
append_output(out, name, message)
|
||
end
|
||
end
|
||
end
|
||
return out
|
||
end
|
||
|
||
local function parse_jest_like(output)
|
||
local ok, data = pcall(vim.json.decode, output)
|
||
if ok and type(data) == "table" then
|
||
return parse_jest_results(data)
|
||
end
|
||
if not output or output == "" then
|
||
return nil
|
||
end
|
||
local start_idx = output:find("{", 1, true)
|
||
if not start_idx then
|
||
return nil
|
||
end
|
||
local end_idx = nil
|
||
for i = #output, start_idx, -1 do
|
||
if output:sub(i, i) == "}" then
|
||
end_idx = i
|
||
break
|
||
end
|
||
end
|
||
if not end_idx then
|
||
return nil
|
||
end
|
||
local candidate = output:sub(start_idx, end_idx)
|
||
local ok2, data2 = pcall(vim.json.decode, candidate)
|
||
if not ok2 or type(data2) ~= "table" then
|
||
return nil
|
||
end
|
||
return parse_jest_results(data2)
|
||
end
|
||
|
||
local jest_symbols = {
|
||
pass = string.char(0xE2, 0x9C, 0x93),
|
||
fail = string.char(0xE2, 0x9C, 0x95),
|
||
skip = string.char(0xE2, 0x97, 0x8B),
|
||
}
|
||
|
||
normalize_js_name = function(name)
|
||
if not name or name == "" then
|
||
return nil
|
||
end
|
||
local out = name
|
||
out = out:gsub("%s*[%>›»]%s*", " ")
|
||
out = out:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
|
||
return out
|
||
end
|
||
|
||
shorten_js_name = function(name)
|
||
if not name or name == "" then
|
||
return nil
|
||
end
|
||
local last = name:match(".*>%s*([^>]+)$")
|
||
if last then
|
||
return last:gsub("^%s+", ""):gsub("%s+$", "")
|
||
end
|
||
last = name:match(".*›%s*([^›]+)$")
|
||
if last then
|
||
return last:gsub("^%s+", ""):gsub("%s+$", "")
|
||
end
|
||
last = name:match(".*»%s*([^»]+)$")
|
||
if last then
|
||
return last:gsub("^%s+", ""):gsub("%s+$", "")
|
||
end
|
||
return name
|
||
end
|
||
|
||
local function trim_jest_verbose_name(name)
|
||
if not name then
|
||
return nil
|
||
end
|
||
name = name:gsub("%s+%(%d+%.?%d*%s*ms%)%s*$", "")
|
||
name = name:gsub("%s+%(%d+%.?%d*%s*sec%)%s*$", "")
|
||
name = name:gsub("%s+%b()%s*$", "")
|
||
name = name:gsub("%s+$", "")
|
||
return name
|
||
end
|
||
|
||
local function parse_jest_verbose_line(line)
|
||
if not line or line == "" then
|
||
return nil, nil
|
||
end
|
||
local name = line:match("^%s*" .. jest_symbols.pass .. "%s+(.+)$")
|
||
if name then
|
||
return "pass", trim_jest_verbose_name(name)
|
||
end
|
||
name = line:match("^%s*" .. jest_symbols.fail .. "%s+(.+)$")
|
||
if name then
|
||
return "fail", trim_jest_verbose_name(name)
|
||
end
|
||
name = line:match("^%s*" .. jest_symbols.skip .. "%s+(.+)$")
|
||
if name then
|
||
return "skip", trim_jest_verbose_name(name)
|
||
end
|
||
return nil, nil
|
||
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 = {}
|
||
local display = { passes = {}, failures = {}, 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 full = payload.fullTitle or payload.title
|
||
local short = payload.title or payload.fullTitle
|
||
if event == "pass" and full then
|
||
table.insert(passes, full)
|
||
if short then
|
||
table.insert(display.passes, short)
|
||
end
|
||
elseif event == "fail" and full then
|
||
table.insert(failures, full)
|
||
if short then
|
||
table.insert(display.failures, short)
|
||
end
|
||
elseif event == "pending" and full then
|
||
table.insert(skips, full)
|
||
if short then
|
||
table.insert(display.skips, short)
|
||
end
|
||
end
|
||
end
|
||
end
|
||
if #passes == 0 and #failures == 0 and #skips == 0 then
|
||
return nil
|
||
end
|
||
return { passes = passes, failures = failures, skips = skips, display = display }
|
||
end
|
||
local passes = {}
|
||
local failures = {}
|
||
local skips = {}
|
||
local display = { passes = {}, failures = {}, skips = {} }
|
||
if type(data.tests) == "table" then
|
||
for _, test in ipairs(data.tests) do
|
||
local full = test.fullTitle or test.title
|
||
local short = test.title or test.fullTitle
|
||
if test.state == "passed" and full then
|
||
table.insert(passes, full)
|
||
if short then
|
||
table.insert(display.passes, short)
|
||
end
|
||
elseif test.state == "failed" and full then
|
||
table.insert(failures, full)
|
||
if short then
|
||
table.insert(display.failures, short)
|
||
end
|
||
elseif test.state == "pending" and full then
|
||
table.insert(skips, full)
|
||
if short then
|
||
table.insert(display.skips, short)
|
||
end
|
||
end
|
||
end
|
||
elseif type(data.passes) == "table" or type(data.failures) == "table" then
|
||
for _, test in ipairs(data.passes or {}) do
|
||
local full = test.fullTitle or test.title
|
||
local short = test.title or test.fullTitle
|
||
if full then
|
||
table.insert(passes, full)
|
||
if short then
|
||
table.insert(display.passes, short)
|
||
end
|
||
end
|
||
end
|
||
for _, test in ipairs(data.failures or {}) do
|
||
local full = test.fullTitle or test.title
|
||
local short = test.title or test.fullTitle
|
||
if full then
|
||
table.insert(failures, full)
|
||
if short then
|
||
table.insert(display.failures, short)
|
||
end
|
||
end
|
||
end
|
||
for _, test in ipairs(data.pending or {}) do
|
||
local full = test.fullTitle or test.title
|
||
local short = test.title or test.fullTitle
|
||
if full then
|
||
table.insert(skips, full)
|
||
if short then
|
||
table.insert(display.skips, short)
|
||
end
|
||
end
|
||
end
|
||
end
|
||
return { passes = passes, failures = failures, skips = skips, display = display }
|
||
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.parse_test_output(output)
|
||
if runner.framework == "mocha" then
|
||
return collect_mocha_failure_output(output)
|
||
end
|
||
local data = decode_json_from_output(output)
|
||
if not data then
|
||
return {}
|
||
end
|
||
return collect_jest_failure_output(data)
|
||
end
|
||
|
||
function runner.output_parser()
|
||
local state = { raw = {}, done = false, saw_stream = false }
|
||
local failures = {}
|
||
local skips = {}
|
||
local jest_seen_pass = {}
|
||
local jest_seen_fail = {}
|
||
local jest_seen_skip = {}
|
||
local jest_json_collecting = false
|
||
local jest_json_buffer = {}
|
||
local jest_streamed = false
|
||
|
||
local function reset_list(dst, src)
|
||
for i = #dst, 1, -1 do
|
||
dst[i] = nil
|
||
end
|
||
for _, item in ipairs(src or {}) do
|
||
if item and item ~= "" then
|
||
table.insert(dst, item)
|
||
end
|
||
end
|
||
end
|
||
|
||
local function emit_jest_results(results, emit_output)
|
||
if not results then
|
||
return nil
|
||
end
|
||
local display = results.display or {}
|
||
if emit_output == false then
|
||
reset_list(failures, results.failures or {})
|
||
return {
|
||
passes = {},
|
||
failures = {},
|
||
skips = {},
|
||
failures_all = vim.deepcopy(failures),
|
||
}
|
||
end
|
||
local out = {
|
||
passes = {},
|
||
failures = {},
|
||
skips = {},
|
||
failures_all = {},
|
||
display = { passes = {}, failures = {}, skips = {} },
|
||
}
|
||
for i, title in ipairs(results.passes or {}) do
|
||
if title and not jest_seen_pass[title] then
|
||
jest_seen_pass[title] = true
|
||
table.insert(out.passes, title)
|
||
local show = display.passes and display.passes[i] or title
|
||
table.insert(out.display.passes, show)
|
||
end
|
||
end
|
||
for i, title in ipairs(results.failures or {}) do
|
||
if title and not jest_seen_fail[title] then
|
||
jest_seen_fail[title] = true
|
||
table.insert(failures, title)
|
||
table.insert(out.failures, title)
|
||
local show = display.failures and display.failures[i] or title
|
||
table.insert(out.display.failures, show)
|
||
end
|
||
end
|
||
for i, title in ipairs(results.skips or {}) do
|
||
if title and not jest_seen_skip[title] then
|
||
jest_seen_skip[title] = true
|
||
table.insert(out.skips, title)
|
||
local show = display.skips and display.skips[i] or title
|
||
table.insert(out.display.skips, show)
|
||
end
|
||
end
|
||
out.failures_all = vim.deepcopy(failures)
|
||
if #out.passes == 0 and #out.failures == 0 and #out.skips == 0 then
|
||
return nil
|
||
end
|
||
return out
|
||
end
|
||
|
||
local function feed_jest_json(line)
|
||
if not jest_json_collecting then
|
||
if not line:match("^%s*{") then
|
||
return nil
|
||
end
|
||
jest_json_collecting = true
|
||
jest_json_buffer = { line }
|
||
else
|
||
table.insert(jest_json_buffer, line)
|
||
end
|
||
local candidate = table.concat(jest_json_buffer, "\n")
|
||
local ok_buf, data_buf = pcall(vim.json.decode, candidate)
|
||
if ok_buf and type(data_buf) == "table" then
|
||
jest_json_collecting = false
|
||
jest_json_buffer = {}
|
||
return data_buf
|
||
end
|
||
return nil
|
||
end
|
||
|
||
local function handle_jest_json(data)
|
||
local results = parse_jest_results(data)
|
||
if not results then
|
||
return nil
|
||
end
|
||
state.done = true
|
||
state.saw_stream = true
|
||
return emit_jest_results(results, not jest_streamed)
|
||
end
|
||
return {
|
||
on_line = function(line, _state)
|
||
if state.done then
|
||
return nil
|
||
end
|
||
if runner.framework == "jest" then
|
||
local kind, name = parse_jest_verbose_line(line)
|
||
if kind and name then
|
||
jest_streamed = true
|
||
local results = { passes = {}, failures = {}, skips = {} }
|
||
if kind == "pass" then
|
||
results.passes = { name }
|
||
elseif kind == "fail" then
|
||
results.failures = { name }
|
||
elseif kind == "skip" then
|
||
results.skips = { name }
|
||
end
|
||
results.display = {
|
||
passes = kind == "pass" and { name } or {},
|
||
failures = kind == "fail" and { name } or {},
|
||
skips = kind == "skip" and { name } or {},
|
||
}
|
||
return emit_jest_results(results, true)
|
||
end
|
||
|
||
local data = feed_jest_json(line)
|
||
if data then
|
||
return handle_jest_json(data)
|
||
end
|
||
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 = {},
|
||
display = {
|
||
passes = { payload.title or 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 = {},
|
||
display = {
|
||
passes = {},
|
||
failures = { payload.title or 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 },
|
||
display = {
|
||
passes = {},
|
||
failures = {},
|
||
skips = { payload.title or 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)
|
||
state.saw_stream = true
|
||
return {
|
||
passes = {},
|
||
failures = {},
|
||
skips = { skip_title },
|
||
display = {
|
||
passes = {},
|
||
failures = {},
|
||
skips = { shorten_js_name(skip_title) or skip_title },
|
||
},
|
||
failures_all = vim.deepcopy(failures),
|
||
}
|
||
end
|
||
ok_title = ok_title:gsub("%s+#%s+time=.*$", "")
|
||
state.saw_stream = true
|
||
return {
|
||
passes = { ok_title },
|
||
failures = {},
|
||
skips = {},
|
||
display = {
|
||
passes = { shorten_js_name(ok_title) or 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=.*$", "")
|
||
local display_name = shorten_js_name(fail_title)
|
||
table.insert(failures, fail_title)
|
||
state.saw_stream = true
|
||
return {
|
||
passes = {},
|
||
failures = { fail_title },
|
||
skips = {},
|
||
display = {
|
||
passes = {},
|
||
failures = { display_name or 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 runner.framework == "jest" then
|
||
if state.done then
|
||
return nil
|
||
end
|
||
local results = parse_jest_like(output)
|
||
if results then
|
||
state.done = true
|
||
state.saw_stream = true
|
||
if _state and _state.scope_kind == "all" and jest_streamed then
|
||
return emit_jest_results(results, false)
|
||
end
|
||
return emit_jest_results(results, not jest_streamed)
|
||
end
|
||
return nil
|
||
end
|
||
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
|
||
|
||
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_js_test_files(command and command.cwd or nil, runner.patterns)
|
||
else
|
||
local file = command and command.file or nil
|
||
if not file and command and type(command.cmd) == "table" then
|
||
file = find_test_file_arg(command.cmd)
|
||
end
|
||
if file then
|
||
files = { file }
|
||
end
|
||
end
|
||
if #files == 0 then
|
||
return {}
|
||
end
|
||
local locations = {}
|
||
for _, file in ipairs(files) do
|
||
local ok, lines = pcall(vim.fn.readfile, file)
|
||
if ok and type(lines) == "table" then
|
||
collect_js_locations(lines, file, locations)
|
||
end
|
||
end
|
||
local items = {}
|
||
local seen = {}
|
||
for _, name in ipairs(failures) do
|
||
local keys = { name }
|
||
if normalize_js_name then
|
||
local normalized = normalize_js_name(name)
|
||
if normalized and normalized ~= name then
|
||
table.insert(keys, normalized)
|
||
end
|
||
end
|
||
if shorten_js_name then
|
||
local short = shorten_js_name(name)
|
||
if short and short ~= name then
|
||
table.insert(keys, short)
|
||
end
|
||
end
|
||
for _, key_name in ipairs(keys) do
|
||
for _, loc in ipairs(locations[key_name] or {}) do
|
||
local key = string.format("%s:%d", loc.filename or "", loc.lnum or 0)
|
||
if not seen[key] then
|
||
seen[key] = true
|
||
table.insert(items, loc)
|
||
end
|
||
end
|
||
end
|
||
end
|
||
return items
|
||
end
|
||
|
||
return runner
|
||
end
|
||
|
||
return M
|