fix TSamNearest and fully support jump Keymap
All checks were successful
tests / test (push) Successful in 10s
All checks were successful
tests / test (push) Successful in 10s
This commit is contained in:
21
README.md
21
README.md
@@ -6,10 +6,10 @@ Mocha.js runner for the test-samurai Neovim plugin.
|
|||||||
|
|
||||||
- Detects Mocha test files by checking `package.json` dependencies.
|
- Detects Mocha test files by checking `package.json` dependencies.
|
||||||
- Supports nearest, file, all, and failed-only commands.
|
- Supports nearest, file, all, and failed-only commands.
|
||||||
- Streams results via Mocha's `json-stream` reporter.
|
- Streams results via a custom NDJSON reporter.
|
||||||
- Provides quickfix locations and per-test output.
|
- Provides quickfix locations and per-test output.
|
||||||
- Uses `--grep` with escaped patterns to match titles safely, even when running through `npm test`.
|
- Uses `--grep` with escaped patterns to match titles safely, even when running through `npm test`.
|
||||||
- Executes tests via `npx mocha` for direct Mocha invocation.
|
- Executes tests via `npx mocha` with the bundled UI + reporter so projects need no extra setup.
|
||||||
- `TSamAll` runs `test/**/*.{test,spec}.{t,j}s` to discover tests reliably.
|
- `TSamAll` runs `test/**/*.{test,spec}.{t,j}s` to discover tests reliably.
|
||||||
|
|
||||||
## Full Name Convention
|
## Full Name Convention
|
||||||
@@ -25,21 +25,30 @@ This convention is used consistently in `results.*`, `parse_test_output`, and
|
|||||||
`collect_failed_locations`. Failed-only runs translate `/` back to spaces for
|
`collect_failed_locations`. Failed-only runs translate `/` back to spaces for
|
||||||
Mocha's `--grep` matching. Avoid `/` in your titles if you rely on that mapping.
|
Mocha's `--grep` matching. Avoid `/` in your titles if you rely on that mapping.
|
||||||
|
|
||||||
|
When the custom reporter provides `titlePath`, it is used to build full names
|
||||||
|
without ambiguity (titles may contain spaces).
|
||||||
|
|
||||||
## Reporter Payload
|
## Reporter Payload
|
||||||
|
|
||||||
The runner expects Mocha's built-in `json-stream` reporter, which emits one JSON
|
The runner uses the bundled NDJSON reporter at `scripts/mocha-ndjson-reporter.cjs`,
|
||||||
object per line. It consumes the following fields when present:
|
which emits one JSON object per line:
|
||||||
|
|
||||||
```
|
```
|
||||||
{
|
{
|
||||||
"event": "suite" | "suite end" | "pass" | "fail" | "pending",
|
"event": "run-begin" | "run-end" | "suite" | "test",
|
||||||
|
"status": "passed" | "failed" | "pending" | "skipped",
|
||||||
"title": "Test or suite title",
|
"title": "Test or suite title",
|
||||||
"fullTitle": "Mocha full title",
|
"fullTitle": "Mocha full title",
|
||||||
|
"titlePath": ["Parent", "Child", "Test"],
|
||||||
"file": "/path/to/test.js",
|
"file": "/path/to/test.js",
|
||||||
"err": { "message": "string", "stack": "string" }
|
"location": { "file": "/path/to/test.js", "line": 10, "column": 5 },
|
||||||
|
"error": { "message": "string", "stack": "string" }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The UI wrapper `scripts/bdd-with-location.cjs` attaches test/suite locations so
|
||||||
|
Quickfix and per-test output are consistent.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Add the module to your test-samurai configuration:
|
Add the module to your test-samurai configuration:
|
||||||
|
|||||||
@@ -3,6 +3,24 @@ local runner = {
|
|||||||
framework = "javascript",
|
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 function read_file(path)
|
||||||
local ok, lines = pcall(vim.fn.readfile, path)
|
local ok, lines = pcall(vim.fn.readfile, path)
|
||||||
if not ok then
|
if not ok then
|
||||||
@@ -82,40 +100,6 @@ local function build_mocha_title(suites, title)
|
|||||||
return table.concat(suites, " ") .. " " .. title
|
return table.concat(suites, " ") .. " " .. title
|
||||||
end
|
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 function suite_titles_from_stack(stack)
|
||||||
local suites = {}
|
local suites = {}
|
||||||
for _, entry in ipairs(stack) do
|
for _, entry in ipairs(stack) do
|
||||||
@@ -126,15 +110,106 @@ local function suite_titles_from_stack(stack)
|
|||||||
return suites
|
return suites
|
||||||
end
|
end
|
||||||
|
|
||||||
local function find_last_of_kind(stack, kind)
|
local function last_suite_from_stack(stack)
|
||||||
for i = #stack, 1, -1 do
|
for i = #stack, 1, -1 do
|
||||||
if stack[i].kind == kind then
|
if stack[i].kind == "suite" then
|
||||||
return stack[i]
|
return stack[i]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return nil
|
return nil
|
||||||
end
|
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 function build_grep_pattern(title)
|
||||||
local pattern = title or ""
|
local pattern = title or ""
|
||||||
pattern = pattern:gsub("%s+", ".*")
|
pattern = pattern:gsub("%s+", ".*")
|
||||||
@@ -146,11 +221,33 @@ local function ensure_state(state)
|
|||||||
state.seen = state.seen or { passes = {}, failures = {}, skips = {} }
|
state.seen = state.seen or { passes = {}, failures = {}, skips = {} }
|
||||||
state.outputs = state.outputs or {}
|
state.outputs = state.outputs or {}
|
||||||
state.locations = state.locations or {}
|
state.locations = state.locations or {}
|
||||||
state.suite_stack = state.suite_stack or {}
|
|
||||||
state.mocha_titles = state.mocha_titles or {}
|
state.mocha_titles = state.mocha_titles or {}
|
||||||
return state
|
return state
|
||||||
end
|
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)
|
local function extract_location(stack)
|
||||||
if not stack then
|
if not stack then
|
||||||
return nil
|
return nil
|
||||||
@@ -172,61 +269,112 @@ local function extract_location(stack)
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
local function record_result(state, event, title, payload)
|
local function normalize_title_path(payload)
|
||||||
local full_name = build_full_name(state.suite_stack, title)
|
if type(payload.titlePath) ~= "table" then
|
||||||
local mocha_title = build_mocha_title(state.suite_stack, title)
|
return nil
|
||||||
state.mocha_titles[full_name] = mocha_title
|
end
|
||||||
local added = false
|
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
|
local function build_full_name_from_payload(payload)
|
||||||
if not state.seen.passes[full_name] then
|
local path = normalize_title_path(payload)
|
||||||
state.seen.passes[full_name] = true
|
if path then
|
||||||
table.insert(state.results.passes, full_name)
|
local full = table.concat(path, "/")
|
||||||
added = true
|
local mocha_full = payload.fullTitle or table.concat(path, " ")
|
||||||
end
|
return full, mocha_full
|
||||||
elseif event == "fail" then
|
end
|
||||||
if not state.seen.failures[full_name] then
|
|
||||||
state.seen.failures[full_name] = true
|
local title = payload.title or payload.fullTitle
|
||||||
table.insert(state.results.failures, full_name)
|
if not title or title == "" then
|
||||||
added = true
|
return nil, nil
|
||||||
end
|
end
|
||||||
elseif event == "pending" then
|
|
||||||
if not state.seen.skips[full_name] then
|
if payload.fullTitle and payload.title then
|
||||||
state.seen.skips[full_name] = true
|
local suffix = " " .. payload.title
|
||||||
table.insert(state.results.skips, full_name)
|
if payload.fullTitle:sub(-#suffix) == suffix then
|
||||||
added = true
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
if event == "fail" and payload then
|
return title, payload.fullTitle or title
|
||||||
local lines = {}
|
end
|
||||||
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
|
local function normalize_location(location)
|
||||||
table.insert(lines, message)
|
if not location then
|
||||||
end
|
return nil
|
||||||
if stack and stack ~= "" then
|
end
|
||||||
for _, line in ipairs(vim.split(stack, "\n")) do
|
local filename = location.file or location.filename
|
||||||
table.insert(lines, line)
|
local lnum = location.line or location.lnum
|
||||||
end
|
local col = location.column or location.col
|
||||||
end
|
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
|
if #lines > 0 then
|
||||||
state.outputs[full_name] = lines
|
state.outputs[full_name] = lines
|
||||||
end
|
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
|
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
|
state.locations[full_name] = location
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -237,40 +385,31 @@ local function record_result(state, event, title, payload)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
local function parse_json_stream_line(line, state)
|
local function parse_ndjson_line(line, state)
|
||||||
local ok, payload = pcall(vim.json.decode, line)
|
local ok, payload = pcall(vim.json.decode, line)
|
||||||
if not ok or type(payload) ~= "table" then
|
if not ok or type(payload) ~= "table" then
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
if payload.event == "test" then
|
||||||
local event = payload.event
|
local name = record_ndjson_result(state, payload.status, payload)
|
||||||
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
|
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
|
||||||
end
|
end
|
||||||
|
return nil
|
||||||
return data
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function parse_json_stream_output(output)
|
local function parse_ndjson_output(output)
|
||||||
local state = ensure_state({})
|
local state = ensure_state({})
|
||||||
for line in output:gmatch("[^\n]+") do
|
for line in output:gmatch("[^\n]+") do
|
||||||
parse_json_stream_line(line, state)
|
parse_ndjson_line(line, state)
|
||||||
end
|
end
|
||||||
return state
|
return state
|
||||||
end
|
end
|
||||||
@@ -306,31 +445,27 @@ function runner.find_nearest(bufnr, row, _col)
|
|||||||
return nil, "no package.json found"
|
return nil, "no package.json found"
|
||||||
end
|
end
|
||||||
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||||
local stack = parse_buffer_scope(lines, row)
|
local suites, tests = parse_buffer_blocks(lines)
|
||||||
local suites = suite_titles_from_stack(stack)
|
local test_block = find_deepest_block_at_row(tests, row)
|
||||||
|
|
||||||
local test_block = find_last_of_kind(stack, "test")
|
|
||||||
if test_block then
|
if test_block then
|
||||||
local full_name = build_full_name(suites, test_block.title)
|
|
||||||
return {
|
return {
|
||||||
file = file,
|
file = file,
|
||||||
cwd = root,
|
cwd = root,
|
||||||
test_name = test_block.title,
|
test_name = test_block.title,
|
||||||
full_name = full_name,
|
full_name = test_block.full_name,
|
||||||
mocha_full_title = build_mocha_title(suites, test_block.title),
|
mocha_full_title = test_block.mocha_full_title,
|
||||||
kind = "test",
|
kind = "test",
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
local suite_block = find_last_of_kind(stack, "suite")
|
local suite_block = find_deepest_block_at_row(suites, row)
|
||||||
if suite_block then
|
if suite_block then
|
||||||
local full_name = table.concat(suites, "/")
|
|
||||||
return {
|
return {
|
||||||
file = file,
|
file = file,
|
||||||
cwd = root,
|
cwd = root,
|
||||||
test_name = suite_block.title,
|
test_name = suite_block.title,
|
||||||
full_name = full_name,
|
full_name = suite_block.full_name,
|
||||||
mocha_full_title = table.concat(suites, " "),
|
mocha_full_title = suite_block.mocha_full_title,
|
||||||
kind = "suite",
|
kind = "suite",
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -355,8 +490,12 @@ function runner.build_command(spec)
|
|||||||
cmd = {
|
cmd = {
|
||||||
"npx",
|
"npx",
|
||||||
"mocha",
|
"mocha",
|
||||||
|
"--ui",
|
||||||
|
"bdd-with-location",
|
||||||
|
"--require",
|
||||||
|
RUNNER_PATHS.ui,
|
||||||
"--reporter",
|
"--reporter",
|
||||||
"json-stream",
|
RUNNER_PATHS.reporter,
|
||||||
"--grep",
|
"--grep",
|
||||||
build_grep_pattern(grep),
|
build_grep_pattern(grep),
|
||||||
spec.file,
|
spec.file,
|
||||||
@@ -378,8 +517,12 @@ function runner.build_file_command(bufnr_or_file)
|
|||||||
cmd = {
|
cmd = {
|
||||||
"npx",
|
"npx",
|
||||||
"mocha",
|
"mocha",
|
||||||
|
"--ui",
|
||||||
|
"bdd-with-location",
|
||||||
|
"--require",
|
||||||
|
RUNNER_PATHS.ui,
|
||||||
"--reporter",
|
"--reporter",
|
||||||
"json-stream",
|
RUNNER_PATHS.reporter,
|
||||||
file,
|
file,
|
||||||
},
|
},
|
||||||
cwd = cwd,
|
cwd = cwd,
|
||||||
@@ -395,8 +538,12 @@ function runner.build_all_command(bufnr)
|
|||||||
cmd = {
|
cmd = {
|
||||||
"npx",
|
"npx",
|
||||||
"mocha",
|
"mocha",
|
||||||
|
"--ui",
|
||||||
|
"bdd-with-location",
|
||||||
|
"--require",
|
||||||
|
RUNNER_PATHS.ui,
|
||||||
"--reporter",
|
"--reporter",
|
||||||
"json-stream",
|
RUNNER_PATHS.reporter,
|
||||||
"test/**/*.{test,spec}.{t,j}s",
|
"test/**/*.{test,spec}.{t,j}s",
|
||||||
},
|
},
|
||||||
cwd = cwd,
|
cwd = cwd,
|
||||||
@@ -410,8 +557,12 @@ function runner.build_failed_command(last_command, failures, _scope_kind)
|
|||||||
cmd = {
|
cmd = {
|
||||||
"npx",
|
"npx",
|
||||||
"mocha",
|
"mocha",
|
||||||
|
"--ui",
|
||||||
|
"bdd-with-location",
|
||||||
|
"--require",
|
||||||
|
RUNNER_PATHS.ui,
|
||||||
"--reporter",
|
"--reporter",
|
||||||
"json-stream",
|
RUNNER_PATHS.reporter,
|
||||||
},
|
},
|
||||||
cwd = cwd,
|
cwd = cwd,
|
||||||
}
|
}
|
||||||
@@ -430,8 +581,12 @@ function runner.build_failed_command(last_command, failures, _scope_kind)
|
|||||||
cmd = {
|
cmd = {
|
||||||
"npx",
|
"npx",
|
||||||
"mocha",
|
"mocha",
|
||||||
|
"--ui",
|
||||||
|
"bdd-with-location",
|
||||||
|
"--require",
|
||||||
|
RUNNER_PATHS.ui,
|
||||||
"--reporter",
|
"--reporter",
|
||||||
"json-stream",
|
RUNNER_PATHS.reporter,
|
||||||
"--grep",
|
"--grep",
|
||||||
table.concat(patterns, "|"),
|
table.concat(patterns, "|"),
|
||||||
},
|
},
|
||||||
@@ -440,7 +595,9 @@ function runner.build_failed_command(last_command, failures, _scope_kind)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function runner.parse_results(output)
|
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
|
return state.results
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -448,26 +605,26 @@ function runner.output_parser()
|
|||||||
return {
|
return {
|
||||||
on_line = function(line, state)
|
on_line = function(line, state)
|
||||||
state = ensure_state(state or {})
|
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_locations = state.locations
|
||||||
runner._last_mocha_titles = state.mocha_titles
|
runner._last_mocha_titles = state.mocha_titles
|
||||||
if not info or not info.event or not info.name then
|
if not info or not info.event or not info.name then
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
local results = { passes = {}, failures = {}, skips = {} }
|
local results = { passes = {}, failures = {}, skips = {} }
|
||||||
if info.event == "pass" then
|
if info.event == "passed" then
|
||||||
results.passes = { info.name }
|
results.passes = { info.name }
|
||||||
elseif info.event == "fail" then
|
elseif info.event == "failed" then
|
||||||
results.failures = { info.name }
|
results.failures = { info.name }
|
||||||
results.failures_all = vim.deepcopy(state.results.failures)
|
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 }
|
results.skips = { info.name }
|
||||||
end
|
end
|
||||||
return results
|
return results
|
||||||
end,
|
end,
|
||||||
on_complete = function(output, state)
|
on_complete = function(output, state)
|
||||||
state = ensure_state(state or {})
|
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_locations = parsed.locations
|
||||||
runner._last_mocha_titles = parsed.mocha_titles
|
runner._last_mocha_titles = parsed.mocha_titles
|
||||||
return nil
|
return nil
|
||||||
@@ -476,7 +633,7 @@ function runner.output_parser()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function runner.parse_test_output(output)
|
function runner.parse_test_output(output)
|
||||||
local state = parse_json_stream_output(output)
|
local state = parse_ndjson_output(output)
|
||||||
return state.outputs
|
return state.outputs
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
139
scripts/bdd-with-location.cjs
Normal file
139
scripts/bdd-with-location.cjs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
function reqFromCwd(id) {
|
||||||
|
// Resolve as if we required from the project root (where mocha is installed)
|
||||||
|
return require(require.resolve(id, { paths: [process.cwd()] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const Mocha = reqFromCwd('mocha');
|
||||||
|
const bdd = reqFromCwd('mocha/lib/interfaces/bdd');
|
||||||
|
|
||||||
|
function escapeRegExp(s) {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureLocationForFile(file, stackStartFn) {
|
||||||
|
const err = new Error();
|
||||||
|
if (Error.captureStackTrace) Error.captureStackTrace(err, stackStartFn);
|
||||||
|
|
||||||
|
const lines = String(err.stack || '').split('\n').slice(1);
|
||||||
|
|
||||||
|
// Prefer exact match for current test file (absolute/normalized path as passed by mocha)
|
||||||
|
const fileRe = new RegExp(`\\(?(${escapeRegExp(file)}):(\\d+):(\\d+)\\)?$`);
|
||||||
|
for (const line of lines) {
|
||||||
|
const m = line.match(fileRe);
|
||||||
|
if (m) return { file: m[1], line: Number(m[2]), column: Number(m[3]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: any frame containing this file path
|
||||||
|
const anyRe = /\(?(.+?):(\d+):(\d+)\)?$/;
|
||||||
|
for (const line of lines) {
|
||||||
|
const m = line.match(anyRe);
|
||||||
|
if (m && m[1] && m[1].includes(file)) {
|
||||||
|
return { file: m[1], line: Number(m[2]), column: Number(m[3]) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { file, line: null, column: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function markTest(test, loc, pendingType) {
|
||||||
|
if (!test) return;
|
||||||
|
test._akLocation = loc;
|
||||||
|
if (pendingType) test._akPendingType = pendingType; // 'todo' | 'skip'
|
||||||
|
}
|
||||||
|
|
||||||
|
function markSuite(suite, loc, pendingType) {
|
||||||
|
if (!suite) return;
|
||||||
|
suite._akLocation = loc;
|
||||||
|
if (pendingType) suite._akPendingType = pendingType; // 'skip'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register custom interface name
|
||||||
|
Mocha.interfaces['bdd-with-location'] = function (suite) {
|
||||||
|
// First install standard BDD globals
|
||||||
|
bdd(suite);
|
||||||
|
|
||||||
|
// Then wrap globals on pre-require (Mocha passes "file" for each loaded test file)
|
||||||
|
suite.on('pre-require', function (context, file) {
|
||||||
|
if (!context || !file) return;
|
||||||
|
|
||||||
|
function loc(stackStartFn) {
|
||||||
|
return captureLocationForFile(file, stackStartFn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------
|
||||||
|
// Wrap `it`
|
||||||
|
// -------------------------
|
||||||
|
const origIt = context.it;
|
||||||
|
|
||||||
|
function itWrapped(title, fn) {
|
||||||
|
const test = origIt.call(this, title, fn);
|
||||||
|
|
||||||
|
// "todo" detection: it('x') without a function
|
||||||
|
const pendingType = typeof fn !== 'function' ? 'todo' : null;
|
||||||
|
|
||||||
|
markTest(test, loc(itWrapped), pendingType);
|
||||||
|
return test;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy properties like `.only`/`.skip` added by Mocha
|
||||||
|
Object.assign(itWrapped, origIt);
|
||||||
|
|
||||||
|
if (typeof origIt.only === 'function') {
|
||||||
|
itWrapped.only = function (title, fn) {
|
||||||
|
const test = origIt.only.call(this, title, fn);
|
||||||
|
const pendingType = typeof fn !== 'function' ? 'todo' : null;
|
||||||
|
markTest(test, loc(itWrapped.only), pendingType);
|
||||||
|
return test;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof origIt.skip === 'function') {
|
||||||
|
itWrapped.skip = function (title, fn) {
|
||||||
|
const test = origIt.skip.call(this, title, fn);
|
||||||
|
// Explicit skip
|
||||||
|
markTest(test, loc(itWrapped.skip), 'skip');
|
||||||
|
return test;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
context.it = itWrapped;
|
||||||
|
if (context.specify) context.specify = itWrapped;
|
||||||
|
|
||||||
|
// -------------------------
|
||||||
|
// Wrap `describe`
|
||||||
|
// -------------------------
|
||||||
|
const origDescribe = context.describe;
|
||||||
|
|
||||||
|
function describeWrapped(title, fn) {
|
||||||
|
const s = origDescribe.call(this, title, fn);
|
||||||
|
markSuite(s, loc(describeWrapped), null);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(describeWrapped, origDescribe);
|
||||||
|
|
||||||
|
if (typeof origDescribe.only === 'function') {
|
||||||
|
describeWrapped.only = function (title, fn) {
|
||||||
|
const s = origDescribe.only.call(this, title, fn);
|
||||||
|
markSuite(s, loc(describeWrapped.only), null);
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof origDescribe.skip === 'function') {
|
||||||
|
describeWrapped.skip = function (title, fn) {
|
||||||
|
const s = origDescribe.skip.call(this, title, fn);
|
||||||
|
// Suite skip
|
||||||
|
markSuite(s, loc(describeWrapped.skip), 'skip');
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
context.describe = describeWrapped;
|
||||||
|
if (context.context) context.context = describeWrapped;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = Mocha.interfaces['bdd-with-location'];
|
||||||
131
scripts/mocha-ndjson-reporter.cjs
Normal file
131
scripts/mocha-ndjson-reporter.cjs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
function reqFromCwd(id) {
|
||||||
|
return require(require.resolve(id, { paths: [process.cwd()] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const Mocha = reqFromCwd('mocha');
|
||||||
|
const {
|
||||||
|
EVENT_RUN_BEGIN,
|
||||||
|
EVENT_RUN_END,
|
||||||
|
EVENT_SUITE_BEGIN,
|
||||||
|
EVENT_TEST_PASS,
|
||||||
|
EVENT_TEST_FAIL,
|
||||||
|
EVENT_TEST_PENDING,
|
||||||
|
} = Mocha.Runner.constants;
|
||||||
|
|
||||||
|
function serializeError(err) {
|
||||||
|
if (!err) return null;
|
||||||
|
return {
|
||||||
|
name: err.name || null,
|
||||||
|
message: err.message || String(err),
|
||||||
|
stack: err.stack || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeTitlePath(entity) {
|
||||||
|
if (entity && typeof entity.titlePath === 'function') {
|
||||||
|
return entity.titlePath();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class NdjsonReporter {
|
||||||
|
constructor(runner /*, options */) {
|
||||||
|
runner
|
||||||
|
.once(EVENT_RUN_BEGIN, () => {
|
||||||
|
this.emit({ event: 'run-begin', total: runner.total });
|
||||||
|
})
|
||||||
|
|
||||||
|
// Optional suite-level event for describe.skip (helps jump to suite definition)
|
||||||
|
.on(EVENT_SUITE_BEGIN, (suite) => {
|
||||||
|
if (suite && suite.pending && suite.title) {
|
||||||
|
this.emit({
|
||||||
|
event: 'suite',
|
||||||
|
status: 'skipped',
|
||||||
|
pendingType: suite._akPendingType || 'skip',
|
||||||
|
title: suite.title,
|
||||||
|
fullTitle: typeof suite.fullTitle === 'function' ? suite.fullTitle() : suite.title,
|
||||||
|
titlePath: safeTitlePath(suite),
|
||||||
|
file: suite.file || (suite._akLocation && suite._akLocation.file) || null,
|
||||||
|
location: suite._akLocation || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
.on(EVENT_TEST_PASS, (test) => {
|
||||||
|
this.emit(this.testPayload('passed', test, null));
|
||||||
|
})
|
||||||
|
|
||||||
|
.on(EVENT_TEST_FAIL, (test, err) => {
|
||||||
|
this.emit(this.testPayload('failed', test, serializeError(err)));
|
||||||
|
})
|
||||||
|
|
||||||
|
.on(EVENT_TEST_PENDING, (test) => {
|
||||||
|
// Mocha "pending" includes:
|
||||||
|
// - TODO tests (no function)
|
||||||
|
// - explicit it.skip / describe.skip
|
||||||
|
// - runtime this.skip()
|
||||||
|
// We'll classify:
|
||||||
|
// - pendingType === 'todo' => pending
|
||||||
|
// - otherwise => skipped
|
||||||
|
const inferredPendingType = this.inferPendingType(test);
|
||||||
|
const status = inferredPendingType === 'todo' ? 'pending' : 'skipped';
|
||||||
|
this.emit({
|
||||||
|
...this.testPayload(status, test, null),
|
||||||
|
pendingType: inferredPendingType,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
.once(EVENT_RUN_END, () => {
|
||||||
|
this.emit({ event: 'run-end', stats: runner.stats || null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
inferPendingType(test) {
|
||||||
|
// Priority: explicit marker from UI wrappers
|
||||||
|
if (test && test._akPendingType) return test._akPendingType;
|
||||||
|
|
||||||
|
// Heuristic:
|
||||||
|
// - If there's no function (or it's missing), it's likely a TODO-style pending
|
||||||
|
// - If function exists but test ended as pending, it's likely skipped (this.skip())
|
||||||
|
if (!test) return null;
|
||||||
|
|
||||||
|
// In many Mocha versions, TODO tests have test.fn undefined/null
|
||||||
|
if (!test.fn) return 'todo';
|
||||||
|
|
||||||
|
return 'skip';
|
||||||
|
}
|
||||||
|
|
||||||
|
testPayload(status, test, errorObj) {
|
||||||
|
const location = (test && test._akLocation) || null;
|
||||||
|
|
||||||
|
const file =
|
||||||
|
(test && test.file) ||
|
||||||
|
(test && test.parent && test.parent.file) ||
|
||||||
|
(location && location.file) ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
event: 'test',
|
||||||
|
status,
|
||||||
|
title: test ? test.title : null,
|
||||||
|
fullTitle: test && typeof test.fullTitle === 'function' ? test.fullTitle() : null,
|
||||||
|
titlePath: safeTitlePath(test),
|
||||||
|
file,
|
||||||
|
location,
|
||||||
|
duration: test && typeof test.duration === 'number' ? test.duration : null,
|
||||||
|
currentRetry: test && typeof test.currentRetry === 'function' ? test.currentRetry() : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status === 'failed') payload.error = errorObj;
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(obj) {
|
||||||
|
process.stdout.write(JSON.stringify(obj) + '\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = NdjsonReporter;
|
||||||
@@ -42,35 +42,82 @@ describe("test-samurai-mocha-runner", function()
|
|||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it("finds nearest with test > suite > file priority", function()
|
it("finds nearest with precise scope selection", function()
|
||||||
with_project({
|
with_project({
|
||||||
["package.json"] = [[{"devDependencies":{"mocha":"10.0.0"}}]],
|
["package.json"] = [[{"devDependencies":{"mocha":"10.0.0"}}]],
|
||||||
}, function(root)
|
}, function(root)
|
||||||
local content = table.concat({
|
local lines = {
|
||||||
"describe(\"Math\", function() {",
|
"// #1",
|
||||||
" it(\"adds\", function() {",
|
"describe(\"outer\", () => {",
|
||||||
" expect(1).to.equal(1)",
|
|
||||||
" })",
|
|
||||||
"",
|
"",
|
||||||
" it(\"subs\", function() {",
|
" it(\"on stage 1\"); // #2",
|
||||||
" expect(1).to.equal(1)",
|
|
||||||
" })",
|
|
||||||
"})",
|
|
||||||
"",
|
"",
|
||||||
"const value = 1",
|
" it(\"i2\", () => { // #3",
|
||||||
}, "\n")
|
" // #4",
|
||||||
|
" }); // #5",
|
||||||
|
"",
|
||||||
|
" // #6",
|
||||||
|
" describe(\"level 2\", () => { // #7",
|
||||||
|
" it(\"i3\", () => {",
|
||||||
|
" // #10",
|
||||||
|
" });",
|
||||||
|
"",
|
||||||
|
" // #8",
|
||||||
|
" it(\"i4\", () => {",
|
||||||
|
" // ...",
|
||||||
|
" });",
|
||||||
|
" }); // #9",
|
||||||
|
"});",
|
||||||
|
}
|
||||||
|
local content = table.concat(lines, "\n")
|
||||||
local bufnr = make_buffer(root .. "/test/math.spec.js", content)
|
local bufnr = make_buffer(root .. "/test/math.spec.js", content)
|
||||||
|
|
||||||
local spec_test = assert(runner.find_nearest(bufnr, 3, 0))
|
local markers = {}
|
||||||
assert.equals("test", spec_test.kind)
|
for i, line in ipairs(lines) do
|
||||||
assert.equals("Math/adds", spec_test.full_name)
|
local mark = line:match("//%s*#(%d+)")
|
||||||
|
if mark then
|
||||||
|
markers[tonumber(mark)] = i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local spec_suite = assert(runner.find_nearest(bufnr, 5, 0))
|
local spec_file = assert(runner.find_nearest(bufnr, markers[1], 0))
|
||||||
assert.equals("suite", spec_suite.kind)
|
|
||||||
assert.equals("Math", spec_suite.full_name)
|
|
||||||
|
|
||||||
local spec_file = assert(runner.find_nearest(bufnr, 11, 0))
|
|
||||||
assert.equals("file", spec_file.kind)
|
assert.equals("file", spec_file.kind)
|
||||||
|
|
||||||
|
local spec_pending = assert(runner.find_nearest(bufnr, markers[2], 0))
|
||||||
|
assert.equals("test", spec_pending.kind)
|
||||||
|
assert.equals("outer/on stage 1", spec_pending.full_name)
|
||||||
|
|
||||||
|
local spec_i2_line = assert(runner.find_nearest(bufnr, markers[3], 0))
|
||||||
|
assert.equals("test", spec_i2_line.kind)
|
||||||
|
assert.equals("outer/i2", spec_i2_line.full_name)
|
||||||
|
|
||||||
|
local spec_i2_body = assert(runner.find_nearest(bufnr, markers[4], 0))
|
||||||
|
assert.equals("test", spec_i2_body.kind)
|
||||||
|
assert.equals("outer/i2", spec_i2_body.full_name)
|
||||||
|
|
||||||
|
local spec_i2_close = assert(runner.find_nearest(bufnr, markers[5], 0))
|
||||||
|
assert.equals("test", spec_i2_close.kind)
|
||||||
|
assert.equals("outer/i2", spec_i2_close.full_name)
|
||||||
|
|
||||||
|
local spec_outer = assert(runner.find_nearest(bufnr, markers[6], 0))
|
||||||
|
assert.equals("suite", spec_outer.kind)
|
||||||
|
assert.equals("outer", spec_outer.full_name)
|
||||||
|
|
||||||
|
local spec_inner_line = assert(runner.find_nearest(bufnr, markers[7], 0))
|
||||||
|
assert.equals("suite", spec_inner_line.kind)
|
||||||
|
assert.equals("outer/level 2", spec_inner_line.full_name)
|
||||||
|
|
||||||
|
local spec_inner_body = assert(runner.find_nearest(bufnr, markers[8], 0))
|
||||||
|
assert.equals("suite", spec_inner_body.kind)
|
||||||
|
assert.equals("outer/level 2", spec_inner_body.full_name)
|
||||||
|
|
||||||
|
local spec_inner_close = assert(runner.find_nearest(bufnr, markers[9], 0))
|
||||||
|
assert.equals("suite", spec_inner_close.kind)
|
||||||
|
assert.equals("outer/level 2", spec_inner_close.full_name)
|
||||||
|
|
||||||
|
local spec_i3 = assert(runner.find_nearest(bufnr, markers[10], 0))
|
||||||
|
assert.equals("test", spec_i3.kind)
|
||||||
|
assert.equals("outer/level 2/i3", spec_i3.full_name)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -92,7 +139,7 @@ describe("test-samurai-mocha-runner", function()
|
|||||||
assert.are.same({ "echo", "no package.json found" }, command.cmd)
|
assert.are.same({ "echo", "no package.json found" }, command.cmd)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it("builds command with mocha json-stream and grep", function()
|
it("builds command with custom ui and reporter and grep", function()
|
||||||
with_project({
|
with_project({
|
||||||
["package.json"] = [[{"devDependencies":{"mocha":"10.0.0"}}]],
|
["package.json"] = [[{"devDependencies":{"mocha":"10.0.0"}}]],
|
||||||
}, function(root)
|
}, function(root)
|
||||||
@@ -106,8 +153,13 @@ describe("test-samurai-mocha-runner", function()
|
|||||||
local command = runner.build_command(spec)
|
local command = runner.build_command(spec)
|
||||||
assert.equals("npx", command.cmd[1])
|
assert.equals("npx", command.cmd[1])
|
||||||
assert.equals("mocha", command.cmd[2])
|
assert.equals("mocha", command.cmd[2])
|
||||||
|
assert.is_true(vim.tbl_contains(command.cmd, "--ui"))
|
||||||
|
assert.is_true(vim.tbl_contains(command.cmd, "bdd-with-location"))
|
||||||
|
assert.is_true(vim.tbl_contains(command.cmd, "--require"))
|
||||||
assert.is_true(vim.tbl_contains(command.cmd, "--reporter"))
|
assert.is_true(vim.tbl_contains(command.cmd, "--reporter"))
|
||||||
assert.is_true(vim.tbl_contains(command.cmd, "json-stream"))
|
local joined = table.concat(command.cmd, " ")
|
||||||
|
assert.is_true(joined:match("scripts/bdd%-with%-location%.cjs") ~= nil)
|
||||||
|
assert.is_true(joined:match("scripts/mocha%-ndjson%-reporter%.cjs") ~= nil)
|
||||||
assert.is_true(vim.tbl_contains(command.cmd, "--grep"))
|
assert.is_true(vim.tbl_contains(command.cmd, "--grep"))
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
@@ -124,18 +176,49 @@ describe("test-samurai-mocha-runner", function()
|
|||||||
|
|
||||||
it("streams results without duplicates", function()
|
it("streams results without duplicates", function()
|
||||||
local output_lines = {
|
local output_lines = {
|
||||||
[=["suite",{"title":"Math","fullTitle":"Math"}]=],
|
vim.json.encode({
|
||||||
[=["pass",{"title":"adds","fullTitle":"Math adds","file":"/tmp/math.spec.js"}]=],
|
event = "test",
|
||||||
[=["pass",{"title":"adds","fullTitle":"Math adds","file":"/tmp/math.spec.js"}]=],
|
status = "passed",
|
||||||
[=["fail",{"title":"subs","fullTitle":"Math subs","file":"/tmp/math.spec.js","err":"oops","stack":"Error: oops\n at Context.<anonymous> (/tmp/math.spec.js:10:5)"}]=],
|
title = "adds",
|
||||||
[=["pending",{"title":"skips","fullTitle":"Math skips","file":"/tmp/math.spec.js"}]=],
|
fullTitle = "Math adds",
|
||||||
[=["suite end",{"title":"Math","fullTitle":"Math"}]=],
|
titlePath = { "Math", "adds" },
|
||||||
|
file = "/tmp/math.spec.js",
|
||||||
|
location = { file = "/tmp/math.spec.js", line = 5, column = 3 },
|
||||||
|
}),
|
||||||
|
vim.json.encode({
|
||||||
|
event = "test",
|
||||||
|
status = "passed",
|
||||||
|
title = "adds",
|
||||||
|
fullTitle = "Math adds",
|
||||||
|
titlePath = { "Math", "adds" },
|
||||||
|
file = "/tmp/math.spec.js",
|
||||||
|
location = { file = "/tmp/math.spec.js", line = 5, column = 3 },
|
||||||
|
}),
|
||||||
|
vim.json.encode({
|
||||||
|
event = "test",
|
||||||
|
status = "failed",
|
||||||
|
title = "subs",
|
||||||
|
fullTitle = "Math subs",
|
||||||
|
titlePath = { "Math", "subs" },
|
||||||
|
file = "/tmp/math.spec.js",
|
||||||
|
location = { file = "/tmp/math.spec.js", line = 10, column = 5 },
|
||||||
|
error = { message = "oops", stack = "Error: oops\n at Context.<anonymous> (/tmp/math.spec.js:10:5)" },
|
||||||
|
}),
|
||||||
|
vim.json.encode({
|
||||||
|
event = "test",
|
||||||
|
status = "pending",
|
||||||
|
title = "skips",
|
||||||
|
fullTitle = "Math skips",
|
||||||
|
titlePath = { "Math", "skips" },
|
||||||
|
file = "/tmp/math.spec.js",
|
||||||
|
location = { file = "/tmp/math.spec.js", line = 15, column = 2 },
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
local parser = runner.output_parser()
|
local parser = runner.output_parser()
|
||||||
local state = {}
|
local state = {}
|
||||||
local aggregated = { passes = {}, failures = {}, skips = {} }
|
local aggregated = { passes = {}, failures = {}, skips = {} }
|
||||||
for _, line in ipairs(output_lines) do
|
for _, line in ipairs(output_lines) do
|
||||||
local results = parser.on_line("[" .. line .. "]", state)
|
local results = parser.on_line(line, state)
|
||||||
if results then
|
if results then
|
||||||
for _, name in ipairs(results.passes or {}) do
|
for _, name in ipairs(results.passes or {}) do
|
||||||
table.insert(aggregated.passes, name)
|
table.insert(aggregated.passes, name)
|
||||||
@@ -157,6 +240,16 @@ describe("test-samurai-mocha-runner", function()
|
|||||||
assert.equals("/tmp/math.spec.js", items[1].filename)
|
assert.equals("/tmp/math.spec.js", items[1].filename)
|
||||||
assert.equals(10, items[1].lnum)
|
assert.equals(10, items[1].lnum)
|
||||||
assert.equals(5, items[1].col)
|
assert.equals(5, items[1].col)
|
||||||
|
|
||||||
|
local pass_items = runner.collect_failed_locations({ "Math/adds" }, {}, "nearest")
|
||||||
|
assert.equals("/tmp/math.spec.js", pass_items[1].filename)
|
||||||
|
assert.equals(5, pass_items[1].lnum)
|
||||||
|
assert.equals(3, pass_items[1].col)
|
||||||
|
|
||||||
|
local skip_items = runner.collect_failed_locations({ "Math/skips" }, {}, "nearest")
|
||||||
|
assert.equals("/tmp/math.spec.js", skip_items[1].filename)
|
||||||
|
assert.equals(15, skip_items[1].lnum)
|
||||||
|
assert.equals(2, skip_items[1].col)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it("builds failed-only command with grep pattern", function()
|
it("builds failed-only command with grep pattern", function()
|
||||||
@@ -179,21 +272,59 @@ describe("test-samurai-mocha-runner", function()
|
|||||||
|
|
||||||
it("parses results and per-test output", function()
|
it("parses results and per-test output", function()
|
||||||
local output = table.concat({
|
local output = table.concat({
|
||||||
[=["suite",{"title":"Math","fullTitle":"Math"}]=],
|
vim.json.encode({
|
||||||
[=["fail",{"title":"subs","fullTitle":"Math subs","file":"/tmp/math.spec.js","err":"oops","stack":"Error: oops\n at Context.<anonymous> (/tmp/math.spec.js:10:5)\n at processImmediate"}]=],
|
event = "test",
|
||||||
[=["suite end",{"title":"Math","fullTitle":"Math"}]=],
|
status = "failed",
|
||||||
|
title = "subs",
|
||||||
|
fullTitle = "Math subs",
|
||||||
|
titlePath = { "Math", "subs" },
|
||||||
|
file = "/tmp/math.spec.js",
|
||||||
|
location = { file = "/tmp/math.spec.js", line = 10, column = 5 },
|
||||||
|
error = {
|
||||||
|
message = "oops",
|
||||||
|
stack = "Error: oops\n at Context.<anonymous> (/tmp/math.spec.js:10:5)\n at processImmediate",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
vim.json.encode({
|
||||||
|
event = "test",
|
||||||
|
status = "passed",
|
||||||
|
title = "adds",
|
||||||
|
fullTitle = "Math adds",
|
||||||
|
titlePath = { "Math", "adds" },
|
||||||
|
file = "/tmp/math.spec.js",
|
||||||
|
location = { file = "/tmp/math.spec.js", line = 4, column = 2 },
|
||||||
|
}),
|
||||||
|
vim.json.encode({
|
||||||
|
event = "test",
|
||||||
|
status = "skipped",
|
||||||
|
title = "skips",
|
||||||
|
fullTitle = "Math skips",
|
||||||
|
titlePath = { "Math", "skips" },
|
||||||
|
file = "/tmp/math.spec.js",
|
||||||
|
location = { file = "/tmp/math.spec.js", line = 6, column = 2 },
|
||||||
|
}),
|
||||||
}, "\n")
|
}, "\n")
|
||||||
local results = runner.parse_results("[" .. output:gsub("\n", "]\n[") .. "]")
|
local results = runner.parse_results(output)
|
||||||
assert.equals("Math/subs", results.failures[1])
|
assert.equals("Math/subs", results.failures[1])
|
||||||
|
|
||||||
local outputs = runner.parse_test_output("[" .. output:gsub("\n", "]\n[") .. "]")
|
local outputs = runner.parse_test_output(output)
|
||||||
assert.is_true(outputs["Math/subs"] ~= nil)
|
assert.is_true(outputs["Math/subs"] ~= nil)
|
||||||
|
assert.is_true(outputs["Math/adds"] ~= nil)
|
||||||
|
assert.is_true(outputs["Math/skips"] ~= nil)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it("does not return results on complete", function()
|
it("does not return results on complete", function()
|
||||||
local parser = runner.output_parser()
|
local parser = runner.output_parser()
|
||||||
local state = {}
|
local state = {}
|
||||||
local output = "[" .. [=["pass",{"title":"adds","fullTitle":"Math adds","file":"/tmp/math.spec.js"}]=] .. "]"
|
local output = vim.json.encode({
|
||||||
|
event = "test",
|
||||||
|
status = "passed",
|
||||||
|
title = "adds",
|
||||||
|
fullTitle = "Math adds",
|
||||||
|
titlePath = { "Math", "adds" },
|
||||||
|
file = "/tmp/math.spec.js",
|
||||||
|
location = { file = "/tmp/math.spec.js", line = 3, column = 2 },
|
||||||
|
})
|
||||||
local results = parser.on_complete(output, state)
|
local results = parser.on_complete(output, state)
|
||||||
assert.is_nil(results)
|
assert.is_nil(results)
|
||||||
end)
|
end)
|
||||||
|
|||||||
Reference in New Issue
Block a user