create runner with ChatGPT-Codex by using the AGENTS.md
Some checks failed
tests / test (push) Failing after 4s
Some checks failed
tests / test (push) Failing after 4s
This commit is contained in:
660
lua/test-samurai-jest-runner/init.lua
Normal file
660
lua/test-samurai-jest-runner/init.lua
Normal file
@@ -0,0 +1,660 @@
|
||||
local runner = {
|
||||
name = "jest",
|
||||
framework = "javascript",
|
||||
}
|
||||
|
||||
local RESULT_PREFIX = "TSAMURAI_RESULT "
|
||||
local STATUS_MAP = {
|
||||
passed = "passes",
|
||||
failed = "failures",
|
||||
skipped = "skips",
|
||||
pending = "skips",
|
||||
todo = "skips",
|
||||
}
|
||||
|
||||
runner._last_locations = {}
|
||||
runner._last_jest_names = {}
|
||||
|
||||
local function get_buf_path(bufnr)
|
||||
return vim.api.nvim_buf_get_name(bufnr)
|
||||
end
|
||||
|
||||
local function get_buf_lines(bufnr)
|
||||
return vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||
end
|
||||
|
||||
local function find_root(path, markers)
|
||||
if not path or path == "" then
|
||||
return nil
|
||||
end
|
||||
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 count_char(line, ch)
|
||||
local count = 0
|
||||
for i = 1, #line do
|
||||
if line:sub(i, i) == ch then
|
||||
count = count + 1
|
||||
end
|
||||
end
|
||||
return count
|
||||
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
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return start_idx
|
||||
end
|
||||
|
||||
local function match_call_name(lines, idx, keywords)
|
||||
local line = lines[idx] or ""
|
||||
for _, key in ipairs(keywords) do
|
||||
local pattern = "%f[%w_]" .. key .. "[%w_%.]*%s*%(%s*['\"]([^'\"]+)['\"]"
|
||||
local name = line:match(pattern)
|
||||
if name and name ~= "" then
|
||||
return name
|
||||
end
|
||||
local has_call = line:match("%f[%w_]" .. key .. "[%w_%.]*%s*%(")
|
||||
if has_call then
|
||||
local max_idx = math.min(#lines, idx + 3)
|
||||
for j = idx + 1, max_idx do
|
||||
local next_line = lines[j] or ""
|
||||
local next_name = next_line:match("['\"]([^'\"]+)['\"]")
|
||||
if next_name and next_name ~= "" then
|
||||
return next_name
|
||||
end
|
||||
if next_line:find("%)") then
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function escape_regex(text)
|
||||
text = text or ""
|
||||
return (text:gsub("([\\.^$|()%%[%]{}*+?%-])", "\\\\%1"))
|
||||
end
|
||||
|
||||
local function build_jest_pattern(parts)
|
||||
local escaped = {}
|
||||
for _, part in ipairs(parts) do
|
||||
table.insert(escaped, escape_regex(part))
|
||||
end
|
||||
return "^.*" .. escape_regex(parts[#parts] or "") .. "$"
|
||||
end
|
||||
|
||||
local function build_jest_prefix_pattern(parts)
|
||||
local tokens = {}
|
||||
for _, part in ipairs(parts or {}) do
|
||||
for token in part:gmatch("%w+") do
|
||||
table.insert(tokens, token)
|
||||
end
|
||||
end
|
||||
if #tokens == 0 then
|
||||
return escape_regex(parts[#parts] or "") .. ".*"
|
||||
end
|
||||
return table.concat(tokens, ".*") .. ".*"
|
||||
end
|
||||
|
||||
local function to_jest_full_name(name)
|
||||
if not name or name == "" then
|
||||
return name
|
||||
end
|
||||
if not name:find("/", 1, true) then
|
||||
return name
|
||||
end
|
||||
local parts = vim.split(name, "/", { plain = true, trimempty = true })
|
||||
return table.concat(parts, " ")
|
||||
end
|
||||
|
||||
local function find_tests(lines)
|
||||
local tests = {}
|
||||
local describes = {}
|
||||
for i, _line in ipairs(lines) do
|
||||
local describe_name = match_call_name(lines, i, { "describe", "context" })
|
||||
if describe_name then
|
||||
local start_idx = i
|
||||
local end_idx = find_block_end(lines, start_idx)
|
||||
table.insert(describes, {
|
||||
name = describe_name,
|
||||
start = start_idx - 1,
|
||||
["end"] = end_idx - 1,
|
||||
})
|
||||
end
|
||||
|
||||
local test_name = match_call_name(lines, i, { "test", "it" })
|
||||
if test_name then
|
||||
local start_idx = i
|
||||
local end_idx = find_block_end(lines, start_idx)
|
||||
table.insert(tests, {
|
||||
name = test_name,
|
||||
start = start_idx - 1,
|
||||
["end"] = end_idx - 1,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
local function describe_chain(at_start)
|
||||
local parents = {}
|
||||
for _, describe in ipairs(describes) do
|
||||
if at_start >= describe.start and at_start <= describe["end"] then
|
||||
table.insert(parents, describe)
|
||||
end
|
||||
end
|
||||
table.sort(parents, function(a, b)
|
||||
if a.start == b.start then
|
||||
return a["end"] < b["end"]
|
||||
end
|
||||
return a.start < b.start
|
||||
end)
|
||||
return parents
|
||||
end
|
||||
|
||||
for _, test in ipairs(tests) do
|
||||
local parents = describe_chain(test.start)
|
||||
local parts = {}
|
||||
for _, parent in ipairs(parents) do
|
||||
table.insert(parts, parent.name)
|
||||
end
|
||||
table.insert(parts, test.name)
|
||||
test.full_name = table.concat(parts, "/")
|
||||
test.jest_name = table.concat(parts, " ")
|
||||
test.jest_parts = parts
|
||||
end
|
||||
|
||||
for _, describe in ipairs(describes) do
|
||||
local parents = describe_chain(describe.start)
|
||||
local parts = {}
|
||||
for _, parent in ipairs(parents) do
|
||||
table.insert(parts, parent.name)
|
||||
end
|
||||
describe.full_name = table.concat(parts, "/")
|
||||
describe.jest_name = table.concat(parts, " ")
|
||||
describe.jest_parts = parts
|
||||
end
|
||||
|
||||
return tests, describes
|
||||
end
|
||||
|
||||
local function test_file_path(path)
|
||||
if not path or path == "" then
|
||||
return false
|
||||
end
|
||||
return path:match("%.test%.[jt]sx?$") ~= nil
|
||||
or path:match("%.spec%.[jt]sx?$") ~= nil
|
||||
or path:match("%.test%.mjs$") ~= nil
|
||||
or path:match("%.spec%.mjs$") ~= nil
|
||||
or path:match("%.test%.cjs$") ~= nil
|
||||
or path:match("%.spec%.cjs$") ~= nil
|
||||
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
|
||||
|
||||
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 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 reporter_path()
|
||||
local source = debug.getinfo(1, "S").source
|
||||
if source:sub(1, 1) == "@" then
|
||||
source = source:sub(2)
|
||||
end
|
||||
local dir = vim.fs.dirname(source)
|
||||
return vim.fs.normalize(dir .. "/../../reporter/test_samurai_jest_reporter.js")
|
||||
end
|
||||
|
||||
local function base_cmd()
|
||||
return {
|
||||
"npx",
|
||||
"jest",
|
||||
"--testLocationInResults",
|
||||
"--reporters",
|
||||
reporter_path(),
|
||||
}
|
||||
end
|
||||
|
||||
local function parse_result_line(line)
|
||||
if not line or line == "" then
|
||||
return nil
|
||||
end
|
||||
if line:sub(1, #RESULT_PREFIX) ~= RESULT_PREFIX then
|
||||
return nil
|
||||
end
|
||||
local payload = line:sub(#RESULT_PREFIX + 1)
|
||||
local ok, data = pcall(vim.json.decode, payload)
|
||||
if not ok or type(data) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
return data
|
||||
end
|
||||
|
||||
local function update_location_cache(name, data)
|
||||
if not name or name == "" then
|
||||
return
|
||||
end
|
||||
local location = data.location
|
||||
if type(location) ~= "table" or not data.file then
|
||||
return
|
||||
end
|
||||
runner._last_locations[name] = {
|
||||
filename = data.file,
|
||||
lnum = location.line or 1,
|
||||
col = location.column or 1,
|
||||
text = name,
|
||||
}
|
||||
if data.jestName and data.jestName ~= "" then
|
||||
runner._last_jest_names[name] = data.jestName
|
||||
end
|
||||
end
|
||||
|
||||
function runner.is_test_file(bufnr)
|
||||
local path = get_buf_path(bufnr)
|
||||
return test_file_path(path)
|
||||
end
|
||||
|
||||
function runner.find_nearest(bufnr, row, _col)
|
||||
local path = get_buf_path(bufnr)
|
||||
if not path or path == "" then
|
||||
return nil, "no file name"
|
||||
end
|
||||
if not test_file_path(path) then
|
||||
return nil, "not a jest test file"
|
||||
end
|
||||
local lines = get_buf_lines(bufnr)
|
||||
local tests, describes = find_tests(lines)
|
||||
local nearest = nil
|
||||
for _, test in ipairs(tests) do
|
||||
if row >= test.start and row <= test["end"] then
|
||||
nearest = test
|
||||
end
|
||||
end
|
||||
if not nearest then
|
||||
local describe_match = nil
|
||||
for _, describe in ipairs(describes) do
|
||||
if row >= describe.start and row <= describe["end"] then
|
||||
if not describe_match or describe.start >= describe_match.start then
|
||||
describe_match = describe
|
||||
end
|
||||
end
|
||||
end
|
||||
if describe_match then
|
||||
local cwd = find_root(path, {
|
||||
"package.json",
|
||||
"jest.config.js",
|
||||
"jest.config.ts",
|
||||
"jest.config.cjs",
|
||||
"jest.config.mjs",
|
||||
"jest.config.json",
|
||||
})
|
||||
return {
|
||||
file = path,
|
||||
cwd = cwd,
|
||||
test_name = describe_match.name,
|
||||
full_name = describe_match.full_name,
|
||||
jest_name = describe_match.jest_name,
|
||||
jest_parts = describe_match.jest_parts,
|
||||
kind = "describe",
|
||||
}
|
||||
end
|
||||
local cwd = find_root(path, {
|
||||
"package.json",
|
||||
"jest.config.js",
|
||||
"jest.config.ts",
|
||||
"jest.config.cjs",
|
||||
"jest.config.mjs",
|
||||
"jest.config.json",
|
||||
})
|
||||
return {
|
||||
file = path,
|
||||
cwd = cwd,
|
||||
kind = "file",
|
||||
}
|
||||
end
|
||||
local cwd = find_root(path, {
|
||||
"package.json",
|
||||
"jest.config.js",
|
||||
"jest.config.ts",
|
||||
"jest.config.cjs",
|
||||
"jest.config.mjs",
|
||||
"jest.config.json",
|
||||
})
|
||||
return {
|
||||
file = path,
|
||||
cwd = cwd,
|
||||
test_name = nearest.name,
|
||||
full_name = nearest.full_name,
|
||||
jest_name = nearest.jest_name,
|
||||
jest_parts = nearest.jest_parts,
|
||||
kind = "test",
|
||||
}
|
||||
end
|
||||
|
||||
function runner.build_command(spec)
|
||||
local file = spec.file
|
||||
if not file or file == "" then
|
||||
return { cmd = base_cmd(), cwd = spec.cwd }
|
||||
end
|
||||
if spec.kind == "file" then
|
||||
local cmd = base_cmd()
|
||||
table.insert(cmd, "--runTestsByPath")
|
||||
table.insert(cmd, file)
|
||||
return { cmd = cmd, cwd = spec.cwd }
|
||||
end
|
||||
local cmd = base_cmd()
|
||||
table.insert(cmd, "--runTestsByPath")
|
||||
table.insert(cmd, file)
|
||||
local ok, pattern = pcall(function()
|
||||
if type(spec.jest_parts) == "table" and #spec.jest_parts > 0 then
|
||||
if spec.kind == "describe" then
|
||||
return build_jest_prefix_pattern(spec.jest_parts)
|
||||
end
|
||||
return build_jest_pattern(spec.jest_parts)
|
||||
end
|
||||
local name = spec.jest_name or spec.test_name
|
||||
if name and name ~= "" then
|
||||
return "^" .. escape_regex(name) .. "$"
|
||||
end
|
||||
return nil
|
||||
end)
|
||||
if not ok then
|
||||
pattern = nil
|
||||
end
|
||||
if pattern then
|
||||
table.insert(cmd, "--testNamePattern")
|
||||
table.insert(cmd, pattern)
|
||||
end
|
||||
return { cmd = cmd, cwd = spec.cwd }
|
||||
end
|
||||
|
||||
function runner.build_file_command(bufnr)
|
||||
local path = get_buf_path(bufnr)
|
||||
local cwd = find_root(path, {
|
||||
"package.json",
|
||||
"jest.config.js",
|
||||
"jest.config.ts",
|
||||
"jest.config.cjs",
|
||||
"jest.config.mjs",
|
||||
"jest.config.json",
|
||||
})
|
||||
local cmd = base_cmd()
|
||||
table.insert(cmd, "--runTestsByPath")
|
||||
table.insert(cmd, path)
|
||||
return { cmd = cmd, cwd = cwd }
|
||||
end
|
||||
|
||||
function runner.build_all_command(bufnr)
|
||||
local path = get_buf_path(bufnr)
|
||||
local cwd = find_root(path, {
|
||||
"package.json",
|
||||
"jest.config.js",
|
||||
"jest.config.ts",
|
||||
"jest.config.cjs",
|
||||
"jest.config.mjs",
|
||||
"jest.config.json",
|
||||
})
|
||||
local cmd = base_cmd()
|
||||
return { cmd = cmd, cwd = cwd }
|
||||
end
|
||||
|
||||
function runner.build_failed_command(last_command, failures, _scope_kind)
|
||||
if not failures or #failures == 0 then
|
||||
if last_command and last_command.cmd then
|
||||
return { cmd = last_command.cmd, cwd = last_command.cwd }
|
||||
end
|
||||
return { cmd = base_cmd() }
|
||||
end
|
||||
|
||||
local pattern_parts = {}
|
||||
for _, name in ipairs(failures or {}) do
|
||||
if name and name ~= "" then
|
||||
local jest_name = runner._last_jest_names[name] or to_jest_full_name(name)
|
||||
table.insert(pattern_parts, "^" .. escape_regex(jest_name) .. "$")
|
||||
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 == "--testNamePattern" then
|
||||
skip_next = true
|
||||
else
|
||||
table.insert(cmd, arg)
|
||||
end
|
||||
end
|
||||
if #cmd == 0 then
|
||||
cmd = base_cmd()
|
||||
end
|
||||
table.insert(cmd, "--testNamePattern")
|
||||
table.insert(cmd, pattern)
|
||||
|
||||
return {
|
||||
cmd = cmd,
|
||||
cwd = last_command and last_command.cwd or nil,
|
||||
}
|
||||
end
|
||||
|
||||
function runner.parse_results(output)
|
||||
runner._last_locations = {}
|
||||
runner._last_jest_names = {}
|
||||
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 = {
|
||||
passes = {},
|
||||
failures = {},
|
||||
skips = {},
|
||||
}
|
||||
for line in output:gmatch("[^\n]+") do
|
||||
local data = parse_result_line(line)
|
||||
if data and data.name and data.status then
|
||||
local kind = STATUS_MAP[data.status]
|
||||
if kind and not seen[kind][data.name] then
|
||||
seen[kind][data.name] = true
|
||||
table.insert(kind == "passes" and passes or kind == "failures" and failures or skips, data.name)
|
||||
if kind == "passes" then
|
||||
pass_display[data.name] = data.display or data.name
|
||||
elseif kind == "failures" then
|
||||
fail_display[data.name] = data.display or data.name
|
||||
else
|
||||
skip_display[data.name] = data.display or data.name
|
||||
end
|
||||
update_location_cache(data.name, data)
|
||||
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.output_parser()
|
||||
runner._last_locations = {}
|
||||
runner._last_jest_names = {}
|
||||
return {
|
||||
on_line = function(line, state)
|
||||
local data = parse_result_line(line)
|
||||
if not data or not data.name or not data.status then
|
||||
return nil
|
||||
end
|
||||
local kind = STATUS_MAP[data.status]
|
||||
if not kind then
|
||||
return nil
|
||||
end
|
||||
state.jest = state.jest or { failures_all = {}, failures_seen = {} }
|
||||
local results = {
|
||||
passes = {},
|
||||
failures = {},
|
||||
skips = {},
|
||||
display = { passes = {}, failures = {}, skips = {} },
|
||||
}
|
||||
if kind == "passes" then
|
||||
results.passes = { data.name }
|
||||
results.display.passes = { data.display or data.name }
|
||||
elseif kind == "failures" then
|
||||
results.failures = { data.name }
|
||||
results.display.failures = { data.display or data.name }
|
||||
if not state.jest.failures_seen[data.name] then
|
||||
state.jest.failures_seen[data.name] = true
|
||||
table.insert(state.jest.failures_all, data.name)
|
||||
end
|
||||
results.failures_all = vim.deepcopy(state.jest.failures_all)
|
||||
else
|
||||
results.skips = { data.name }
|
||||
results.display.skips = { data.display or data.name }
|
||||
end
|
||||
update_location_cache(data.name, data)
|
||||
return results
|
||||
end,
|
||||
on_complete = function(output, state)
|
||||
return nil
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
function runner.parse_test_output(output)
|
||||
local out = {}
|
||||
if not output or output == "" then
|
||||
return out
|
||||
end
|
||||
for line in output:gmatch("[^\n]+") do
|
||||
local data = parse_result_line(line)
|
||||
if data and data.name and data.output then
|
||||
out[data.name] = out[data.name] or {}
|
||||
if type(data.output) == "string" then
|
||||
for _, item in ipairs(split_output_lines(data.output)) do
|
||||
table.insert(out[data.name], item)
|
||||
end
|
||||
elseif type(data.output) == "table" then
|
||||
for _, item in ipairs(data.output) do
|
||||
if item and item ~= "" then
|
||||
table.insert(out[data.name], item)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function runner.collect_failed_locations(failures, _command, _scope_kind)
|
||||
if type(failures) ~= "table" or #failures == 0 then
|
||||
return {}
|
||||
end
|
||||
local items = {}
|
||||
local seen = {}
|
||||
for _, name in ipairs(failures) do
|
||||
local loc = runner._last_locations[name]
|
||||
if loc then
|
||||
local key = string.format("%s:%d:%d:%s", loc.filename or "", loc.lnum or 0, loc.col or 0, name)
|
||||
if not seen[key] then
|
||||
seen[key] = true
|
||||
table.insert(items, loc)
|
||||
end
|
||||
end
|
||||
end
|
||||
return items
|
||||
end
|
||||
|
||||
return runner
|
||||
Reference in New Issue
Block a user