--- Result statuses emitted for test, hook, and synthetic records. --- @alias test.harness.ResultStatus ---| 'success' ---| 'pending' ---| 'failure' ---| 'error' --- Hook phases supported by the chunk environment. --- @alias test.harness.HookKind ---| 'setup' ---| 'teardown' ---| 'before_each' ---| 'after_each' --- Execution scopes that can run under harness error handling. --- @alias test.harness.ExecutionScope ---| 'test' ---| 'setup' ---| 'teardown' ---| 'before_each' ---| 'after_each' ---| 'suite_end' --- Source location recorded for defined suites, tests, and hooks. --- @class test.harness.Trace --- @field short_src string --- @field currentline integer --- Structured error payload used internally by the harness. --- @class test.harness.ErrorPayload --- @field __harness_pending? boolean --- @field message string --- @field trace? test.harness.Trace --- @field traceback? string --- @alias test.harness.Element --- | test.harness.Suite --- | test.harness.Test --- Base node shared by suite and test definitions. --- @class test.harness.ElementBase --- @field name string --- @field parent? test.harness.Suite --- @field trace? test.harness.Trace --- @field duration? number --- @field full_name? string --- Registered callback together with the location where it was registered. --- @class test.harness.RegisteredCallback --- @field fn fun() --- @field trace? test.harness.Trace --- Hook callbacks grouped by phase. --- @class test.harness.HookSet --- @field setup test.harness.RegisteredCallback[] --- @field teardown test.harness.RegisteredCallback[] --- @field before_each test.harness.RegisteredCallback[] --- @field after_each test.harness.RegisteredCallback[] --- Suite node containing hooks and nested children. --- @class test.harness.Suite : test.harness.ElementBase --- @field kind 'suite' --- @field is_file boolean --- @field children test.harness.Element[] --- @field selected_count integer --- @field hooks test.harness.HookSet --- Test node containing an optional runnable body. --- @class test.harness.Test : test.harness.ElementBase --- @field kind 'test' --- @field fn? fun() --- @field parent test.harness.Suite --- @field pending_message? string --- @field selected? boolean --- Normalized result returned from running a test or hook. --- @class test.harness.Result --- @field status test.harness.ResultStatus --- @field message? string --- @field traceback? string --- @field trace? test.harness.Trace --- Collected test file path plus its display label. --- @class test.harness.FileEntry --- @field path string --- @field display_name string --- Shallow process baseline restored between test files. --- Mutable tables intentionally preserve identity here; deeper isolation --- requires separate processes rather than table cloning inside one Lua state. --- @class test.harness.RuntimeBaseline --- @field cwd string --- @field package_path string --- @field package_cpath string --- @field package_preload table --- @field globals table --- @field loaded table --- @field env table --- @field arg table --- Parsed CLI options controlling one harness run. --- @class test.harness.Options --- @field keep_going boolean --- @field verbose boolean --- @field repeat_count integer --- @field summary_file string --- @field helper? string --- @field tags string[] --- @field filter? string --- @field filter_out string[] --- @field lpaths string[] --- @field cpaths string[] --- @field paths string[] --- Stored suite-end callback together with its registration site. --- @class test.harness.SuiteEndRegistration : test.harness.RegisteredCallback --- @field key string --- Active execution context for one running hook or test. --- @class test.harness.Execution --- @field scope test.harness.ExecutionScope --- @field finalizers test.harness.RegisteredCallback[] --- Mutable harness state shared across definition and execution. --- @class test.harness.State --- @field suite_end_callbacks test.harness.SuiteEndRegistration[] --- @field current_define_suite? test.harness.Suite --- @field current_execution? test.harness.Execution local uv = vim.uv --- Public test harness module surface. --- @class test.harness --- @field is_ci fun(name?: 'github'): boolean --- @field on_suite_end fun(callback: fun()): fun() --- @field read_nvim_log fun(logfile?: string, ci_rename?: boolean): string? local M = {} --- @type test.harness.State local state = { suite_end_callbacks = {}, current_define_suite = nil, current_execution = nil, } --- Return the current wall-clock time in seconds. --- @return number local function now_seconds() local sec, usec = assert(uv.gettimeofday()) return sec + usec * 1e-6 end --- Check whether the harness is running in CI, optionally for one provider. --- @param name? 'github' --- @return boolean function M.is_ci(name) local any_provider = (name == nil) assert(any_provider or name == 'github') local github_actions = ((any_provider or name == 'github') and nil ~= os.getenv('GITHUB_ACTIONS')) return github_actions end --- Read the last `keep` lines from a file. --- @param path string --- @param keep integer --- @return string[]? local function read_tail_lines(path, keep) local file = io.open(path, 'r') if not file then return nil end local lines = {} for line in file:lines() do lines[#lines + 1] = line if #lines > keep then table.remove(lines, 1) end end file:close() return lines end -- TODO(lewis6991): move out of harness --- Read and optionally rename the current Nvim log for failure output. --- @param logfile? string --- @param ci_rename? boolean --- @return string? function M.read_nvim_log(logfile, ci_rename) logfile = logfile or os.getenv('NVIM_LOG_FILE') or 'nvim.log' if not uv.fs_stat(logfile) then return end local ci = M.is_ci() local keep = ci and 100 or 10 local lines = read_tail_lines(logfile, keep) or {} local separator = ('-'):rep(78) local parts = { separator, '\n', string.format('$NVIM_LOG_FILE: %s\n', logfile), #lines > 0 and string.format('(last %d lines)\n', keep) or '(empty)\n', } for _, line in ipairs(lines) do parts[#parts + 1] = line parts[#parts + 1] = '\n' end if ci and ci_rename then os.rename(logfile, logfile .. '.displayed') end parts[#parts + 1] = separator parts[#parts + 1] = '\n' return table.concat(parts) end --- Normalize a path relative to the current working directory. --- @param path string --- @return string local function normalize_path(path) return vim.fs.normalize(vim.fs.abspath(path)) end --- Render a path relative to the current working directory when possible. --- @param path string --- @return string local function display_path(path) path = normalize_path(path) local relative = vim.fs.relpath('.', path) if relative then return relative end return path end --- Restore a table to a previously captured shallow snapshot. --- @generic K, V --- @param current table --- @param snapshot table --- @param unset? fun(key: K) --- @param set? fun(key: K, value: V) local function restore_snapshot(current, snapshot, unset, set) unset = unset or function(k) rawset(current, k, nil) end set = set or function(k, v) rawset(current, k, v) end for k in pairs(current) do if rawget(snapshot, k) == nil then unset(k) end end for k, v in pairs(snapshot) do set(k, v) end end --- Restore the process state to a captured baseline. --- @param baseline test.harness.RuntimeBaseline local function restore_runtime_baseline(baseline) if uv.cwd() ~= baseline.cwd then uv.chdir(baseline.cwd) end package.path = baseline.package_path package.cpath = baseline.package_cpath restore_snapshot(package.preload, baseline.package_preload) restore_snapshot(package.loaded, baseline.loaded) restore_snapshot(_G, baseline.globals) restore_snapshot(uv.os_environ(), baseline.env, uv.os_unsetenv, uv.os_setenv) _G.arg = vim._copy(baseline.arg) state.current_define_suite = nil state.current_execution = nil end --- Restore the baseline and run GC cleanup. --- @param baseline test.harness.RuntimeBaseline local function cleanup_runtime_baseline(baseline) restore_runtime_baseline(baseline) -- One full cycle may only run __gc/finalizers for dead userdata/cdata. -- Those finalizers can release the last references to more uv/mpack objects, -- which do not become collectible until the next cycle. Collect twice before -- switching files or ending the harness so leak checks see only live state. collectgarbage('collect') collectgarbage('collect') end local harness_source = debug.getinfo(1, 'S').source local test_assert = require('test.assert') local assert_source = debug.getinfo(test_assert.eq, 'S').source --- @param info? debug.Info --- @return test.harness.Trace local function trace_from_info(info) return { short_src = info and vim.fs.normalize(info.short_src or '') or '', currentline = info and info.currentline or 0, } end --- Capture the source location of a caller frame. --- Walk upward until we find a user Lua frame so Lua 5.1 tail-call elision --- does not collapse `it()`/hook registrations into `(tail call) @ -1`. --- @param level? integer --- @return test.harness.Trace local function caller_trace(level) local frame = level or 3 local fallback while true do local info = debug.getinfo(frame, 'Sln') if not info then return trace_from_info(fallback) end fallback = fallback or info if info.what ~= 'C' and info.source ~= harness_source and info.short_src ~= '(tail call)' and info.currentline > 0 then return trace_from_info(info) end frame = frame + 1 end end --- Register a suite-end callback, deduplicated by callsite. --- @param callback fun() --- @return fun() function M.on_suite_end(callback) assert(type(callback) == 'function', 'on_suite_end() expects a function') local trace = caller_trace(3) local caller_info = debug.getinfo(2, 'S') local key_source = caller_info and caller_info.source if type(key_source) == 'string' and vim.startswith(key_source, '@') then key_source = vim.fs.normalize(key_source:sub(2)) else key_source = trace.short_src end local key = ('%s:%d'):format(key_source, trace.currentline) for _, registration in ipairs(state.suite_end_callbacks) do if registration.key == key then return registration.fn end end table.insert(state.suite_end_callbacks, { fn = callback, trace = trace, key = key, }) return callback end --- Create a suite node for the definition tree. --- @param name string? --- @param parent? test.harness.Suite --- @param trace? test.harness.Trace --- @param is_file? boolean --- @return test.harness.Suite local function create_suite(name, parent, trace, is_file) return { kind = 'suite', name = name or '', parent = parent, trace = trace, is_file = is_file or false, hooks = { setup = {}, teardown = {}, before_each = {}, after_each = {}, }, children = {}, selected_count = 0, } end --- Return the suite currently receiving test definitions. --- @return test.harness.Suite local function current_suite() assert(state.current_define_suite, 'test definition is not active') return state.current_define_suite end --- Add a test node to the current suite. --- @param name string --- @param fn? fun() --- @param pending_message? string --- @return test.harness.Test local function register_test(name, fn, pending_message) assert(type(name) == 'string' and name ~= '', 'test name must be a non-empty string') if fn ~= nil then assert(type(fn) == 'function', 'test body must be a function') end local suite = current_suite() local test = { kind = 'test', name = name, fn = fn, parent = suite, trace = caller_trace(3), pending_message = pending_message, } table.insert(suite.children, test) return test end --- Build a hook registrar exposed in the test chunk environment. --- @param kind test.harness.HookKind --- @return fun(fn: fun()) local function chunk_hook(kind) return function(fn) assert(type(fn) == 'function', ('%s expects a function'):format(kind)) table.insert(current_suite().hooks[kind], { fn = fn, trace = caller_trace(3), }) end end -- Chunk environment local chunk_env = { _G = _G, assert = test_assert, setup = chunk_hook('setup'), teardown = chunk_hook('teardown'), before_each = chunk_hook('before_each'), after_each = chunk_hook('after_each'), } --- Define a nested suite in the chunk environment. --- @param name string --- @param fn fun() --- @return test.harness.Suite function chunk_env.describe(name, fn) assert(type(name) == 'string', 'describe() expects a string') assert(type(fn) == 'function', 'describe() expects a function body') local parent = current_suite() local suite = create_suite(name, parent, caller_trace(3), false) table.insert(parent.children, suite) local previous_define_suite = state.current_define_suite state.current_define_suite = suite local ok, err = xpcall(fn, debug.traceback) state.current_define_suite = previous_define_suite if not ok then error(err, 0) end return suite end --- Define a test in the chunk environment. --- @param name string --- @param fn? fun() --- @return test.harness.Test function chunk_env.it(name, fn) return register_test(name, fn, nil) end --- Mark the current test as pending or define a pending test. --- When called while a test or hook is running, this aborts the current --- execution and reports the current test as pending with `name` as the --- pending message. --- When called during file definition, this registers a new pending test in --- the current suite. In that form, `block` may be a string used as the --- pending message. --- @param name? string --- @param block? fun()|string --- @return boolean function chunk_env.pending(name, block) if state.current_execution then error({ __harness_pending = true, message = name or 'pending', }, 0) end local pending_message = type(block) == 'string' and block or nil register_test(name or 'pending', nil, pending_message) return false end --- Register a finalizer to run after the current test body. --- @param fn fun() function chunk_env.finally(fn) assert(type(fn) == 'function', 'finally() expects a function') assert( state.current_execution and state.current_execution.scope == 'test', 'finally() must be called while a test body is running' ) table.insert(state.current_execution.finalizers, { fn = fn, trace = caller_trace(3), }) end --- Convert an arbitrary error value into printable text. --- @param err any --- @return string local function format_error_value(err) if type(err) == 'string' then return err end local ok, inspected = pcall(vim.inspect, err) if ok then return inspected end return tostring(err) end --- Parse one traceback line into a source location. --- @param text? string --- @return test.harness.Trace? local function parse_trace_line(text) if type(text) ~= 'string' then return end local src, line = text:match('^(.+):(%d+):') if not src or not line then return end return { short_src = vim.fs.normalize(src), currentline = tonumber(line) or 0, } end --- @param source string --- @param candidate string --- @return boolean local function source_matches(source, candidate) if source == candidate then return true end local tail = source:match('^%.%.%.(.+)$') return tail ~= nil and vim.endswith(candidate, tail) end --- @param left? test.harness.Trace --- @param right? test.harness.Trace --- @return boolean local function same_trace_source(left, right) if left == nil or right == nil then return false end return source_matches(left.short_src, right.short_src) or source_matches(right.short_src, left.short_src) end --- Extract a source location from an error message or traceback. --- @param message? string --- @param traceback? string --- @return test.harness.Trace? local function parse_error_trace(message, traceback) if type(message) == 'string' then local first_line = message:match('^[^\n]+') local trace = parse_trace_line(first_line) if trace then return trace end local load_path = first_line and first_line:match("^error loading module .+ from file '([^']+)':$") if load_path == nil and first_line then load_path = first_line:match('^error loading module .+ from file "([^"]+)":$') end if load_path then local detail_line = message:match('\n([^\n]+)') trace = parse_trace_line(detail_line and detail_line:gsub('^%s+', '')) local load_trace = { short_src = vim.fs.normalize(load_path), currentline = 0, } if trace and same_trace_source(trace, load_trace) then return trace end end end if type(traceback) == 'string' then for line in traceback:gmatch('[^\n]+') do local trace = parse_trace_line(line:gsub('^%s+', '')) if trace then return trace end end end end --- Capture only user-visible Lua frames for test and hook failures. --- Once execution returns to the harness, the rest of the stack is framework --- noise and should not be printed in verbose failure output. --- @return string? local function build_error_traceback() local lines = {} for level = 3, math.huge do local info = debug.getinfo(level, 'Sln') if not info then break end if info.source == harness_source then break end if (info.what == 'Lua' or info.what == 'main') and info.source ~= assert_source then local trace = trace_from_info(info) if trace.currentline > 0 and trace.short_src ~= '' then local location = ('%s:%d'):format(trace.short_src, trace.currentline) if info.what == 'main' then lines[#lines + 1] = ('\t%s: in main chunk'):format(location) elseif info.name and info.name ~= '' then lines[#lines + 1] = ("\t%s: in function '%s'"):format(location, info.name) elseif (info.linedefined or 0) > 0 then lines[#lines + 1] = ('\t%s: in function <%s:%d>'):format( location, trace.short_src, info.linedefined ) else lines[#lines + 1] = ('\t%s: in function ?'):format(location) end end end end if #lines == 0 then return end return 'stack traceback:\n' .. table.concat(lines, '\n') end --- Normalize thrown values into harness error payloads. --- @param err any --- @return test.harness.ErrorPayload local function exception_handler(err) if type(err) == 'table' and err.__harness_pending then --- @cast err test.harness.ErrorPayload return err end local message = format_error_value(err) local raw_traceback = debug.traceback('', 2) return { message = message, trace = parse_error_trace(message, raw_traceback), traceback = build_error_traceback() or raw_traceback, } end --- Convert a handled error payload into a test result. --- @param err test.harness.ErrorPayload --- @param fallback_status? test.harness.ResultStatus --- @return test.harness.Result local function decode_error(err, fallback_status) return { status = err.__harness_pending and 'pending' or fallback_status or 'failure', message = err.message, traceback = err.traceback, trace = err.trace or parse_error_trace(err.message, err.traceback), } end --- Run a callable under harness error handling and finalizer cleanup. --- @param scope test.harness.ExecutionScope --- @param callable test.harness.RegisteredCallback --- @param fallback_status? test.harness.ResultStatus --- @return test.harness.Result, test.harness.Trace? local function run_callable(scope, callable, fallback_status) local previous_execution = state.current_execution --- @type test.harness.Execution local execution = { scope = scope, finalizers = {}, } state.current_execution = execution local ok, err = xpcall(callable.fn, exception_handler) local finalizer_err local finalizer_trace for i = #execution.finalizers, 1, -1 do local finalizer = execution.finalizers[i] local finalizer_ok, ferr = xpcall(finalizer.fn, exception_handler) if not finalizer_ok and not finalizer_err then finalizer_err = ferr finalizer_trace = finalizer.trace end end state.current_execution = previous_execution local result = ok and { status = 'success' } or decode_error(err, fallback_status) local report_trace = not ok and callable.trace or nil if not finalizer_err then return result, report_trace end local finalizer_result = decode_error(finalizer_err, 'error') if result.status == 'success' then finalizer_result.status = 'error' return finalizer_result, finalizer_trace end if result.status == 'pending' then return { status = 'error', message = ('finally: %s'):format(finalizer_result.message), traceback = finalizer_result.traceback, trace = finalizer_result.trace, }, finalizer_trace end result.message = result.message .. '\n\nfinally: ' .. finalizer_result.message if not result.traceback then result.traceback = finalizer_result.traceback end if not result.trace then result.trace = finalizer_result.trace end return result, report_trace end --- Prefer the parsed trace only when it points at the same source file as the --- owning test or callback. Otherwise, fall back to the definition site. --- @param trace? test.harness.Trace --- @param fallback? test.harness.Trace --- @return test.harness.Trace? local function summary_trace(trace, fallback) if same_trace_source(trace, fallback) then return trace end return fallback or trace end --- Build the suite and test name parts used for reporting. --- @param element test.harness.Element? --- @return string[] local function full_name_parts(element) local parts = {} local node = element while node do if node.kind == 'test' and node.name ~= '' then table.insert(parts, 1, node.name) elseif node.kind == 'suite' and not node.is_file and node.name ~= '' then table.insert(parts, 1, node.name) end node = node.parent end return parts end --- Return the full hierarchical name for a suite or test. --- @param element test.harness.Element --- @return string function M.get_full_name(element) return table.concat(full_name_parts(element), ' ') end --- Check whether a test matches the current selection options. --- @param test test.harness.Test --- @param opts test.harness.Options --- @return boolean local function test_selected(test, opts) test.full_name = M.get_full_name(test) if #opts.tags > 0 then local tagged = false for tag in test.full_name:gmatch('#([%w_%-]+)') do if vim.list_contains(opts.tags, tag) then tagged = true break end end if not tagged then return false end end if opts.filter and not test.full_name:match(opts.filter) then return false end for _, filter_out in ipairs(opts.filter_out) do if test.full_name:match(filter_out) then return false end end return true end --- Mark selected tests in a suite subtree and count them. --- @param node test.harness.Suite --- @param opts test.harness.Options --- @return integer local function mark_selected(node, opts) local selected_count = 0 for _, child in ipairs(node.children) do if child.kind == 'suite' then selected_count = selected_count + mark_selected(child, opts) else --- @cast child test.harness.Test child.selected = test_selected(child, opts) if child.selected then selected_count = selected_count + 1 end end end node.selected_count = selected_count return selected_count end --- Collect inherited `before_each` hooks from outermost to innermost. --- @param suite test.harness.Suite --- @return test.harness.RegisteredCallback[] local function gather_before_each(suite) local hooks = {} if suite.parent then vim.list_extend(hooks, gather_before_each(suite.parent)) end for _, hook in ipairs(suite.hooks.before_each) do hooks[#hooks + 1] = hook end return hooks end --- Collect inherited `after_each` hooks from innermost to outermost. --- @param suite test.harness.Suite --- @return test.harness.RegisteredCallback[] local function gather_after_each(suite) local hooks = {} for _, hook in ipairs(suite.hooks.after_each) do hooks[#hooks + 1] = hook end if suite.parent then vim.list_extend(hooks, gather_after_each(suite.parent)) end return hooks end --- Finalized result record passed to the reporter and stored in summaries. --- @class test.harness.Record --- @field name string --- @field status test.harness.ResultStatus --- @field trace? test.harness.Trace --- @field duration number --- @field message? string --- @field traceback? string --- Execution summary accumulated by the harness for one suite iteration. --- @class test.harness.RunSummary --- @field file_count integer --- @field result_count integer --- @field test_count integer --- @field success_count integer --- @field skipped_count integer --- @field failure_count integer --- @field error_count integer --- @field pendings test.harness.Record[] --- @field failures test.harness.Record[] --- @field errors test.harness.Record[] --- Per-file summary accumulated by the harness while one file runs. --- @class test.harness.FileRunSummary --- @field test_count integer --- Build the reporter record for a completed result. --- @param name string --- @param result test.harness.Result --- @param trace? test.harness.Trace --- @param duration number --- @return test.harness.Record local function build_record(name, result, trace, duration) return { name = name, status = result.status, trace = trace, duration = duration, message = result.message, traceback = result.traceback, } end --- Record a completed test or synthetic result into the harness summary. --- @param summary test.harness.RunSummary --- @param file_summary? test.harness.FileRunSummary --- @param record test.harness.Record --- @param count_as_test? boolean local function record_result(summary, file_summary, record, count_as_test) summary.result_count = summary.result_count + 1 if count_as_test ~= false then summary.test_count = summary.test_count + 1 if file_summary then file_summary.test_count = file_summary.test_count + 1 end end if record.status == 'success' then summary.success_count = summary.success_count + 1 elseif record.status == 'pending' then summary.skipped_count = summary.skipped_count + 1 table.insert(summary.pendings, record) elseif record.status == 'failure' then summary.failure_count = summary.failure_count + 1 table.insert(summary.failures, record) else -- error summary.error_count = summary.error_count + 1 table.insert(summary.errors, record) end end --- Report a synthetic result as a test-shaped record. --- @param reporter test.base_reporter --- @param summary test.harness.RunSummary --- @param file_summary? test.harness.FileRunSummary --- @param parent test.harness.Suite --- @param phase string --- @param result test.harness.Result --- @param trace? test.harness.Trace --- @return test.harness.ResultStatus local function run_synthetic_result(reporter, summary, file_summary, parent, phase, result, trace) local name = M.get_full_name(parent) if name == '' then name = parent.name ~= '' and parent.name or 'suite' end name = ('%s [%s]'):format(name, phase) local record_trace = trace or result.trace or parent.trace local record = build_record(name, result, record_trace, 0) reporter:test_start(record.name) record_result(summary, file_summary, record, false) reporter:test_end(record) return record.status end --- Run registered suite-end callbacks and report failures. --- @param reporter test.base_reporter --- @param summary test.harness.RunSummary --- @return boolean local function run_suite_end_callbacks(reporter, summary) local suite_end_callbacks = vim._copy(state.suite_end_callbacks) local callback_failed = false for index, callback in ipairs(suite_end_callbacks) do local result, report_trace = run_callable('suite_end', callback, 'error') if result.status ~= 'success' then callback_failed = true result.status = 'error' local name = ('[suite_end %d]'):format(index) local record_trace = summary_trace(result.trace, report_trace or callback.trace) local record = build_record(name, result, record_trace, 0) reporter:test_start(record.name) record_result(summary, nil, record, false) reporter:test_end(record) end end return callback_failed end --- Run a single test with its surrounding before and after hooks. --- @param test test.harness.Test --- @param reporter test.base_reporter --- @param summary test.harness.RunSummary --- @param file_summary test.harness.FileRunSummary --- @return test.harness.ResultStatus local function run_test(test, reporter, summary, file_summary) local name = test.full_name or M.get_full_name(test) local start_time = now_seconds() --- @type test.harness.Result local result local report_trace = test.trace if test.fn == nil then reporter:test_start(name) result = { status = 'pending', message = test.pending_message, } else result = { status = 'success' } for _, hook in ipairs(gather_before_each(test.parent)) do result, report_trace = run_callable('before_each', hook, 'failure') if result.status ~= 'success' then break end end reporter:test_start(name) if result.status == 'success' then result, report_trace = run_callable('test', { fn = test.fn, trace = test.trace }, 'failure') end for _, hook in ipairs(gather_after_each(test.parent)) do local hook_result, hook_trace = run_callable('after_each', hook, 'failure') if result.status == 'success' then result = hook_result report_trace = hook_trace elseif hook_result.status ~= 'success' then local hook_report_trace = hook_trace or hook.trace result.message = (result.message or '') .. (result.message and result.message ~= '' and '\n\n' or '') .. 'after_each: ' .. hook_result.message if not result.traceback then result.traceback = hook_result.traceback end if not result.trace then result.trace = hook_result.trace end if result.status == 'pending' then result.status = 'error' report_trace = hook_report_trace elseif not report_trace then report_trace = hook_report_trace end end end end test.duration = now_seconds() - start_time local record = build_record( name, result, summary_trace(result.trace, report_trace or test.trace), test.duration ) record_result(summary, file_summary, record) reporter:test_end(record) return record.status end --- Run a suite subtree until completion or a stop condition. --- @param suite test.harness.Suite --- @param reporter test.base_reporter --- @param summary test.harness.RunSummary --- @param file_summary test.harness.FileRunSummary --- @param opts test.harness.Options --- @return boolean local function run_suite(suite, reporter, summary, file_summary, opts) if suite.selected_count == 0 then return false end local stop_requested = false local run_children = true -- Run setup() hooks for _, hook in ipairs(suite.hooks.setup) do local result = run_callable('setup', hook, 'error') if result.status ~= 'success' then run_synthetic_result(reporter, summary, file_summary, suite, 'setup', result, hook.trace) run_children = false if result.status ~= 'pending' and not opts.keep_going then stop_requested = true end break end end if run_children and not stop_requested then for _, child in ipairs(suite.children) do if child.kind == 'suite' then stop_requested = run_suite(child, reporter, summary, file_summary, opts) or stop_requested elseif child.selected then local status = run_test(child, reporter, summary, file_summary) if status ~= 'success' and status ~= 'pending' and not opts.keep_going then stop_requested = true end end if stop_requested then break end end end -- Run teardown() hooks for _, hook in ipairs(suite.hooks.teardown) do local result = run_callable('teardown', hook, 'error') if result.status ~= 'success' then run_synthetic_result(reporter, summary, file_summary, suite, 'teardown', result, hook.trace) if result.status ~= 'pending' and not opts.keep_going then stop_requested = true end end end return stop_requested end --- Collect test files from a file or directory path. --- @param path string --- @param files test.harness.FileEntry[] --- @param seen_files table --- @return boolean?, string? local function collect_test_files(path, files, seen_files) --- @param file string local function add_test_file(file) local abs_file = normalize_path(file) if seen_files[abs_file] then return end seen_files[abs_file] = true files[#files + 1] = { path = abs_file, display_name = display_path(abs_file), } end local abs = normalize_path(path) local stat = uv.fs_stat(abs) if not stat then return nil, ('test path not found: %s'):format(path) end if stat.type == 'file' then add_test_file(abs) return true end if stat.type ~= 'directory' then return nil, ('unsupported test path: %s'):format(path) end for _, file in ipairs(vim.fs.find(function(name) return name:match('_spec%.lua$') ~= nil end, { path = abs, type = 'file', limit = math.huge, })) do add_test_file(file) end return true end --- Parse harness CLI arguments into execution options. --- @param argv string[] --- @return test.harness.Options?, string? local function parse_args(argv) --- @type test.harness.Options local opts = { keep_going = true, verbose = false, repeat_count = 1, summary_file = '-', tags = {}, filter_out = {}, lpaths = {}, cpaths = {}, paths = {}, } --- @type table local seen_tags = {} --- @param flag string --- @return nil, string local function missing_value(flag) return nil, 'missing value for ' .. flag end --- Parse and validate the `--repeat` argument. --- @param value? string --- @return integer?, string? local function parse_repeat_count(value) if type(value) ~= 'string' or value == '' then return missing_value('--repeat') end local count = tonumber(value) if count == nil or count < 1 or count ~= math.floor(count) then return nil, ('invalid value for --repeat: %s'):format(value) end --- @cast count integer return count end --- @type table local switch_options = { ['-v'] = function() opts.verbose = true end, ['--verbose'] = function() opts.verbose = true end, ['--no-keep-going'] = function() opts.keep_going = false end, } --- @param flag string --- @param value string --- @return string?, string? local function require_nonempty(flag, value) if value == '' then return missing_value(flag) end return value end --- @param setter fun(value: any) --- @return fun(value: any): boolean?, string? local function set_value(setter) return function(value) setter(value) return true end end --- @param parse fun(value: string): any?, string? --- @param setter fun(value: any) --- @return fun(value: string): boolean?, string? local function set_parsed_value(parse, setter) return function(value) local parsed, err = parse(value) if parsed == nil then return nil, err end setter(parsed) return true end end --- @param flag string --- @param setter fun(value: string) --- @return fun(value: string): boolean?, string? local function set_nonempty_value(flag, setter) return set_parsed_value(function(value) return require_nonempty(flag, value) end, setter) end --- Validate that a filter option contains a valid Lua pattern. --- @param flag string --- @param value string --- @return string?, string? local function validate_pattern(flag, value) local ok, err = pcall(string.match, '', value) if not ok then local message = tostring(err) local detail = message:match('malformed pattern.*') or message return nil, ('invalid value for %s: %s'):format(flag, detail) end return value end --- @param flag string --- @param setter fun(value: string) --- @return fun(value: string): boolean?, string? local function set_pattern_value(flag, setter) return set_parsed_value(function(value) return validate_pattern(flag, value) end, setter) end --- @param values string[] --- @return fun(value: string): boolean?, string? local function append_value(values) return set_value(function(value) table.insert(values, value) end) end --- @type table local value_options = { ['--repeat'] = set_parsed_value(parse_repeat_count, function(count) opts.repeat_count = count end), ['--helper'] = set_nonempty_value('--helper', function(value) opts.helper = value end), ['--summary-file'] = set_nonempty_value('--summary-file', function(value) opts.summary_file = value end), ['--tags'] = function(value) for token in value:gmatch('[^,%s]+') do local tag = token:gsub('^#', '') if tag ~= '' and not seen_tags[tag] then seen_tags[tag] = true table.insert(opts.tags, tag) end end return true end, ['--filter'] = set_pattern_value('--filter', function(pattern) opts.filter = pattern end), ['--filter-out'] = set_pattern_value('--filter-out', function(pattern) table.insert(opts.filter_out, pattern) end), ['--lpath'] = append_value(opts.lpaths), ['--cpath'] = append_value(opts.cpaths), } local i = 1 --- @param arg string --- @return string, string? local function split_option(arg) local eq = arg:find('=', 1, true) if not eq then return arg, nil end return arg:sub(1, eq - 1), arg:sub(eq + 1) end --- Consume the next argv item as the value for `flag`. --- @param flag string --- @return string?, string? local function take_value(flag) i = i + 1 local value = argv[i] if type(value) ~= 'string' then return missing_value(flag) end return value end --- Parse one named option and apply it to `opts`. --- @param arg string --- @return boolean, string? local function apply_named_option(arg) local switch_handler = switch_options[arg] if switch_handler then switch_handler() return true end local flag, value = split_option(arg) local handler = value_options[flag] if handler then if value == nil then local err value, err = take_value(flag) if not value then return false, err end end local ok, handler_err = handler(value) if not ok then return false, handler_err end return true end return false end while i <= #argv do local arg = assert(argv[i]) local handled, err = apply_named_option(arg) if handled then elseif err then return nil, err elseif vim.startswith(arg, '-') then return nil, 'unknown test harness option: ' .. arg else opts.paths[#opts.paths + 1] = arg end i = i + 1 end if #opts.paths == 0 then return nil, 'no test paths provided' end return opts end --- Load a Lua chunk and bind it to a shallow copy of the given environment. --- @param path string --- @param env table --- @return function?, string? local function load_chunk(path, env) local chunk, err = loadfile(path) if not chunk then return nil, err end return setfenv(chunk, setmetatable(vim._copy(env), { __index = _G })) end --- Load a helper file before the test baseline is captured. --- Helper files are preload-only: they may require modules, set defaults, --- and register suite-end callbacks, but they do not define tests or hooks. --- @param path string --- @return boolean?, string? local function load_helper(path) local helper_path = normalize_path(path) local chunk, err = load_chunk(helper_path, { _G = _G, assert = test_assert, }) if not chunk then return nil, err end local ok, load_err = xpcall(chunk, debug.traceback) if not ok then return nil, load_err end return true end --- Evaluate a test file into a per-file root suite. --- @param file test.harness.FileEntry --- @param root_suite test.harness.Suite --- @return test.harness.Suite, test.harness.Result? local function evaluate_test_file(file, root_suite) local file_suite = create_suite(file.display_name, root_suite, { short_src = file.display_name, currentline = 1, }, true) table.insert(root_suite.children, file_suite) state.current_define_suite = file_suite local chunk, load_err = load_chunk(file.path, chunk_env) if not chunk then state.current_define_suite = nil return file_suite, { status = 'error', message = load_err, trace = parse_error_trace(load_err, nil), } end local ok, runtime_err = xpcall(chunk, exception_handler) state.current_define_suite = nil if not ok then local load_error = decode_error(runtime_err, 'error') load_error.status = 'error' return file_suite, load_error end return file_suite end --- Run a single file in the current prepared runtime state. --- @param file test.harness.FileEntry --- @param reporter test.base_reporter --- @param summary test.harness.RunSummary --- @param opts test.harness.Options --- @return boolean, boolean local function run_test_file(file, reporter, summary, opts) local root_suite = create_suite('') local saved_suite_end_callbacks = vim._copy(state.suite_end_callbacks) local file_suite, load_error = evaluate_test_file(file, root_suite) local selected_count = mark_selected(root_suite, opts) if load_error then state.suite_end_callbacks = saved_suite_end_callbacks elseif selected_count == 0 then state.suite_end_callbacks = saved_suite_end_callbacks return false, false end --- @type test.reporter.FileElement local file_element = { name = file.display_name, duration = 0 } --- @type test.harness.FileRunSummary local file_summary = { test_count = 0 } reporter:file_start(file_element) local start = now_seconds() local stop_requested = false if load_error then local status = run_synthetic_result( reporter, summary, file_summary, file_suite, 'load', load_error, load_error.trace ) if status ~= 'success' and status ~= 'pending' and not opts.keep_going then stop_requested = true end else stop_requested = run_suite(root_suite, reporter, summary, file_summary, opts) end file_element.duration = now_seconds() - start summary.file_count = summary.file_count + 1 reporter:file_end(file_element, file_summary.test_count) return true, stop_requested end --- Aggregate outcome from running one suite iteration. --- @class test.harness.IterationResult --- @field ran_any boolean --- @field stop_requested boolean --- @field summary test.harness.RunSummary --- Run one full suite iteration across the selected files. --- @param Reporter test.base_reporter --- @param opts test.harness.Options --- @param files test.harness.FileEntry[] --- @param pre_helper_baseline test.harness.RuntimeBaseline --- @param repeat_index integer --- @return test.harness.IterationResult?, string? local function run_iteration(Reporter, opts, files, pre_helper_baseline, repeat_index) local reporter = Reporter.new({ paths = opts.paths, verbose = opts.verbose, summary_file = opts.summary_file, }) --- @type test.harness.RunSummary local summary = { file_count = 0, result_count = 0, test_count = 0, success_count = 0, skipped_count = 0, failure_count = 0, error_count = 0, pendings = {}, failures = {}, errors = {}, } restore_runtime_baseline(pre_helper_baseline) state.suite_end_callbacks = {} if opts.helper then local ok, err = load_helper(opts.helper) if not ok then return nil, err end end --- @type test.harness.RuntimeBaseline local file_baseline = { cwd = assert(uv.cwd()), package_path = package.path, package_cpath = package.cpath, package_preload = vim._copy(package.preload), globals = vim._copy(_G), loaded = vim._copy(package.loaded), env = vim._copy(uv.os_environ()), arg = vim._copy(_G.arg or {}), } reporter:suite_start(repeat_index, opts.repeat_count) local start_time = now_seconds() local ran_any = false local stop_requested = false for _, file in ipairs(files) do restore_runtime_baseline(file_baseline) local ran_file, stop = run_test_file(file, reporter, summary, opts) cleanup_runtime_baseline(file_baseline) ran_any = ran_any or ran_file if stop then stop_requested = true break end end local duration = now_seconds() - start_time run_suite_end_callbacks(reporter, summary) cleanup_runtime_baseline(file_baseline) state.suite_end_callbacks = {} local failure_output if summary.failure_count > 0 or summary.error_count > 0 then failure_output = M.read_nvim_log(nil, true) end reporter:suite_end(duration, summary, failure_output) return { ran_any = ran_any, stop_requested = stop_requested, summary = summary, } end --- Run the test harness CLI entrypoint. --- @param argv string[] --- @return integer function M.main(argv) if os.getenv('BUSTED_ARGS') ~= nil then io.stderr:write('$BUSTED_ARGS is no longer supported; use $TEST_ARGS instead.\n') return 1 end local opts, err = parse_args(argv) if not opts then io.stderr:write(err .. '\n') return 1 end if #opts.lpaths > 0 then package.path = table.concat(opts.lpaths, ';') .. ';' .. package.path end if #opts.cpaths > 0 then package.cpath = table.concat(opts.cpaths, ';') .. ';' .. package.cpath end --- @type test.harness.RuntimeBaseline local pre_helper_baseline = { cwd = assert(uv.cwd()), package_path = package.path, package_cpath = package.cpath, package_preload = vim._copy(package.preload), globals = vim._copy(_G), loaded = vim._copy(package.loaded), env = vim._copy(uv.os_environ()), arg = vim._copy(_G.arg or {}), } local files = {} --- @type test.harness.FileEntry[] local seen_files = {} --- @type table for _, path in ipairs(opts.paths) do local ok, collect_err = collect_test_files(path, files, seen_files) if not ok then io.stderr:write(collect_err .. '\n') return 1 end end table.sort(files, function(a, b) return a.display_name < b.display_name end) if #files == 0 then io.stderr:write('No test files found.\n') return 1 end local ReporterModule = require('reporter') local exit_code = 0 local ran_any = false for repeat_index = 1, opts.repeat_count do local result, run_err = run_iteration(ReporterModule, opts, files, pre_helper_baseline, repeat_index) if not result then io.stderr:write(run_err .. '\n') return 1 end ran_any = ran_any or result.ran_any if not result.ran_any and result.summary.result_count == 0 then io.stderr:write('No tests matched the current selection.\n') exit_code = 1 break end if result.summary.failure_count > 0 or result.summary.error_count > 0 then exit_code = 1 end if result.stop_requested then break end end if not ran_any then exit_code = 1 end return exit_code end return M