mirror of
https://github.com/neovim/neovim.git
synced 2026-05-06 08:26:45 -04:00
468 lines
12 KiB
Lua
468 lines
12 KiB
Lua
--- @alias test.reporter.SummaryStatus 'skipped' | 'failure' | 'error'
|
|
|
|
--- @class test.reporter.ColorMap
|
|
--- @field bright fun(s: any): string
|
|
--- @field green fun(s: any): string
|
|
--- @field yellow fun(s: any): string
|
|
--- @field magenta fun(s: any): string
|
|
--- @field red fun(s: any): string
|
|
--- @field cyan fun(s: any): string
|
|
--- @field dim fun(s: any): string
|
|
|
|
--- @class test.reporter.FileElement
|
|
--- @field name string
|
|
--- @field duration? number
|
|
|
|
--- @class test.reporter.Options
|
|
--- @field paths string[]
|
|
--- @field verbose boolean
|
|
--- @field summary_file string
|
|
|
|
--- @class test.base_reporter
|
|
--- @field new fun(opts: test.reporter.Options): test.base_reporter
|
|
--- @field suite_start fun(self: test.base_reporter, repeat_index?: integer, repeat_count?: integer)
|
|
--- @field file_start fun(self: test.base_reporter, file: test.reporter.FileElement)
|
|
--- @field test_start fun(self: test.base_reporter, name: string)
|
|
--- @field test_end fun(self: test.base_reporter, record: test.harness.Record)
|
|
--- @field file_end fun(self: test.base_reporter, file: test.reporter.FileElement, test_count: integer)
|
|
--- @field suite_end fun(self: test.base_reporter, duration: number, run_summary: test.harness.RunSummary, failure_output?: string)
|
|
|
|
--- @class test.reporter : test.base_reporter
|
|
--- @field opts test.reporter.Options
|
|
--- @field colors test.reporter.ColorMap
|
|
local M = {}
|
|
M.__index = M
|
|
|
|
--- @return test.reporter.ColorMap
|
|
local function identity_colors()
|
|
return setmetatable({}, {
|
|
__index = function()
|
|
return function(s)
|
|
return s == nil and '' or tostring(s)
|
|
end
|
|
end,
|
|
})
|
|
end
|
|
|
|
--- @param code string
|
|
--- @return fun(s: any): string
|
|
local function ansi_color(code)
|
|
return function(s)
|
|
return ('\27[%sm%s\27[0m'):format(code, s == nil and '' or tostring(s))
|
|
end
|
|
end
|
|
|
|
local ansi_colors = {
|
|
bright = ansi_color('1'),
|
|
green = ansi_color('32'),
|
|
yellow = ansi_color('33'),
|
|
magenta = ansi_color('35'),
|
|
red = ansi_color('31'),
|
|
cyan = ansi_color('36'),
|
|
dim = ansi_color('2'),
|
|
}
|
|
|
|
--- @return boolean
|
|
local function use_colors()
|
|
local test_colors = os.getenv('TEST_COLORS')
|
|
if not test_colors then
|
|
return true
|
|
end
|
|
|
|
local value = test_colors:lower()
|
|
return not (value == 'false' or value == '0' or value == 'no' or value == 'off')
|
|
end
|
|
|
|
--- @param path? string
|
|
--- @return file
|
|
local function open_summary_file(path)
|
|
if type(path) ~= 'string' or path == '-' then
|
|
return io.stdout
|
|
end
|
|
|
|
return (assert(io.open(path, 'w')))
|
|
end
|
|
|
|
--- @param opts test.reporter.Options
|
|
--- @return test.reporter
|
|
function M.new(opts)
|
|
local colors = identity_colors()
|
|
if use_colors() then
|
|
colors = ansi_colors
|
|
end
|
|
|
|
--- @type test.reporter
|
|
local self = setmetatable({
|
|
opts = opts,
|
|
colors = colors,
|
|
}, M)
|
|
|
|
return self
|
|
end
|
|
|
|
--- @param element? { duration?: number }
|
|
--- @return number
|
|
local function get_elapsed_time_ms(element)
|
|
if element and element.duration then
|
|
return element.duration * 1000
|
|
end
|
|
|
|
return tonumber('nan')
|
|
end
|
|
|
|
--- @private
|
|
--- @param s any
|
|
--- @return string
|
|
function M:succ(s)
|
|
return self.colors.bright(self.colors.green(s))
|
|
end
|
|
|
|
--- @private
|
|
--- @param s any
|
|
--- @return string
|
|
function M:skip(s)
|
|
return self.colors.bright(self.colors.yellow(s))
|
|
end
|
|
|
|
--- @private
|
|
--- @param s any
|
|
--- @return string
|
|
function M:fail(s)
|
|
return self.colors.bright(self.colors.magenta(s))
|
|
end
|
|
|
|
--- @private
|
|
--- @param s any
|
|
--- @return string
|
|
function M:errr(s)
|
|
return self.colors.bright(self.colors.red(s))
|
|
end
|
|
|
|
--- @private
|
|
--- @param s any
|
|
--- @return string
|
|
function M:fpath(s)
|
|
return self.colors.cyan(s)
|
|
end
|
|
|
|
--- @private
|
|
--- @param s any
|
|
--- @return string
|
|
function M:time(s)
|
|
return self.colors.dim(s)
|
|
end
|
|
|
|
--- @private
|
|
--- @param s any
|
|
--- @return string
|
|
function M:sect(s)
|
|
return self.colors.green(self.colors.dim(s))
|
|
end
|
|
|
|
--- @private
|
|
--- @param s any
|
|
--- @return string
|
|
function M:nmbr(s)
|
|
return self.colors.bright(s)
|
|
end
|
|
|
|
--- @private
|
|
--- @param status test.harness.ResultStatus
|
|
--- @return string
|
|
function M:result_text(status)
|
|
if status == 'success' then
|
|
return ('%s\n'):format(self:succ('OK'))
|
|
elseif status == 'pending' then
|
|
return ('%s\n'):format(self:skip('SKIP'))
|
|
elseif status == 'failure' then
|
|
return ('%s\n'):format(self:fail('FAIL'))
|
|
end
|
|
|
|
return ('%s\n'):format(self:errr('ERR'))
|
|
end
|
|
|
|
--- @private
|
|
--- @param status test.reporter.SummaryStatus
|
|
--- @return string
|
|
function M:summary_label(status)
|
|
if status == 'skipped' then
|
|
return self:skip('SKIPPED ')
|
|
elseif status == 'failure' then
|
|
return self:fail('FAILED ')
|
|
end
|
|
|
|
return self:errr('ERROR ')
|
|
end
|
|
|
|
--- @private
|
|
--- @param status test.reporter.SummaryStatus
|
|
--- @param count integer
|
|
--- @return string
|
|
function M:summary_header_noun(status, count)
|
|
if status == 'error' then
|
|
return count == 1 and 'error' or 'errors'
|
|
end
|
|
|
|
return count == 1 and 'test' or 'tests'
|
|
end
|
|
|
|
--- @private
|
|
--- @param status test.reporter.SummaryStatus
|
|
--- @param count integer
|
|
--- @return string
|
|
function M:summary_footer_noun(status, count)
|
|
if status == 'skipped' then
|
|
return count == 1 and 'SKIPPED TEST' or 'SKIPPED TESTS'
|
|
elseif status == 'failure' then
|
|
return count == 1 and 'FAILED TEST' or 'FAILED TESTS'
|
|
elseif status == 'error' then
|
|
return count == 1 and 'ERROR' or 'ERRORS'
|
|
end
|
|
|
|
return count == 1 and 'TEST' or 'TESTS'
|
|
end
|
|
|
|
--- @param message any
|
|
--- @return string
|
|
local function stringify_message(message)
|
|
if type(message) == 'string' then
|
|
return message
|
|
elseif message == nil then
|
|
return ''
|
|
end
|
|
|
|
return vim.inspect(message)
|
|
end
|
|
|
|
--- @private
|
|
--- @param pending test.harness.Record
|
|
--- @return boolean
|
|
function M:na_check(pending)
|
|
if pending.name and vim.list_contains(vim.split(pending.name, '[ :]'), 'N/A') then
|
|
return true
|
|
end
|
|
|
|
if type(pending.message) == 'string' then
|
|
return vim.list_contains(vim.split(pending.message, '[ :]'), 'N/A')
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
--- @private
|
|
--- @param pending test.harness.Record
|
|
--- @return string
|
|
function M:pending_description(pending)
|
|
local message = stringify_message(pending.message)
|
|
if message == '' then
|
|
return ''
|
|
end
|
|
|
|
return table.concat({ message, '\n' })
|
|
end
|
|
|
|
--- @private
|
|
--- @param failure test.harness.Record
|
|
--- @return string
|
|
function M:failure_description(failure)
|
|
local message = stringify_message(failure.message)
|
|
if message == '' then
|
|
message = 'Nil error'
|
|
end
|
|
|
|
local parts = { message, '\n' }
|
|
if self.opts.verbose and failure.traceback then
|
|
parts[#parts + 1] = failure.traceback
|
|
parts[#parts + 1] = '\n'
|
|
end
|
|
|
|
return table.concat(parts)
|
|
end
|
|
|
|
--- @private
|
|
--- @param trace? test.harness.Trace
|
|
--- @return string
|
|
function M:get_file_line(trace)
|
|
if not trace or not trace.short_src then
|
|
return ''
|
|
end
|
|
|
|
local source = vim.fs.normalize(trace.short_src)
|
|
local line = trace.currentline or 0
|
|
return self:fpath(source) .. ' @ ' .. self:fpath(line) .. ': '
|
|
end
|
|
|
|
--- @private
|
|
--- @param status test.reporter.SummaryStatus
|
|
--- @param count integer
|
|
--- @param list test.harness.Record[]
|
|
--- @param describe fun(self: test.reporter, item: test.harness.Record): string
|
|
--- @return string
|
|
function M:get_test_list(status, count, list, describe)
|
|
if count == 0 then
|
|
return ''
|
|
end
|
|
|
|
local label = self:summary_label(status)
|
|
local parts = {
|
|
('%s %s %s, listed below:\n'):format(
|
|
label,
|
|
self:nmbr(count),
|
|
self:summary_header_noun(status, count)
|
|
),
|
|
}
|
|
local na_count = 0
|
|
|
|
for _, item in ipairs(list) do
|
|
if status == 'skipped' and self:na_check(item) then
|
|
na_count = na_count + 1
|
|
else
|
|
local fullname = self:get_file_line(item.trace) .. self:nmbr(item.name)
|
|
parts[#parts + 1] = ('%s %s\n'):format(label, fullname)
|
|
parts[#parts + 1] = describe(self, item)
|
|
end
|
|
end
|
|
|
|
if na_count > 0 then
|
|
parts[#parts + 1] =
|
|
self:nmbr(('%d N/A %s not shown\n'):format(na_count, na_count == 1 and 'test' or 'tests'))
|
|
end
|
|
|
|
return table.concat(parts)
|
|
end
|
|
|
|
--- @private
|
|
--- @param status test.reporter.SummaryStatus
|
|
--- @param count integer
|
|
--- @return string
|
|
function M:get_summary(status, count)
|
|
if count == 0 then
|
|
return ''
|
|
end
|
|
|
|
return (' %s %s\n'):format(self:nmbr(count), self:summary_footer_noun(status, count))
|
|
end
|
|
|
|
--- @private
|
|
--- @return string
|
|
--- @param summary test.harness.RunSummary
|
|
function M:get_summary_string(summary)
|
|
local tests = summary.success_count == 1 and 'test' or 'tests'
|
|
local parts = {
|
|
('%s %s %s.\n'):format(self:succ('PASSED '), self:nmbr(summary.success_count), tests),
|
|
self:get_test_list('skipped', summary.skipped_count, summary.pendings, M.pending_description),
|
|
self:get_test_list('failure', summary.failure_count, summary.failures, M.failure_description),
|
|
self:get_test_list('error', summary.error_count, summary.errors, M.failure_description),
|
|
}
|
|
|
|
if (summary.skipped_count + summary.failure_count + summary.error_count) > 0 then
|
|
parts[#parts + 1] = '\n'
|
|
end
|
|
|
|
parts[#parts + 1] = self:get_summary('skipped', summary.skipped_count)
|
|
parts[#parts + 1] = self:get_summary('failure', summary.failure_count)
|
|
parts[#parts + 1] = self:get_summary('error', summary.error_count)
|
|
return table.concat(parts)
|
|
end
|
|
|
|
--- @return nil
|
|
--- @param repeat_index? integer
|
|
--- @param repeat_count? integer
|
|
function M:suite_start(repeat_index, repeat_count)
|
|
if repeat_count and repeat_count > 1 and repeat_index then
|
|
io.write(('\nRepeating all tests (run %d of %d) . . .\n\n'):format(repeat_index, repeat_count))
|
|
end
|
|
io.write(('%s Global test environment setup.\n'):format(self:sect('--------')))
|
|
io.flush()
|
|
end
|
|
|
|
--- @param file test.reporter.FileElement
|
|
function M:file_start(file)
|
|
io.write(
|
|
('%s Running tests from %s\n'):format(
|
|
self:sect('--------'),
|
|
self:fpath(vim.fs.normalize(file.name))
|
|
)
|
|
)
|
|
io.flush()
|
|
end
|
|
|
|
--- @param name string
|
|
function M:test_start(name)
|
|
local desc = ('%s %s'):format(_G._nvim_test_id or '', name)
|
|
io.write(('%s %s: '):format(self:sect('RUN '), desc))
|
|
io.flush()
|
|
end
|
|
|
|
--- @private
|
|
--- @param record { duration?: number }
|
|
--- @param text string
|
|
function M:write_status(record, text)
|
|
io.write(('%s %s'):format(self:time(('%.2f ms'):format(get_elapsed_time_ms(record))), text))
|
|
io.flush()
|
|
end
|
|
|
|
--- @param record test.harness.Record
|
|
function M:test_end(record)
|
|
local text = self:result_text(record.status)
|
|
if record.status == 'failure' or record.status == 'error' then
|
|
text = text .. self:failure_description(record)
|
|
end
|
|
|
|
self:write_status(record, text)
|
|
end
|
|
|
|
--- @param file test.reporter.FileElement
|
|
--- @param test_count integer
|
|
function M:file_end(file, test_count)
|
|
io.write(
|
|
('%s %s %s from %s %s\n\n'):format(
|
|
self:sect('--------'),
|
|
self:nmbr(test_count),
|
|
test_count == 1 and 'test' or 'tests',
|
|
self:fpath(vim.fs.normalize(file.name)),
|
|
self:time(('(%.2f ms total)'):format(get_elapsed_time_ms(file)))
|
|
)
|
|
)
|
|
io.flush()
|
|
end
|
|
|
|
--- @param duration number
|
|
--- @param run_summary test.harness.RunSummary
|
|
--- @param failure_output? string
|
|
function M:suite_end(duration, run_summary, failure_output)
|
|
local tests = run_summary.test_count == 1 and 'test' or 'tests'
|
|
local files = run_summary.file_count == 1 and 'file' or 'files'
|
|
|
|
io.write(('%s Global test environment teardown.\n'):format(self:sect('--------')))
|
|
io.flush()
|
|
|
|
local fpath = function(s)
|
|
return self:fpath(s)
|
|
end
|
|
|
|
local summary_file = open_summary_file(self.opts.summary_file)
|
|
summary_file:write('\n')
|
|
summary_file:write(
|
|
('%s %s %s from %s test %s of %s ran. %s\n'):format(
|
|
self:sect('========'),
|
|
self:nmbr(run_summary.test_count),
|
|
tests,
|
|
self:nmbr(run_summary.file_count),
|
|
files,
|
|
vim.iter(self.opts.paths):map(fpath):join(';'),
|
|
self:time(('(%.2f ms total)'):format(duration * 1000))
|
|
)
|
|
)
|
|
summary_file:write(self:get_summary_string(run_summary))
|
|
if failure_output then
|
|
summary_file:write(failure_output)
|
|
end
|
|
summary_file:flush()
|
|
if summary_file ~= io.stdout then
|
|
summary_file:close()
|
|
end
|
|
end
|
|
|
|
return M
|