Compare commits
2 Commits
f3350cad98
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
2a1bac55b2
|
|||
|
2d5889dce3
|
22
README.md
22
README.md
@@ -8,9 +8,10 @@ Mocha.js runner for the test-samurai Neovim plugin.
|
||||
- Supports nearest, file, all, and failed-only commands.
|
||||
- Streams results via a custom NDJSON reporter.
|
||||
- Provides quickfix locations and per-test output.
|
||||
- Captures hook failures (before/after/beforeEach/afterEach) as failed entries with output.
|
||||
- Uses `--grep` with escaped patterns to match titles safely, even when running through `npm test`.
|
||||
- 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.js` by default.
|
||||
|
||||
## Full Name Convention
|
||||
|
||||
@@ -35,11 +36,12 @@ which emits one JSON object per line:
|
||||
|
||||
```
|
||||
{
|
||||
"event": "run-begin" | "run-end" | "suite" | "test",
|
||||
"event": "run-begin" | "run-end" | "suite" | "test" | "hook",
|
||||
"status": "passed" | "failed" | "pending" | "skipped",
|
||||
"title": "Test or suite title",
|
||||
"fullTitle": "Mocha full title",
|
||||
"titlePath": ["Parent", "Child", "Test"],
|
||||
"hookType": "beforeEach" | "afterEach" | "beforeAll" | "afterAll",
|
||||
"file": "/path/to/test.js",
|
||||
"location": { "file": "/path/to/test.js", "line": 10, "column": 5 },
|
||||
"output": ["stdout line 1", "stdout line 2"],
|
||||
@@ -62,6 +64,22 @@ require("test-samurai").setup({
|
||||
})
|
||||
```
|
||||
|
||||
Override the default glob for `TSamAll`:
|
||||
|
||||
```lua
|
||||
require("test-samurai-mocha-runner").setup({
|
||||
all_test_glob = "test/**/*.test.js",
|
||||
})
|
||||
```
|
||||
|
||||
Enable debug logging (writes raw parser input lines):
|
||||
|
||||
```lua
|
||||
require("test-samurai-mocha-runner").setup({
|
||||
debug_log_path = "/tmp/tsam-mocha.log",
|
||||
})
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Run tests:
|
||||
|
||||
@@ -3,6 +3,34 @@ local runner = {
|
||||
framework = "javascript",
|
||||
}
|
||||
|
||||
local default_config = {
|
||||
all_test_glob = "test/**/*.test.js",
|
||||
debug_log_path = nil,
|
||||
}
|
||||
|
||||
local config = vim.deepcopy(default_config)
|
||||
|
||||
function runner.setup(opts)
|
||||
if not opts then
|
||||
config = vim.deepcopy(default_config)
|
||||
return config
|
||||
end
|
||||
config = vim.tbl_deep_extend("force", vim.deepcopy(default_config), opts)
|
||||
return config
|
||||
end
|
||||
|
||||
local function debug_log_line(label, line)
|
||||
if not config or not config.debug_log_path then
|
||||
return
|
||||
end
|
||||
local ok, handle = pcall(io.open, config.debug_log_path, "a")
|
||||
if not ok or not handle then
|
||||
return
|
||||
end
|
||||
handle:write(string.format("%s %s\n", label, line or ""))
|
||||
handle:close()
|
||||
end
|
||||
|
||||
local function runner_root()
|
||||
local source = debug.getinfo(1, "S").source
|
||||
if source:sub(1, 1) == "@" then
|
||||
@@ -342,8 +370,24 @@ local function build_full_name_from_payload(payload)
|
||||
return title, payload.fullTitle or title
|
||||
end
|
||||
|
||||
local function build_hook_name_from_payload(payload)
|
||||
local hook_type = payload.hookType or payload.hook_type or payload.title or "hook"
|
||||
local hook_label = tostring(hook_type)
|
||||
local path = normalize_title_path(payload)
|
||||
if path then
|
||||
return hook_label .. ": " .. table.concat(path, "/"), hook_label .. " " .. table.concat(path, " ")
|
||||
end
|
||||
if payload.fullTitle and payload.fullTitle ~= "" then
|
||||
return hook_label .. ": " .. payload.fullTitle, hook_label .. " " .. payload.fullTitle
|
||||
end
|
||||
if payload.title and payload.title ~= "" then
|
||||
return hook_label .. ": " .. payload.title, hook_label .. " " .. payload.title
|
||||
end
|
||||
return hook_label, hook_label
|
||||
end
|
||||
|
||||
local function normalize_location(location)
|
||||
if not location then
|
||||
if not location or type(location) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
local filename = location.file or location.filename
|
||||
@@ -424,9 +468,80 @@ local function record_ndjson_result(state, status, payload)
|
||||
return nil
|
||||
end
|
||||
|
||||
local function record_hook_failure(state, payload)
|
||||
local full_name, mocha_title = build_hook_name_from_payload(payload or {})
|
||||
if not full_name then
|
||||
return nil
|
||||
end
|
||||
state.mocha_titles[full_name] = mocha_title
|
||||
|
||||
if not state.seen.failures[full_name] then
|
||||
state.seen.failures[full_name] = true
|
||||
table.insert(state.results.failures, full_name)
|
||||
end
|
||||
|
||||
local location = normalize_location(payload.location)
|
||||
local output_lines = output_lines_from_payload(payload)
|
||||
local lines = {}
|
||||
append_lines(lines, output_lines)
|
||||
append_lines(lines, build_error_lines(payload.error))
|
||||
if #lines > 0 then
|
||||
state.outputs[full_name] = lines
|
||||
end
|
||||
|
||||
if not location and payload.error and payload.error.stack then
|
||||
location = extract_location(payload.error.stack)
|
||||
end
|
||||
if location then
|
||||
location.text = (payload.error and payload.error.message) or "hook failure"
|
||||
state.locations[full_name] = location
|
||||
end
|
||||
|
||||
if not state.outputs[full_name] or #state.outputs[full_name] == 0 then
|
||||
state.outputs[full_name] = { "failed" }
|
||||
end
|
||||
|
||||
return full_name
|
||||
end
|
||||
|
||||
local function decode_ndjson_payload(line)
|
||||
if not line or line == "" then
|
||||
return nil
|
||||
end
|
||||
local trimmed = vim.trim(line)
|
||||
if trimmed == "" then
|
||||
return nil
|
||||
end
|
||||
local ok, payload = pcall(vim.json.decode, trimmed)
|
||||
if ok and type(payload) == "table" then
|
||||
return payload
|
||||
end
|
||||
local start = trimmed:find("{", 1, true)
|
||||
if not start then
|
||||
return nil
|
||||
end
|
||||
local last = nil
|
||||
for i = #trimmed, 1, -1 do
|
||||
if trimmed:sub(i, i) == "}" then
|
||||
last = i
|
||||
break
|
||||
end
|
||||
end
|
||||
if not last or last <= start then
|
||||
return nil
|
||||
end
|
||||
local candidate = trimmed:sub(start, last)
|
||||
ok, payload = pcall(vim.json.decode, candidate)
|
||||
if ok and type(payload) == "table" then
|
||||
return payload
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function parse_ndjson_line(line, state)
|
||||
local ok, payload = pcall(vim.json.decode, line)
|
||||
if not ok or type(payload) ~= "table" then
|
||||
debug_log_line("on_line_raw", line)
|
||||
local payload = decode_ndjson_payload(line)
|
||||
if not payload then
|
||||
return nil
|
||||
end
|
||||
if payload.event == "test" then
|
||||
@@ -434,6 +549,11 @@ local function parse_ndjson_line(line, state)
|
||||
if name then
|
||||
return { event = payload.status, name = name }
|
||||
end
|
||||
elseif payload.event == "hook" and payload.status == "failed" then
|
||||
local name = record_hook_failure(state, payload)
|
||||
if name then
|
||||
return { event = "failed", 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)
|
||||
@@ -573,6 +693,7 @@ function runner.build_all_command(bufnr)
|
||||
if not cwd then
|
||||
return { cmd = { "echo", "no package.json found" } }
|
||||
end
|
||||
local glob = config and config.all_test_glob or default_config.all_test_glob
|
||||
return {
|
||||
cmd = {
|
||||
"npx",
|
||||
@@ -583,7 +704,7 @@ function runner.build_all_command(bufnr)
|
||||
RUNNER_PATHS.ui,
|
||||
"--reporter",
|
||||
RUNNER_PATHS.reporter,
|
||||
"test/**/*.{test,spec}.{t,j}s",
|
||||
glob,
|
||||
},
|
||||
cwd = cwd,
|
||||
}
|
||||
@@ -662,11 +783,15 @@ function runner.output_parser()
|
||||
return results
|
||||
end,
|
||||
on_complete = function(output, state)
|
||||
debug_log_line("on_complete_raw", output)
|
||||
state = ensure_state(state or {})
|
||||
local parsed = parse_ndjson_output(output)
|
||||
runner._last_locations = parsed.locations
|
||||
runner._last_mocha_titles = parsed.mocha_titles
|
||||
return nil
|
||||
if #state.results.passes > 0 or #state.results.failures > 0 or #state.results.skips > 0 then
|
||||
return nil
|
||||
end
|
||||
return parsed.results
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -14,6 +14,7 @@ const {
|
||||
EVENT_TEST_PASS,
|
||||
EVENT_TEST_FAIL,
|
||||
EVENT_TEST_PENDING,
|
||||
EVENT_HOOK_FAIL,
|
||||
} = Mocha.Runner.constants;
|
||||
|
||||
function serializeError(err) {
|
||||
@@ -81,6 +82,10 @@ class NdjsonReporter {
|
||||
this.emit(this.testPayload('failed', test, serializeError(err)));
|
||||
})
|
||||
|
||||
.on(EVENT_HOOK_FAIL, (hook, err) => {
|
||||
this.emit(this.hookPayload('failed', hook, serializeError(err)));
|
||||
})
|
||||
|
||||
.on(EVENT_TEST_PENDING, (test) => {
|
||||
// Mocha "pending" includes:
|
||||
// - TODO tests (no function)
|
||||
@@ -169,6 +174,43 @@ class NdjsonReporter {
|
||||
return payload;
|
||||
}
|
||||
|
||||
hookPayload(status, hook, errorObj) {
|
||||
const currentTest = hook && hook.ctx && hook.ctx.currentTest ? hook.ctx.currentTest : null;
|
||||
const hookType = hook && hook.type ? hook.type : 'hook';
|
||||
const titlePath =
|
||||
(currentTest && safeTitlePath(currentTest)) ||
|
||||
(hook && hook.parent && safeTitlePath(hook.parent)) ||
|
||||
safeTitlePath(hook);
|
||||
const fullTitle =
|
||||
(currentTest && typeof currentTest.fullTitle === 'function' && currentTest.fullTitle()) ||
|
||||
(hook && hook.title) ||
|
||||
null;
|
||||
const file =
|
||||
(hook && hook.file) ||
|
||||
(currentTest && currentTest.file) ||
|
||||
(hook && hook.parent && hook.parent.file) ||
|
||||
null;
|
||||
const location =
|
||||
(currentTest && currentTest._akLocation) ||
|
||||
(hook && hook._akLocation) ||
|
||||
null;
|
||||
|
||||
const payload = {
|
||||
event: 'hook',
|
||||
status,
|
||||
hookType,
|
||||
title: hook && hook.title ? hook.title : null,
|
||||
fullTitle,
|
||||
titlePath,
|
||||
file,
|
||||
location,
|
||||
};
|
||||
|
||||
if (status === 'failed') payload.error = errorObj;
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
emit(obj) {
|
||||
this.originalStdoutWrite(JSON.stringify(obj) + '\n');
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ local function with_project(structure, fn)
|
||||
end
|
||||
|
||||
describe("test-samurai-mocha-runner", function()
|
||||
before_each(function()
|
||||
runner.setup()
|
||||
end)
|
||||
|
||||
it("detects mocha test files via package.json", function()
|
||||
with_project({
|
||||
["package.json"] = [[{"devDependencies":{"mocha":"10.0.0"}}]],
|
||||
@@ -170,7 +174,18 @@ describe("test-samurai-mocha-runner", function()
|
||||
}, function(root)
|
||||
local bufnr = make_buffer(root .. "/test/sample.spec.js", "")
|
||||
local command = runner.build_all_command(bufnr)
|
||||
assert.is_true(vim.tbl_contains(command.cmd, "test/**/*.{test,spec}.{t,j}s"))
|
||||
assert.is_true(vim.tbl_contains(command.cmd, "test/**/*.test.js"))
|
||||
end)
|
||||
end)
|
||||
|
||||
it("allows overriding the TSamAll test glob via setup", function()
|
||||
with_project({
|
||||
["package.json"] = [[{"devDependencies":{"mocha":"10.0.0"}}]],
|
||||
}, function(root)
|
||||
runner.setup({ all_test_glob = "spec/**/*_spec.js" })
|
||||
local bufnr = make_buffer(root .. "/test/sample.spec.js", "")
|
||||
local command = runner.build_all_command(bufnr)
|
||||
assert.is_true(vim.tbl_contains(command.cmd, "spec/**/*_spec.js"))
|
||||
end)
|
||||
end)
|
||||
|
||||
@@ -252,6 +267,92 @@ describe("test-samurai-mocha-runner", function()
|
||||
assert.equals(2, skip_items[1].col)
|
||||
end)
|
||||
|
||||
it("captures hook failures in results and output", function()
|
||||
local output = vim.json.encode({
|
||||
event = "hook",
|
||||
status = "failed",
|
||||
hookType = "beforeEach",
|
||||
titlePath = { "Math", "adds" },
|
||||
fullTitle = "Math adds",
|
||||
file = "/tmp/math.spec.js",
|
||||
error = {
|
||||
message = "boom",
|
||||
stack = "Error: boom\n at Context.<anonymous> (/tmp/math.spec.js:5:3)",
|
||||
},
|
||||
})
|
||||
|
||||
local results = runner.parse_results(output)
|
||||
assert.are.same({ "beforeEach: Math/adds" }, results.failures)
|
||||
|
||||
local outputs = runner.parse_test_output(output)
|
||||
assert.is_table(outputs["beforeEach: Math/adds"])
|
||||
assert.is_true(table.concat(outputs["beforeEach: Math/adds"], "\n"):match("boom") ~= nil)
|
||||
|
||||
local items = runner.collect_failed_locations(results.failures)
|
||||
assert.are.same({
|
||||
{ filename = "/tmp/math.spec.js", lnum = 5, col = 3, text = "boom" },
|
||||
}, items)
|
||||
end)
|
||||
|
||||
it("parses mocha hook failures emitted as test events", function()
|
||||
local output = vim.json.encode({
|
||||
event = "test",
|
||||
status = "failed",
|
||||
title = "\"before each\" hook for \"findAllInquiries()\"",
|
||||
fullTitle = "BlacklistRepository... \"before each\" hook for \"findAllInquiries()\"",
|
||||
titlePath = { "BlacklistRepository...", "\"before each\" hook for \"findAllInquiries()\"" },
|
||||
file = "/tmp/BlacklistRepository.test.js",
|
||||
location = nil,
|
||||
error = {
|
||||
message = "missing dbName",
|
||||
stack = "Error: missing dbName\n at Context.<anonymous> (/tmp/BlacklistRepository.test.js:10:25)",
|
||||
},
|
||||
})
|
||||
|
||||
local results = runner.parse_results(output)
|
||||
assert.are.same({
|
||||
"BlacklistRepository.../\"before each\" hook for \"findAllInquiries()\"",
|
||||
}, results.failures)
|
||||
|
||||
local outputs = runner.parse_test_output(output)
|
||||
assert.is_true(table.concat(outputs[results.failures[1]], "\n"):match("missing dbName") ~= nil)
|
||||
|
||||
local items = runner.collect_failed_locations(results.failures)
|
||||
assert.are.same({
|
||||
{ filename = "/tmp/BlacklistRepository.test.js", lnum = 10, col = 25, text = "missing dbName" },
|
||||
}, items)
|
||||
end)
|
||||
|
||||
it("handles null location fields in ndjson output", function()
|
||||
local output = [[{"event":"test","status":"failed","title":"\"before each\" hook for \"findAllInquiries()\"","fullTitle":"BlacklistRepository... \"before each\" hook for \"findAllInquiries()\"","titlePath":["BlacklistRepository...","\"before each\" hook for \"findAllInquiries()\""],"file":"/tmp/BlacklistRepository.test.js","location":null,"error":{"name":"Error","message":"missing dbName","stack":"Error: missing dbName\n at Context.<anonymous> (/tmp/BlacklistRepository.test.js:10:25)"}}]]
|
||||
local results = runner.parse_results(output)
|
||||
assert.are.same({
|
||||
"BlacklistRepository.../\"before each\" hook for \"findAllInquiries()\"",
|
||||
}, results.failures)
|
||||
end)
|
||||
|
||||
it("writes debug logs when configured", function()
|
||||
local log_path = vim.fn.tempname()
|
||||
runner.setup({ debug_log_path = log_path })
|
||||
local parser = runner.output_parser()
|
||||
local state = {}
|
||||
local line = 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 },
|
||||
})
|
||||
parser.on_line(line, state)
|
||||
parser.on_complete(line, state)
|
||||
local lines = vim.fn.readfile(log_path)
|
||||
assert.is_true(#lines >= 2)
|
||||
assert.is_true(lines[1]:match("on_line_raw") ~= nil)
|
||||
assert.is_true(lines[2]:match("on_complete_raw") ~= nil)
|
||||
end)
|
||||
|
||||
it("builds failed-only command with grep pattern", function()
|
||||
runner._last_mocha_titles = {
|
||||
["Math/adds"] = "Math adds",
|
||||
@@ -319,7 +420,7 @@ describe("test-samurai-mocha-runner", function()
|
||||
assert.equals("skipped", outputs["Math/skips"][1])
|
||||
end)
|
||||
|
||||
it("does not return results on complete", function()
|
||||
it("returns results on complete when no streaming state exists", function()
|
||||
local parser = runner.output_parser()
|
||||
local state = {}
|
||||
local output = vim.json.encode({
|
||||
@@ -332,6 +433,23 @@ describe("test-samurai-mocha-runner", function()
|
||||
location = { file = "/tmp/math.spec.js", line = 3, column = 2 },
|
||||
})
|
||||
local results = parser.on_complete(output, state)
|
||||
assert.are.same({ "Math/adds" }, results.passes)
|
||||
end)
|
||||
|
||||
it("does not return results on complete when streaming already handled", function()
|
||||
local parser = runner.output_parser()
|
||||
local state = {}
|
||||
local line = 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 },
|
||||
})
|
||||
parser.on_line(line, state)
|
||||
local results = parser.on_complete(line, state)
|
||||
assert.is_nil(results)
|
||||
end)
|
||||
end)
|
||||
|
||||
Reference in New Issue
Block a user