fix TSamNearest and fully support jump Keymap
All checks were successful
tests / test (push) Successful in 10s

This commit is contained in:
2026-01-05 08:58:48 +01:00
parent 7218ed0c4c
commit 29ddc34add
5 changed files with 736 additions and 169 deletions

View File

@@ -3,6 +3,24 @@ local runner = {
framework = "javascript",
}
local function runner_root()
local source = debug.getinfo(1, "S").source
if source:sub(1, 1) == "@" then
source = source:sub(2)
end
return vim.fn.fnamemodify(source, ":p:h:h:h")
end
local function runner_paths()
local root = runner_root()
return {
ui = root .. "/scripts/bdd-with-location.cjs",
reporter = root .. "/scripts/mocha-ndjson-reporter.cjs",
}
end
local RUNNER_PATHS = runner_paths()
local function read_file(path)
local ok, lines = pcall(vim.fn.readfile, path)
if not ok then
@@ -82,40 +100,6 @@ local function build_mocha_title(suites, title)
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
@@ -126,15 +110,106 @@ local function suite_titles_from_stack(stack)
return suites
end
local function find_last_of_kind(stack, kind)
local function last_suite_from_stack(stack)
for i = #stack, 1, -1 do
if stack[i].kind == kind then
if stack[i].kind == "suite" then
return stack[i]
end
end
return nil
end
local function parse_buffer_blocks(lines)
local depth = 0
local stack = {}
local suites = {}
local tests = {}
local suite_names = { "describe", "context" }
local test_names = { "it", "test" }
for i = 1, #lines do
local line = lines[i] or ""
local opens = count_char(line, "{")
local closes = count_char(line, "}")
local kind, title = extract_title(line, suite_names)
local new_depth = depth + opens - closes
if title then
local parent_titles = suite_titles_from_stack(stack)
local node = {
kind = "suite",
title = title,
start_line = i,
end_line = i,
depth = new_depth,
parent = last_suite_from_stack(stack),
}
node.full_name = build_full_name(parent_titles, title)
node.mocha_full_title = build_mocha_title(parent_titles, title)
table.insert(suites, node)
if opens > closes then
table.insert(stack, node)
else
node.end_line = i
end
depth = new_depth
else
kind, title = extract_title(line, test_names)
if title then
local parent_titles = suite_titles_from_stack(stack)
local node = {
kind = "test",
title = title,
start_line = i,
end_line = i,
depth = new_depth,
parent = last_suite_from_stack(stack),
}
node.full_name = build_full_name(parent_titles, title)
node.mocha_full_title = build_mocha_title(parent_titles, title)
table.insert(tests, node)
if opens > closes then
table.insert(stack, node)
else
node.end_line = i
end
end
depth = new_depth
end
while #stack > 0 and stack[#stack].depth > depth do
local node = table.remove(stack)
node.end_line = i
end
end
for _, node in ipairs(stack) do
if not node.end_line or node.end_line < node.start_line then
node.end_line = #lines
end
end
return suites, tests
end
local function find_deepest_block_at_row(blocks, row)
local best = nil
for _, block in ipairs(blocks) do
if row >= block.start_line and row <= block.end_line then
if not best then
best = block
else
local best_span = best.end_line - best.start_line
local block_span = block.end_line - block.start_line
if block_span <= best_span then
best = block
end
end
end
end
return best
end
local function build_grep_pattern(title)
local pattern = title or ""
pattern = pattern:gsub("%s+", ".*")
@@ -146,11 +221,33 @@ local function ensure_state(state)
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 build_error_lines(error_obj)
local lines = {}
if not error_obj then
return lines
end
if type(error_obj) == "string" then
table.insert(lines, error_obj)
return lines
end
if type(error_obj) ~= "table" then
return lines
end
if error_obj.message and error_obj.message ~= "" then
table.insert(lines, error_obj.message)
end
if error_obj.stack and error_obj.stack ~= "" then
for _, line in ipairs(vim.split(error_obj.stack, "\n")) do
table.insert(lines, line)
end
end
return lines
end
local function extract_location(stack)
if not stack then
return nil
@@ -172,61 +269,112 @@ local function extract_location(stack)
}
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
local function normalize_title_path(payload)
if type(payload.titlePath) ~= "table" then
return nil
end
local path = {}
for _, part in ipairs(payload.titlePath) do
if type(part) == "string" and part ~= "" then
table.insert(path, part)
end
end
if #path == 0 then
return nil
end
return path
end
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
local function build_full_name_from_payload(payload)
local path = normalize_title_path(payload)
if path then
local full = table.concat(path, "/")
local mocha_full = payload.fullTitle or table.concat(path, " ")
return full, mocha_full
end
local title = payload.title or payload.fullTitle
if not title or title == "" then
return nil, nil
end
if payload.fullTitle and payload.title then
local suffix = " " .. payload.title
if payload.fullTitle:sub(-#suffix) == suffix then
local prefix = payload.fullTitle:sub(1, #payload.fullTitle - #suffix)
if prefix ~= "" then
local parts = vim.split(prefix, " ", { plain = true })
table.insert(parts, payload.title)
return table.concat(parts, "/"), payload.fullTitle
end
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
return title, payload.fullTitle or title
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
local function normalize_location(location)
if not location then
return nil
end
local filename = location.file or location.filename
local lnum = location.line or location.lnum
local col = location.column or location.col
if not filename or not lnum or not col then
return nil
end
return {
filename = filename,
lnum = tonumber(lnum),
col = tonumber(col),
}
end
local function record_ndjson_result(state, status, payload)
local full_name, mocha_title = build_full_name_from_payload(payload)
if not full_name then
return nil
end
state.mocha_titles[full_name] = mocha_title
local bucket
if status == "passed" then
bucket = "passes"
elseif status == "failed" then
bucket = "failures"
elseif status == "pending" or status == "skipped" then
bucket = "skips"
else
return nil
end
local added = false
if not state.seen[bucket][full_name] then
state.seen[bucket][full_name] = true
table.insert(state.results[bucket], full_name)
added = true
end
local location = normalize_location(payload.location)
if status == "failed" then
local lines = build_error_lines(payload.error)
if #lines > 0 then
state.outputs[full_name] = lines
end
local location = extract_location(stack or "")
if not location and payload.error and payload.error.stack then
location = extract_location(payload.error.stack)
end
if location then
location.text = message or "test failure"
location.text = (payload.error and payload.error.message) or "test failure"
state.locations[full_name] = location
end
else
if not state.outputs[full_name] then
state.outputs[full_name] = { status }
end
if location then
location.text = status
state.locations[full_name] = location
end
end
@@ -237,40 +385,31 @@ local function record_result(state, event, title, payload)
return nil
end
local function parse_json_stream_line(line, state)
local function parse_ndjson_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 payload.event == "test" then
local name = record_ndjson_result(state, payload.status, payload)
if name then
return { event = event, name = name }
return { event = payload.status, name = name }
end
elseif payload.event == "suite" then
if payload.status == "skipped" or payload.status == "pending" then
local name = record_ndjson_result(state, payload.status, payload)
if name then
return { event = payload.status, name = name }
end
end
end
return data
return nil
end
local function parse_json_stream_output(output)
local function parse_ndjson_output(output)
local state = ensure_state({})
for line in output:gmatch("[^\n]+") do
parse_json_stream_line(line, state)
parse_ndjson_line(line, state)
end
return state
end
@@ -306,31 +445,27 @@ function runner.find_nearest(bufnr, row, _col)
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")
local suites, tests = parse_buffer_blocks(lines)
local test_block = find_deepest_block_at_row(tests, row)
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),
full_name = test_block.full_name,
mocha_full_title = test_block.mocha_full_title,
kind = "test",
}
end
local suite_block = find_last_of_kind(stack, "suite")
local suite_block = find_deepest_block_at_row(suites, row)
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, " "),
full_name = suite_block.full_name,
mocha_full_title = suite_block.mocha_full_title,
kind = "suite",
}
end
@@ -355,8 +490,12 @@ function runner.build_command(spec)
cmd = {
"npx",
"mocha",
"--ui",
"bdd-with-location",
"--require",
RUNNER_PATHS.ui,
"--reporter",
"json-stream",
RUNNER_PATHS.reporter,
"--grep",
build_grep_pattern(grep),
spec.file,
@@ -378,8 +517,12 @@ function runner.build_file_command(bufnr_or_file)
cmd = {
"npx",
"mocha",
"--ui",
"bdd-with-location",
"--require",
RUNNER_PATHS.ui,
"--reporter",
"json-stream",
RUNNER_PATHS.reporter,
file,
},
cwd = cwd,
@@ -395,8 +538,12 @@ function runner.build_all_command(bufnr)
cmd = {
"npx",
"mocha",
"--ui",
"bdd-with-location",
"--require",
RUNNER_PATHS.ui,
"--reporter",
"json-stream",
RUNNER_PATHS.reporter,
"test/**/*.{test,spec}.{t,j}s",
},
cwd = cwd,
@@ -410,8 +557,12 @@ function runner.build_failed_command(last_command, failures, _scope_kind)
cmd = {
"npx",
"mocha",
"--ui",
"bdd-with-location",
"--require",
RUNNER_PATHS.ui,
"--reporter",
"json-stream",
RUNNER_PATHS.reporter,
},
cwd = cwd,
}
@@ -430,8 +581,12 @@ function runner.build_failed_command(last_command, failures, _scope_kind)
cmd = {
"npx",
"mocha",
"--ui",
"bdd-with-location",
"--require",
RUNNER_PATHS.ui,
"--reporter",
"json-stream",
RUNNER_PATHS.reporter,
"--grep",
table.concat(patterns, "|"),
},
@@ -440,7 +595,9 @@ function runner.build_failed_command(last_command, failures, _scope_kind)
end
function runner.parse_results(output)
local state = parse_json_stream_output(output)
local state = parse_ndjson_output(output)
runner._last_locations = state.locations
runner._last_mocha_titles = state.mocha_titles
return state.results
end
@@ -448,26 +605,26 @@ function runner.output_parser()
return {
on_line = function(line, state)
state = ensure_state(state or {})
local info = parse_json_stream_line(line, state)
local info = parse_ndjson_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
if info.event == "passed" then
results.passes = { info.name }
elseif info.event == "fail" then
elseif info.event == "failed" then
results.failures = { info.name }
results.failures_all = vim.deepcopy(state.results.failures)
elseif info.event == "pending" then
elseif info.event == "pending" or info.event == "skipped" 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)
local parsed = parse_ndjson_output(output)
runner._last_locations = parsed.locations
runner._last_mocha_titles = parsed.mocha_titles
return nil
@@ -476,7 +633,7 @@ function runner.output_parser()
end
function runner.parse_test_output(output)
local state = parse_json_stream_output(output)
local state = parse_ndjson_output(output)
return state.outputs
end