require('coxpcall') local busted = require('busted') local luv = require('luv') local lfs = require('lfs') local mpack = require('mpack') local global_helpers = require('test.helpers') -- nvim client: Found in .deps/usr/share/lua//nvim/ if "bundled". local Session = require('nvim.session') local TcpStream = require('nvim.tcp_stream') local SocketStream = require('nvim.socket_stream') local ChildProcessStream = require('nvim.child_process_stream') local check_cores = global_helpers.check_cores local check_logs = global_helpers.check_logs local dedent = global_helpers.dedent local eq = global_helpers.eq local filter = global_helpers.tbl_filter local is_os = global_helpers.is_os local map = global_helpers.tbl_map local ok = global_helpers.ok local sleep = global_helpers.sleep local tbl_contains = global_helpers.tbl_contains local write_file = global_helpers.write_file local fail = global_helpers.fail local module = { NIL = mpack.NIL, mkdir = lfs.mkdir, } local start_dir = lfs.currentdir() module.nvim_prog = ( os.getenv('NVIM_PRG') or global_helpers.test_build_dir .. '/bin/nvim' ) -- Default settings for the test session. module.nvim_set = ( 'set shortmess+=IS background=light noswapfile noautoindent startofline' ..' laststatus=1 undodir=. directory=. viewdir=. backupdir=.' ..' belloff= wildoptions-=pum noshowcmd noruler nomore redrawdebug=invalid') module.nvim_argv = { module.nvim_prog, '-u', 'NONE', '-i', 'NONE', '--cmd', module.nvim_set, '--embed'} -- Directory containing nvim. module.nvim_dir = module.nvim_prog:gsub("[/\\][^/\\]+$", "") if module.nvim_dir == module.nvim_prog then module.nvim_dir = "." end local tmpname = global_helpers.tmpname local iswin = global_helpers.iswin local prepend_argv if os.getenv('VALGRIND') then local log_file = os.getenv('VALGRIND_LOG') or 'valgrind-%p.log' prepend_argv = {'valgrind', '-q', '--tool=memcheck', '--leak-check=yes', '--track-origins=yes', '--show-possibly-lost=no', '--suppressions=src/.valgrind.supp', '--log-file='..log_file} if os.getenv('GDB') then table.insert(prepend_argv, '--vgdb=yes') table.insert(prepend_argv, '--vgdb-error=0') end elseif os.getenv('GDB') then local gdbserver_port = '7777' if os.getenv('GDBSERVER_PORT') then gdbserver_port = os.getenv('GDBSERVER_PORT') end prepend_argv = {'gdbserver', 'localhost:'..gdbserver_port} end if prepend_argv then local new_nvim_argv = {} local len = #prepend_argv for i = 1, len do new_nvim_argv[i] = prepend_argv[i] end for i = 1, #module.nvim_argv do new_nvim_argv[i + len] = module.nvim_argv[i] end module.nvim_argv = new_nvim_argv module.prepend_argv = prepend_argv end local session, loop_running, last_error, method_error function module.get_session() return session end function module.set_session(s, keep) if session and not keep then session:close() end session = s end function module.request(method, ...) local status, rv = session:request(method, ...) if not status then if loop_running then last_error = rv[2] session:stop() else error(rv[2]) end end return rv end function module.next_msg(timeout) return session:next_message(timeout and timeout or 10000) end function module.expect_twostreams(msgs1, msgs2) local pos1, pos2 = 1, 1 while pos1 <= #msgs1 or pos2 <= #msgs2 do local msg = module.next_msg() if pos1 <= #msgs1 and pcall(eq, msgs1[pos1], msg) then pos1 = pos1 + 1 elseif pos2 <= #msgs2 then eq(msgs2[pos2], msg) pos2 = pos2 + 1 else -- already failed, but show the right error message eq(msgs1[pos1], msg) end end end -- Expects a sequence of next_msg() results. If multiple sequences are -- passed they are tried until one succeeds, in order of shortest to longest. -- -- Can be called with positional args (list of sequences only): -- expect_msg_seq(seq1, seq2, ...) -- or keyword args: -- expect_msg_seq{ignore={...}, seqs={seq1, seq2, ...}} -- -- ignore: List of ignored event names. -- seqs: List of one or more potential event sequences. function module.expect_msg_seq(...) if select('#', ...) < 1 then error('need at least 1 argument') end local arg1 = select(1, ...) if (arg1['seqs'] and select('#', ...) > 1) or type(arg1) ~= 'table' then error('invalid args') end local ignore = arg1['ignore'] and arg1['ignore'] or {} local seqs = arg1['seqs'] and arg1['seqs'] or {...} if type(ignore) ~= 'table' then error("'ignore' arg must be a list of strings") end table.sort(seqs, function(a, b) -- Sort ascending, by (shallow) length. return #a < #b end) local actual_seq = {} local nr_ignored = 0 local final_error = '' local function cat_err(err1, err2) if err1 == nil then return err2 end return string.format('%s\n%s\n%s', err1, string.rep('=', 78), err2) end local msg_timeout = module.load_adjust(10000) -- Big timeout for ASAN/valgrind. for anum = 1, #seqs do local expected_seq = seqs[anum] -- Collect enough messages to compare the next expected sequence. while #actual_seq < #expected_seq do local msg = module.next_msg(msg_timeout) local msg_type = msg and msg[2] or nil if msg == nil then error(cat_err(final_error, string.format('got %d messages (ignored %d), expected %d', #actual_seq, nr_ignored, #expected_seq))) elseif tbl_contains(ignore, msg_type) then nr_ignored = nr_ignored + 1 else table.insert(actual_seq, msg) end end local status, result = pcall(eq, expected_seq, actual_seq) if status then return result end local message = result if type(result) == "table" then -- 'eq' returns several things message = result.message end final_error = cat_err(final_error, message) end error(final_error) end local function call_and_stop_on_error(lsession, ...) local status, result = copcall(...) -- luacheck: ignore if not status then lsession:stop() last_error = result return '' end return result end function module.set_method_error(err) method_error = err end function module.run_session(lsession, request_cb, notification_cb, setup_cb, timeout) local on_request, on_notification, on_setup if request_cb then function on_request(method, args) method_error = nil local result = call_and_stop_on_error(lsession, request_cb, method, args) if method_error ~= nil then return method_error, true end return result end end if notification_cb then function on_notification(method, args) call_and_stop_on_error(lsession, notification_cb, method, args) end end if setup_cb then function on_setup() call_and_stop_on_error(lsession, setup_cb) end end loop_running = true session:run(on_request, on_notification, on_setup, timeout) loop_running = false if last_error then local err = last_error last_error = nil error(err) end end function module.run(request_cb, notification_cb, setup_cb, timeout) module.run_session(session, request_cb, notification_cb, setup_cb, timeout) end function module.stop() session:stop() end function module.nvim_prog_abs() -- system(['build/bin/nvim']) does not work for whatever reason. It must -- be executable searched in $PATH or something starting with / or ./. if module.nvim_prog:match('[/\\]') then return module.request('nvim_call_function', 'fnamemodify', {module.nvim_prog, ':p'}) else return module.nvim_prog end end -- Executes an ex-command. VimL errors manifest as client (lua) errors, but -- v:errmsg will not be updated. function module.command(cmd) module.request('nvim_command', cmd) end -- Evaluates a VimL expression. -- Fails on VimL error, but does not update v:errmsg. function module.eval(expr) return module.request('nvim_eval', expr) end -- Executes a VimL function. -- Fails on VimL error, but does not update v:errmsg. function module.call(name, ...) return module.request('nvim_call_function', name, {...}) end -- Sends user input to Nvim. -- Does not fail on VimL error, but v:errmsg will be updated. local function nvim_feed(input) while #input > 0 do local written = module.request('nvim_input', input) if written == nil then module.assert_alive() error('crash? (nvim_input returned nil)') end input = input:sub(written + 1) end end function module.feed(...) for _, v in ipairs({...}) do nvim_feed(dedent(v)) end end function module.rawfeed(...) for _, v in ipairs({...}) do nvim_feed(dedent(v)) end end function module.merge_args(...) local i = 1 local argv = {} for anum = 1,select('#', ...) do local args = select(anum, ...) if args then for _, arg in ipairs(args) do argv[i] = arg i = i + 1 end end end return argv end -- Removes Nvim startup args from `args` matching items in `args_rm`. -- -- "-u", "-i", "--cmd" are treated specially: their "values" are also removed. -- Example: -- args={'--headless', '-u', 'NONE'} -- args_rm={'--cmd', '-u'} -- Result: -- {'--headless'} -- -- All cases are removed. -- Example: -- args={'--cmd', 'foo', '-N', '--cmd', 'bar'} -- args_rm={'--cmd', '-u'} -- Result: -- {'-N'} local function remove_args(args, args_rm) local new_args = {} local skip_following = {'-u', '-i', '-c', '--cmd', '-s', '--listen'} if not args_rm or #args_rm == 0 then return {unpack(args)} end for _, v in ipairs(args_rm) do assert(type(v) == 'string') end local last = '' for _, arg in ipairs(args) do if tbl_contains(skip_following, last) then last = '' elseif tbl_contains(args_rm, arg) then last = arg else table.insert(new_args, arg) end end return new_args end function module.spawn(argv, merge, env) local child_stream = ChildProcessStream.spawn( merge and module.merge_args(prepend_argv, argv) or argv, env) return Session.new(child_stream) end -- Creates a new Session connected by domain socket (named pipe) or TCP. function module.connect(file_or_address) local addr, port = string.match(file_or_address, "(.*):(%d+)") local stream = (addr and port) and TcpStream.open(addr, port) or SocketStream.open(file_or_address) return Session.new(stream) end -- Calls fn() until it succeeds, up to `max` times or until `max_ms` -- milliseconds have passed. function module.retry(max, max_ms, fn) assert(max == nil or max > 0) assert(max_ms == nil or max_ms > 0) local tries = 1 local timeout = (max_ms and max_ms or 10000) local start_time = luv.now() while true do local status, result = pcall(fn) if status then return result end luv.update_time() -- Update cached value of luv.now() (libuv: uv_now()). if (max and tries >= max) or (luv.now() - start_time > timeout) then busted.fail(string.format("retry() attempts: %d\n%s", tries, tostring(result)), 2) end tries = tries + 1 luv.sleep(20) -- Avoid hot loop... end end -- Starts a new global Nvim session. -- -- Parameters are interpreted as startup args, OR a map with these keys: -- args: List: Args appended to the default `nvim_argv` set. -- args_rm: List: Args removed from the default set. All cases are -- removed, e.g. args_rm={'--cmd'} removes all cases of "--cmd" -- (and its value) from the default set. -- env: Map: Defines the environment of the new session. -- -- Example: -- clear('-e') -- clear{args={'-e'}, args_rm={'-i'}, env={TERM=term}} function module.clear(...) local argv, env = module.new_argv(...) module.set_session(module.spawn(argv, nil, env)) end -- Builds an argument list for use in clear(). -- --@see clear() for parameters. function module.new_argv(...) local args = {unpack(module.nvim_argv)} table.insert(args, '--headless') local new_args local env = nil local opts = select(1, ...) if type(opts) == 'table' then args = remove_args(args, opts.args_rm) if opts.env then local env_tbl = {} for k, v in pairs(opts.env) do assert(type(k) == 'string') assert(type(v) == 'string') env_tbl[k] = v end for _, k in ipairs({ 'HOME', 'ASAN_OPTIONS', 'TSAN_OPTIONS', 'MSAN_OPTIONS', 'LD_LIBRARY_PATH', 'PATH', 'NVIM_LOG_FILE', 'NVIM_RPLUGIN_MANIFEST', 'GCOV_ERROR_FILE', 'XDG_DATA_DIRS', 'TMPDIR', }) do if not env_tbl[k] then env_tbl[k] = os.getenv(k) end end env = {} for k, v in pairs(env_tbl) do env[#env + 1] = k .. '=' .. v end end new_args = opts.args or {} else new_args = {...} end for _, arg in ipairs(new_args) do table.insert(args, arg) end return args, env end function module.insert(...) nvim_feed('i') for _, v in ipairs({...}) do local escaped = v:gsub('<', '') module.rawfeed(escaped) end nvim_feed('') end -- Executes an ex-command by user input. Because nvim_input() is used, VimL -- errors will not manifest as client (lua) errors. Use command() for that. function module.feed_command(...) for _, v in ipairs({...}) do if v:sub(1, 1) ~= '/' then -- not a search command, prefix with colon nvim_feed(':') end nvim_feed(v:gsub('<', '')) nvim_feed('') end end local sourced_fnames = {} function module.source(code) local fname = tmpname() write_file(fname, code) module.command('source '..fname) -- DO NOT REMOVE FILE HERE. -- do_source() has a habit of checking whether files are “same” by using inode -- and device IDs. If you run two source() calls in quick succession there is -- a good chance that underlying filesystem will reuse the inode, making files -- appear as “symlinks” to do_source when it checks FileIDs. With current -- setup linux machines (both QB, travis and mine(ZyX-I) with XFS) do reuse -- inodes, Mac OS machines (again, both QB and travis) do not. -- -- Files appearing as “symlinks” mean that both the first and the second -- source() calls will use same SID, which may fail some tests which check for -- exact numbers after `` in e.g. function names. sourced_fnames[#sourced_fnames + 1] = fname return fname end function module.has_powershell() return module.eval('executable("'..(iswin() and 'powershell' or 'pwsh')..'")') == 1 end function module.set_shell_powershell() local shell = iswin() and 'powershell' or 'pwsh' assert(module.has_powershell()) local cmd = 'Remove-Item -Force '..table.concat(iswin() and {'alias:cat', 'alias:echo', 'alias:sleep'} or {'alias:echo'}, ',')..';' module.source([[ let &shell = ']]..shell..[[' set shellquote= shellpipe=\| shellxquote= let &shellredir = '| Out-File -Encoding UTF8' let &shellcmdflag = '-NoLogo -NoProfile -ExecutionPolicy RemoteSigned -Command ]]..cmd..[[' ]]) end function module.nvim(method, ...) return module.request('nvim_'..method, ...) end local function ui(method, ...) return module.request('nvim_ui_'..method, ...) end function module.nvim_async(method, ...) session:notify('nvim_'..method, ...) end function module.buffer(method, ...) return module.request('nvim_buf_'..method, ...) end function module.window(method, ...) return module.request('nvim_win_'..method, ...) end function module.tabpage(method, ...) return module.request('nvim_tabpage_'..method, ...) end function module.curbuf(method, ...) if not method then return module.nvim('get_current_buf') end return module.buffer(method, 0, ...) end function module.poke_eventloop() -- Execute 'nvim_eval' (a deferred function) to -- force at least one main_loop iteration session:request('nvim_eval', '1') end function module.buf_lines(bufnr) return module.exec_lua("return vim.api.nvim_buf_get_lines((...), 0, -1, false)", bufnr) end --@see buf_lines() function module.curbuf_contents() module.poke_eventloop() -- Before inspecting the buffer, do whatever. return table.concat(module.curbuf('get_lines', 0, -1, true), '\n') end function module.curwin(method, ...) if not method then return module.nvim('get_current_win') end return module.window(method, 0, ...) end function module.curtab(method, ...) if not method then return module.nvim('get_current_tabpage') end return module.tabpage(method, 0, ...) end function module.expect(contents) return eq(dedent(contents), module.curbuf_contents()) end function module.expect_any(contents) contents = dedent(contents) return ok(nil ~= string.find(module.curbuf_contents(), contents, 1, true)) end function module.expect_events(expected, received, kind) local inspect = require'vim.inspect' if not pcall(eq, expected, received) then local msg = 'unexpected '..kind..' received.\n\n' msg = msg .. 'received events:\n' for _, e in ipairs(received) do msg = msg .. ' ' .. inspect(e) .. ';\n' end msg = msg .. '\nexpected events:\n' for _, e in ipairs(expected) do msg = msg .. ' ' .. inspect(e) .. ';\n' end fail(msg) end return received end -- Checks that the Nvim session did not terminate. function module.assert_alive() assert(2 == module.eval('1+1'), 'crash? request failed') end -- Asserts that buffer is loaded and visible in the current tabpage. function module.assert_visible(bufnr, visible) assert(type(visible) == 'boolean') eq(visible, module.bufmeths.is_loaded(bufnr)) if visible then assert(-1 ~= module.funcs.bufwinnr(bufnr), 'expected buffer to be visible in current tabpage: '..tostring(bufnr)) else assert(-1 == module.funcs.bufwinnr(bufnr), 'expected buffer NOT visible in current tabpage: '..tostring(bufnr)) end end local function do_rmdir(path) local mode, errmsg, errcode = lfs.attributes(path, 'mode') if mode == nil then if errcode == 2 then -- "No such file or directory", don't complain. return end error(string.format('rmdir: %s (%d)', errmsg, errcode)) end if mode ~= 'directory' then error(string.format('rmdir: not a directory: %s', path)) end for file in lfs.dir(path) do if file ~= '.' and file ~= '..' then local abspath = path..'/'..file if lfs.attributes(abspath, 'mode') == 'directory' then do_rmdir(abspath) -- recurse else local ret, err = os.remove(abspath) if not ret then if not session then error('os.remove: '..err) else -- Try Nvim delete(): it handles `readonly` attribute on Windows, -- and avoids Lua cross-version/platform incompatibilities. if -1 == module.call('delete', abspath) then local hint = (is_os('win') and ' (hint: try :%bwipeout! before rmdir())' or '') error('delete() failed'..hint..': '..abspath) end end end end end end local ret, err = lfs.rmdir(path) if not ret then error('lfs.rmdir('..path..'): '..err) end end function module.rmdir(path) local ret, _ = pcall(do_rmdir, path) if not ret and is_os('win') then -- Maybe "Permission denied"; try again after changing the nvim -- process to the top-level directory. module.command([[exe 'cd '.fnameescape(']]..start_dir.."')") ret, _ = pcall(do_rmdir, path) end -- During teardown, the nvim process may not exit quickly enough, then rmdir() -- will fail (on Windows). if not ret then -- Try again. sleep(1000) do_rmdir(path) end end function module.exc_exec(cmd) module.command(([[ try execute "%s" catch let g:__exception = v:exception endtry ]]):format(cmd:gsub('\n', '\\n'):gsub('[\\"]', '\\%0'))) local ret = module.eval('get(g:, "__exception", 0)') module.command('unlet! g:__exception') return ret end function module.create_callindex(func) local table = {} setmetatable(table, { __index = function(tbl, arg1) local ret = function(...) return func(arg1, ...) end tbl[arg1] = ret return ret end, }) return table end -- Helper to skip tests. Returns true in Windows systems. -- pending_fn is pending() from busted function module.pending_win32(pending_fn) if iswin() then if pending_fn ~= nil then pending_fn('FIXME: Windows', function() end) end return true else return false end end function module.pending_c_parser(pending_fn) local status, msg = unpack(module.exec_lua([[ return {pcall(vim.treesitter.require_language, 'c')} ]])) if not status then if module.isCI() then error("treesitter C parser not found, required on CI: " .. msg) else pending_fn 'no C parser, skipping' return true end end return false end -- Calls pending() and returns `true` if the system is too slow to -- run fragile or expensive tests. Else returns `false`. function module.skip_fragile(pending_fn, cond) if pending_fn == nil or type(pending_fn) ~= type(function()end) then error("invalid pending_fn") end if cond then pending_fn("skipped (test is fragile on this system)", function() end) return true elseif os.getenv("TEST_SKIP_FRAGILE") then pending_fn("skipped (TEST_SKIP_FRAGILE)", function() end) return true end return false end module.funcs = module.create_callindex(module.call) module.meths = module.create_callindex(module.nvim) module.async_meths = module.create_callindex(module.nvim_async) module.uimeths = module.create_callindex(ui) module.bufmeths = module.create_callindex(module.buffer) module.winmeths = module.create_callindex(module.window) module.tabmeths = module.create_callindex(module.tabpage) module.curbufmeths = module.create_callindex(module.curbuf) module.curwinmeths = module.create_callindex(module.curwin) module.curtabmeths = module.create_callindex(module.curtab) function module.exec(code) return module.meths.exec(code, false) end function module.exec_capture(code) return module.meths.exec(code, true) end function module.exec_lua(code, ...) return module.meths.exec_lua(code, {...}) end function module.redir_exec(cmd) module.meths.set_var('__redir_exec_cmd', cmd) module.command([[ redir => g:__redir_exec_output silent! execute g:__redir_exec_cmd redir END ]]) local ret = module.meths.get_var('__redir_exec_output') module.meths.del_var('__redir_exec_output') module.meths.del_var('__redir_exec_cmd') return ret end function module.get_pathsep() return iswin() and '\\' or '/' end function module.pathroot() local pathsep = package.config:sub(1,1) return iswin() and (module.nvim_dir:sub(1,2)..pathsep) or '/' end -- Returns a valid, platform-independent $NVIM_LISTEN_ADDRESS. -- Useful for communicating with child instances. function module.new_pipename() -- HACK: Start a server temporarily, get the name, then stop it. local pipename = module.eval('serverstart()') module.funcs.serverstop(pipename) return pipename end function module.missing_provider(provider) if provider == 'ruby' or provider == 'node' or provider == 'perl' then local e = module.funcs['provider#'..provider..'#Detect']()[2] return e ~= '' and e or false elseif provider == 'python' or provider == 'python3' then local py_major_version = (provider == 'python3' and 3 or 2) local e = module.funcs['provider#pythonx#Detect'](py_major_version)[2] return e ~= '' and e or false else assert(false, 'Unknown provider: '..provider) end end function module.alter_slashes(obj) if not iswin() then return obj end if type(obj) == 'string' then local ret = obj:gsub('/', '\\') return ret elseif type(obj) == 'table' then local ret = {} for k, v in pairs(obj) do ret[k] = module.alter_slashes(v) end return ret else assert(false, 'expected string or table of strings, got '..type(obj)) end end local load_factor = 1 if global_helpers.isCI() then -- Compute load factor only once (but outside of any tests). module.clear() module.request('nvim_command', 'source src/nvim/testdir/load.vim') load_factor = module.request('nvim_eval', 'g:test_load_factor') end function module.load_adjust(num) return math.ceil(num * load_factor) end function module.parse_context(ctx) local parsed = {} for _, item in ipairs({'regs', 'jumps', 'bufs', 'gvars'}) do parsed[item] = filter(function(v) return type(v) == 'table' end, module.call('msgpackparse', ctx[item])) end parsed['bufs'] = parsed['bufs'][1] return map(function(v) if #v == 0 then return nil end return v end, parsed) end function module.add_builddir_to_rtp() -- Add runtime from build dir for doc/tags (used with :help). module.command(string.format([[set rtp+=%s/runtime]], module.test_build_dir)) end -- Kill process with given pid function module.os_kill(pid) return os.execute((iswin() and 'taskkill /f /t /pid '..pid..' > nul' or 'kill -9 '..pid..' > /dev/null')) end module = global_helpers.tbl_extend('error', module, global_helpers) return function(after_each) if after_each then after_each(function() for _, fname in ipairs(sourced_fnames) do os.remove(fname) end check_logs() check_cores('build/bin/nvim') if session then local msg = session:next_message(0) if msg then if msg[1] == "notification" and msg[2] == "nvim_error_event" then error(msg[3][2]) end end end end) end return module end