create runner with ChatGPT-Codex by using the runner-agents.md from the core
Some checks failed
tests / test (push) Failing after 4s
Some checks failed
tests / test (push) Failing after 4s
This commit is contained in:
503
lua/test-samurai-mocha-runner/init.lua
Normal file
503
lua/test-samurai-mocha-runner/init.lua
Normal file
@@ -0,0 +1,503 @@
|
||||
local runner = {
|
||||
name = "test-samurai-mocha-runner",
|
||||
framework = "javascript",
|
||||
}
|
||||
|
||||
local function read_file(path)
|
||||
local ok, lines = pcall(vim.fn.readfile, path)
|
||||
if not ok then
|
||||
return nil
|
||||
end
|
||||
return table.concat(lines, "\n")
|
||||
end
|
||||
|
||||
local function find_project_root(start_path)
|
||||
if not start_path or start_path == "" then
|
||||
return nil
|
||||
end
|
||||
local dir = vim.fn.fnamemodify(start_path, ":h")
|
||||
local prev = nil
|
||||
while dir and dir ~= prev do
|
||||
local candidate = dir .. "/package.json"
|
||||
if vim.fn.filereadable(candidate) == 1 then
|
||||
return dir
|
||||
end
|
||||
prev = dir
|
||||
dir = vim.fn.fnamemodify(dir, ":h")
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function load_package_json(root)
|
||||
if not root then
|
||||
return nil
|
||||
end
|
||||
local content = read_file(root .. "/package.json")
|
||||
if not content then
|
||||
return nil
|
||||
end
|
||||
local ok, data = pcall(vim.json.decode, content)
|
||||
if not ok then
|
||||
return nil
|
||||
end
|
||||
return data
|
||||
end
|
||||
|
||||
local function has_mocha_dependency(pkg)
|
||||
if not pkg then
|
||||
return false
|
||||
end
|
||||
local deps = pkg.dependencies or {}
|
||||
local dev_deps = pkg.devDependencies or {}
|
||||
return deps.mocha ~= nil or dev_deps.mocha ~= nil
|
||||
end
|
||||
|
||||
local function count_char(line, char)
|
||||
local _, count = line:gsub(char, "")
|
||||
return count
|
||||
end
|
||||
|
||||
local function extract_title(line, names)
|
||||
for _, name in ipairs(names) do
|
||||
local pattern = name .. "%s*%(%s*(['\"])(.-)%1"
|
||||
local _, title = line:match(pattern)
|
||||
if title and title ~= "" then
|
||||
return name, title
|
||||
end
|
||||
end
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
local function build_full_name(suites, title)
|
||||
if #suites == 0 then
|
||||
return title
|
||||
end
|
||||
return table.concat(suites, "/") .. "/" .. title
|
||||
end
|
||||
|
||||
local function build_mocha_title(suites, title)
|
||||
if #suites == 0 then
|
||||
return title
|
||||
end
|
||||
return table.concat(suites, " ") .. " " .. title
|
||||
end
|
||||
|
||||
local function parse_buffer_scope(lines, row)
|
||||
local depth = 0
|
||||
local stack = {}
|
||||
local suite_names = { "describe", "context" }
|
||||
local test_names = { "it", "test" }
|
||||
|
||||
for i = 1, row do
|
||||
local line = lines[i] or ""
|
||||
local kind, title = extract_title(line, suite_names)
|
||||
if title then
|
||||
local opens = count_char(line, "{")
|
||||
local closes = count_char(line, "}")
|
||||
local new_depth = depth + opens - closes
|
||||
table.insert(stack, { kind = "suite", title = title, depth = new_depth, line = i })
|
||||
depth = new_depth
|
||||
else
|
||||
kind, title = extract_title(line, test_names)
|
||||
local opens = count_char(line, "{")
|
||||
local closes = count_char(line, "}")
|
||||
local new_depth = depth + opens - closes
|
||||
if title then
|
||||
table.insert(stack, { kind = "test", title = title, depth = new_depth, line = i })
|
||||
end
|
||||
depth = new_depth
|
||||
end
|
||||
|
||||
while #stack > 0 and stack[#stack].depth > depth do
|
||||
table.remove(stack)
|
||||
end
|
||||
end
|
||||
|
||||
return stack
|
||||
end
|
||||
|
||||
local function suite_titles_from_stack(stack)
|
||||
local suites = {}
|
||||
for _, entry in ipairs(stack) do
|
||||
if entry.kind == "suite" then
|
||||
table.insert(suites, entry.title)
|
||||
end
|
||||
end
|
||||
return suites
|
||||
end
|
||||
|
||||
local function find_last_of_kind(stack, kind)
|
||||
for i = #stack, 1, -1 do
|
||||
if stack[i].kind == kind then
|
||||
return stack[i]
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function build_grep_pattern(title)
|
||||
local pattern = title or ""
|
||||
pattern = pattern:gsub("%s+", ".*")
|
||||
return pattern
|
||||
end
|
||||
|
||||
local function ensure_state(state)
|
||||
state.results = state.results or { passes = {}, failures = {}, skips = {} }
|
||||
state.seen = state.seen or { passes = {}, failures = {}, skips = {} }
|
||||
state.outputs = state.outputs or {}
|
||||
state.locations = state.locations or {}
|
||||
state.suite_stack = state.suite_stack or {}
|
||||
state.mocha_titles = state.mocha_titles or {}
|
||||
return state
|
||||
end
|
||||
|
||||
local function extract_location(stack)
|
||||
if not stack then
|
||||
return nil
|
||||
end
|
||||
local filename, lnum, col = stack:match("%(([^%s%(%):]+):(%d+):(%d+)%)")
|
||||
if not filename then
|
||||
filename, lnum, col = stack:match("at%s+[^%s]+%s+([^%s%(%):]+):(%d+):(%d+)")
|
||||
end
|
||||
if not filename then
|
||||
filename, lnum, col = stack:match("([^%s%(%):]+):(%d+):(%d+)")
|
||||
end
|
||||
if not filename then
|
||||
return nil
|
||||
end
|
||||
return {
|
||||
filename = filename,
|
||||
lnum = tonumber(lnum),
|
||||
col = tonumber(col),
|
||||
}
|
||||
end
|
||||
|
||||
local function record_result(state, event, title, payload)
|
||||
local full_name = build_full_name(state.suite_stack, title)
|
||||
local mocha_title = build_mocha_title(state.suite_stack, title)
|
||||
state.mocha_titles[full_name] = mocha_title
|
||||
local added = false
|
||||
|
||||
if event == "pass" then
|
||||
if not state.seen.passes[full_name] then
|
||||
state.seen.passes[full_name] = true
|
||||
table.insert(state.results.passes, full_name)
|
||||
added = true
|
||||
end
|
||||
elseif event == "fail" then
|
||||
if not state.seen.failures[full_name] then
|
||||
state.seen.failures[full_name] = true
|
||||
table.insert(state.results.failures, full_name)
|
||||
added = true
|
||||
end
|
||||
elseif event == "pending" then
|
||||
if not state.seen.skips[full_name] then
|
||||
state.seen.skips[full_name] = true
|
||||
table.insert(state.results.skips, full_name)
|
||||
added = true
|
||||
end
|
||||
end
|
||||
|
||||
if event == "fail" and payload then
|
||||
local lines = {}
|
||||
local message = nil
|
||||
local stack = nil
|
||||
if type(payload.err) == "table" then
|
||||
message = payload.err.message
|
||||
stack = payload.err.stack
|
||||
elseif type(payload.err) == "string" then
|
||||
message = payload.err
|
||||
end
|
||||
if type(payload.stack) == "string" and payload.stack ~= "" then
|
||||
stack = payload.stack
|
||||
end
|
||||
|
||||
if message and message ~= "" then
|
||||
table.insert(lines, message)
|
||||
end
|
||||
if stack and stack ~= "" then
|
||||
for _, line in ipairs(vim.split(stack, "\n")) do
|
||||
table.insert(lines, line)
|
||||
end
|
||||
end
|
||||
if #lines > 0 then
|
||||
state.outputs[full_name] = lines
|
||||
end
|
||||
|
||||
local location = extract_location(stack or "")
|
||||
if location then
|
||||
location.text = message or "test failure"
|
||||
state.locations[full_name] = location
|
||||
end
|
||||
end
|
||||
|
||||
if added then
|
||||
return full_name
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function parse_json_stream_line(line, state)
|
||||
local ok, payload = pcall(vim.json.decode, line)
|
||||
if not ok or type(payload) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local event = payload.event
|
||||
local data = payload
|
||||
if event == nil and payload[1] then
|
||||
event = payload[1]
|
||||
data = payload[2] or {}
|
||||
end
|
||||
if event == "suite" then
|
||||
if data.title and data.title ~= "" then
|
||||
table.insert(state.suite_stack, data.title)
|
||||
end
|
||||
elseif event == "suite end" then
|
||||
if data.title and data.title ~= "" and #state.suite_stack > 0 then
|
||||
table.remove(state.suite_stack)
|
||||
end
|
||||
elseif event == "pass" or event == "fail" or event == "pending" then
|
||||
local name = record_result(state, event, data.title or "", data)
|
||||
if name then
|
||||
return { event = event, name = name }
|
||||
end
|
||||
end
|
||||
|
||||
return data
|
||||
end
|
||||
|
||||
local function parse_json_stream_output(output)
|
||||
local state = ensure_state({})
|
||||
for line in output:gmatch("[^\n]+") do
|
||||
parse_json_stream_line(line, state)
|
||||
end
|
||||
return state
|
||||
end
|
||||
|
||||
local function project_root_for_buf(bufnr)
|
||||
local file = vim.api.nvim_buf_get_name(bufnr)
|
||||
if file == "" then
|
||||
return nil
|
||||
end
|
||||
return find_project_root(file)
|
||||
end
|
||||
|
||||
function runner.is_test_file(bufnr)
|
||||
local file = vim.api.nvim_buf_get_name(bufnr)
|
||||
if file == "" then
|
||||
return false
|
||||
end
|
||||
local root = find_project_root(file)
|
||||
if not root then
|
||||
return false
|
||||
end
|
||||
local pkg = load_package_json(root)
|
||||
return has_mocha_dependency(pkg)
|
||||
end
|
||||
|
||||
function runner.find_nearest(bufnr, row, _col)
|
||||
local file = vim.api.nvim_buf_get_name(bufnr)
|
||||
if file == "" then
|
||||
return nil, "buffer has no name"
|
||||
end
|
||||
local root = find_project_root(file)
|
||||
if not root then
|
||||
return nil, "no package.json found"
|
||||
end
|
||||
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||
local stack = parse_buffer_scope(lines, row)
|
||||
local suites = suite_titles_from_stack(stack)
|
||||
|
||||
local test_block = find_last_of_kind(stack, "test")
|
||||
if test_block then
|
||||
local full_name = build_full_name(suites, test_block.title)
|
||||
return {
|
||||
file = file,
|
||||
cwd = root,
|
||||
test_name = test_block.title,
|
||||
full_name = full_name,
|
||||
mocha_full_title = build_mocha_title(suites, test_block.title),
|
||||
kind = "test",
|
||||
}
|
||||
end
|
||||
|
||||
local suite_block = find_last_of_kind(stack, "suite")
|
||||
if suite_block then
|
||||
local full_name = table.concat(suites, "/")
|
||||
return {
|
||||
file = file,
|
||||
cwd = root,
|
||||
test_name = suite_block.title,
|
||||
full_name = full_name,
|
||||
mocha_full_title = table.concat(suites, " "),
|
||||
kind = "suite",
|
||||
}
|
||||
end
|
||||
|
||||
return {
|
||||
file = file,
|
||||
cwd = root,
|
||||
kind = "file",
|
||||
}
|
||||
end
|
||||
|
||||
function runner.build_command(spec)
|
||||
if not spec.cwd or spec.cwd == "" then
|
||||
return { cmd = { "echo", "no package.json found" } }
|
||||
end
|
||||
if spec.kind == "file" then
|
||||
return runner.build_file_command(spec.file)
|
||||
end
|
||||
|
||||
local grep = spec.mocha_full_title or spec.full_name or spec.test_name
|
||||
return {
|
||||
cmd = {
|
||||
"npx",
|
||||
"mocha",
|
||||
"--reporter",
|
||||
"json-stream",
|
||||
"--grep",
|
||||
build_grep_pattern(grep),
|
||||
spec.file,
|
||||
},
|
||||
cwd = spec.cwd,
|
||||
}
|
||||
end
|
||||
|
||||
function runner.build_file_command(bufnr_or_file)
|
||||
local file = bufnr_or_file
|
||||
if type(bufnr_or_file) == "number" then
|
||||
file = vim.api.nvim_buf_get_name(bufnr_or_file)
|
||||
end
|
||||
local cwd = file and find_project_root(file) or nil
|
||||
if not cwd then
|
||||
return { cmd = { "echo", "no package.json found" } }
|
||||
end
|
||||
return {
|
||||
cmd = {
|
||||
"npx",
|
||||
"mocha",
|
||||
"--reporter",
|
||||
"json-stream",
|
||||
file,
|
||||
},
|
||||
cwd = cwd,
|
||||
}
|
||||
end
|
||||
|
||||
function runner.build_all_command(bufnr)
|
||||
local cwd = project_root_for_buf(bufnr)
|
||||
if not cwd then
|
||||
return { cmd = { "echo", "no package.json found" } }
|
||||
end
|
||||
return {
|
||||
cmd = {
|
||||
"npx",
|
||||
"mocha",
|
||||
"--reporter",
|
||||
"json-stream",
|
||||
"test/**/*.{test,spec}.{t,j}s",
|
||||
},
|
||||
cwd = cwd,
|
||||
}
|
||||
end
|
||||
|
||||
function runner.build_failed_command(last_command, failures, _scope_kind)
|
||||
local cwd = last_command and last_command.cwd or nil
|
||||
if not failures or #failures == 0 then
|
||||
return {
|
||||
cmd = {
|
||||
"npx",
|
||||
"mocha",
|
||||
"--reporter",
|
||||
"json-stream",
|
||||
},
|
||||
cwd = cwd,
|
||||
}
|
||||
end
|
||||
|
||||
local patterns = {}
|
||||
for _, failure in ipairs(failures) do
|
||||
local mocha_title = runner._last_mocha_titles and runner._last_mocha_titles[failure]
|
||||
if not mocha_title then
|
||||
mocha_title = failure:gsub("/", " ")
|
||||
end
|
||||
table.insert(patterns, build_grep_pattern(mocha_title))
|
||||
end
|
||||
|
||||
return {
|
||||
cmd = {
|
||||
"npx",
|
||||
"mocha",
|
||||
"--reporter",
|
||||
"json-stream",
|
||||
"--grep",
|
||||
table.concat(patterns, "|"),
|
||||
},
|
||||
cwd = cwd,
|
||||
}
|
||||
end
|
||||
|
||||
function runner.parse_results(output)
|
||||
local state = parse_json_stream_output(output)
|
||||
return state.results
|
||||
end
|
||||
|
||||
function runner.output_parser()
|
||||
return {
|
||||
on_line = function(line, state)
|
||||
state = ensure_state(state or {})
|
||||
local info = parse_json_stream_line(line, state)
|
||||
runner._last_locations = state.locations
|
||||
runner._last_mocha_titles = state.mocha_titles
|
||||
if not info or not info.event or not info.name then
|
||||
return nil
|
||||
end
|
||||
local results = { passes = {}, failures = {}, skips = {} }
|
||||
if info.event == "pass" then
|
||||
results.passes = { info.name }
|
||||
elseif info.event == "fail" then
|
||||
results.failures = { info.name }
|
||||
results.failures_all = vim.deepcopy(state.results.failures)
|
||||
elseif info.event == "pending" then
|
||||
results.skips = { info.name }
|
||||
end
|
||||
return results
|
||||
end,
|
||||
on_complete = function(output, state)
|
||||
state = ensure_state(state or {})
|
||||
local parsed = parse_json_stream_output(output)
|
||||
runner._last_locations = parsed.locations
|
||||
runner._last_mocha_titles = parsed.mocha_titles
|
||||
return nil
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
function runner.parse_test_output(output)
|
||||
local state = parse_json_stream_output(output)
|
||||
return state.outputs
|
||||
end
|
||||
|
||||
function runner.collect_failed_locations(failures, _command, _scope_kind)
|
||||
local items = {}
|
||||
if not failures or not runner._last_locations then
|
||||
return items
|
||||
end
|
||||
|
||||
for _, name in ipairs(failures) do
|
||||
local location = runner._last_locations[name]
|
||||
if location then
|
||||
table.insert(items, {
|
||||
filename = location.filename,
|
||||
lnum = location.lnum,
|
||||
col = location.col,
|
||||
text = location.text or name,
|
||||
})
|
||||
end
|
||||
end
|
||||
return items
|
||||
end
|
||||
|
||||
return runner
|
||||
Reference in New Issue
Block a user