From 29ddc34add39952a3061671d8b6d3fd9f919b364 Mon Sep 17 00:00:00 2001 From: "M.Schirmer" Date: Mon, 5 Jan 2026 08:58:48 +0100 Subject: [PATCH] fix TSamNearest and fully support jump Keymap --- README.md | 21 +- lua/test-samurai-mocha-runner/init.lua | 413 +++++++++++++++++-------- scripts/bdd-with-location.cjs | 139 +++++++++ scripts/mocha-ndjson-reporter.cjs | 131 ++++++++ tests/test_mocha_runner_spec.lua | 201 +++++++++--- 5 files changed, 736 insertions(+), 169 deletions(-) create mode 100644 scripts/bdd-with-location.cjs create mode 100644 scripts/mocha-ndjson-reporter.cjs diff --git a/README.md b/README.md index 121ae0e..9443573 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,10 @@ Mocha.js runner for the test-samurai Neovim plugin. - Detects Mocha test files by checking `package.json` dependencies. - 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. - 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. ## 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 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 -The runner expects Mocha's built-in `json-stream` reporter, which emits one JSON -object per line. It consumes the following fields when present: +The runner uses the bundled NDJSON reporter at `scripts/mocha-ndjson-reporter.cjs`, +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", "fullTitle": "Mocha full title", + "titlePath": ["Parent", "Child", "Test"], "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 Add the module to your test-samurai configuration: diff --git a/lua/test-samurai-mocha-runner/init.lua b/lua/test-samurai-mocha-runner/init.lua index f18dd9b..3d1f329 100644 --- a/lua/test-samurai-mocha-runner/init.lua +++ b/lua/test-samurai-mocha-runner/init.lua @@ -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 diff --git a/scripts/bdd-with-location.cjs b/scripts/bdd-with-location.cjs new file mode 100644 index 0000000..5d25826 --- /dev/null +++ b/scripts/bdd-with-location.cjs @@ -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']; diff --git a/scripts/mocha-ndjson-reporter.cjs b/scripts/mocha-ndjson-reporter.cjs new file mode 100644 index 0000000..6d0291d --- /dev/null +++ b/scripts/mocha-ndjson-reporter.cjs @@ -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; diff --git a/tests/test_mocha_runner_spec.lua b/tests/test_mocha_runner_spec.lua index 44f5046..4fa66e8 100644 --- a/tests/test_mocha_runner_spec.lua +++ b/tests/test_mocha_runner_spec.lua @@ -42,35 +42,82 @@ describe("test-samurai-mocha-runner", function() end) end) - it("finds nearest with test > suite > file priority", function() + it("finds nearest with precise scope selection", function() with_project({ ["package.json"] = [[{"devDependencies":{"mocha":"10.0.0"}}]], }, function(root) - local content = table.concat({ - "describe(\"Math\", function() {", - " it(\"adds\", function() {", - " expect(1).to.equal(1)", - " })", + local lines = { + "// #1", + "describe(\"outer\", () => {", "", - " it(\"subs\", function() {", - " expect(1).to.equal(1)", - " })", - "})", + " it(\"on stage 1\"); // #2", "", - "const value = 1", - }, "\n") + " it(\"i2\", () => { // #3", + " // #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 spec_test = assert(runner.find_nearest(bufnr, 3, 0)) - assert.equals("test", spec_test.kind) - assert.equals("Math/adds", spec_test.full_name) + local markers = {} + for i, line in ipairs(lines) do + 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)) - assert.equals("suite", spec_suite.kind) - assert.equals("Math", spec_suite.full_name) - - local spec_file = assert(runner.find_nearest(bufnr, 11, 0)) + local spec_file = assert(runner.find_nearest(bufnr, markers[1], 0)) 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) @@ -92,7 +139,7 @@ describe("test-samurai-mocha-runner", function() assert.are.same({ "echo", "no package.json found" }, command.cmd) end) - it("builds command with mocha json-stream and grep", function() + it("builds command with custom ui and reporter and grep", function() with_project({ ["package.json"] = [[{"devDependencies":{"mocha":"10.0.0"}}]], }, function(root) @@ -106,8 +153,13 @@ describe("test-samurai-mocha-runner", function() local command = runner.build_command(spec) assert.equals("npx", command.cmd[1]) 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, "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")) end) end) @@ -124,18 +176,49 @@ describe("test-samurai-mocha-runner", function() it("streams results without duplicates", function() local output_lines = { - [=["suite",{"title":"Math","fullTitle":"Math"}]=], - [=["pass",{"title":"adds","fullTitle":"Math adds","file":"/tmp/math.spec.js"}]=], - [=["pass",{"title":"adds","fullTitle":"Math adds","file":"/tmp/math.spec.js"}]=], - [=["fail",{"title":"subs","fullTitle":"Math subs","file":"/tmp/math.spec.js","err":"oops","stack":"Error: oops\n at Context. (/tmp/math.spec.js:10:5)"}]=], - [=["pending",{"title":"skips","fullTitle":"Math skips","file":"/tmp/math.spec.js"}]=], - [=["suite end",{"title":"Math","fullTitle":"Math"}]=], + 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 = "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. (/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 state = {} local aggregated = { passes = {}, failures = {}, skips = {} } for _, line in ipairs(output_lines) do - local results = parser.on_line("[" .. line .. "]", state) + local results = parser.on_line(line, state) if results then for _, name in ipairs(results.passes or {}) do 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(10, items[1].lnum) 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) 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() local output = table.concat({ - [=["suite",{"title":"Math","fullTitle":"Math"}]=], - [=["fail",{"title":"subs","fullTitle":"Math subs","file":"/tmp/math.spec.js","err":"oops","stack":"Error: oops\n at Context. (/tmp/math.spec.js:10:5)\n at processImmediate"}]=], - [=["suite end",{"title":"Math","fullTitle":"Math"}]=], + 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. (/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") - local results = runner.parse_results("[" .. output:gsub("\n", "]\n[") .. "]") + local results = runner.parse_results(output) 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/adds"] ~= nil) + assert.is_true(outputs["Math/skips"] ~= nil) end) it("does not return results on complete", function() local parser = runner.output_parser() 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) assert.is_nil(results) end)