testnvim.lua (30304B)
1 local uv = vim.uv 2 local t = require('test.testutil') 3 local busted = require('busted') 4 5 local Session = require('test.client.session') 6 local uv_stream = require('test.client.uv_stream') 7 local SocketStream = uv_stream.SocketStream 8 local ProcStream = uv_stream.ProcStream 9 10 local check_cores = t.check_cores 11 local check_logs = t.check_logs 12 local dedent = t.dedent 13 local eq = t.eq 14 local is_os = t.is_os 15 local ok = t.ok 16 local sleep = uv.sleep 17 18 --- Functions executing in the current nvim session/process being tested. 19 local M = {} 20 21 local lib_path = t.paths.test_build_dir .. (t.is_zig_build() and '/lib' or '/lib/nvim') 22 M.runtime_set = 'set runtimepath^=' .. lib_path 23 24 M.nvim_prog = (os.getenv('NVIM_PRG') or t.paths.test_build_dir .. '/bin/nvim') 25 -- Default settings for the test session. 26 M.nvim_set = ( 27 'set shortmess+=IS background=light noswapfile noautoindent startofline' 28 .. ' laststatus=1 undodir=. directory=. viewdir=. backupdir=.' 29 .. " belloff= wildoptions-=pum joinspaces noshowcmd noruler nomore redrawdebug=invalid shada=!,'100,<50,s10,h" 30 .. [[ statusline=%<%f\ %{%nvim_eval_statusline('%h%w%m%r',\ {'maxwidth':\ 30}).width\ >\ 0\ ?\ '%h%w%m%r\ '\ :\ ''%}%=%{%\ &showcmdloc\ ==\ 'statusline'\ ?\ '%-10.S\ '\ :\ ''\ %}%{%\ exists('b:keymap_name')\ ?\ '<'..b:keymap_name..'>\ '\ :\ ''\ %}%{%\ &ruler\ ?\ (\ &rulerformat\ ==\ ''\ ?\ '%-14.(%l,%c%V%)\ %P'\ :\ &rulerformat\ )\ :\ ''\ %}]] 31 ) 32 M.nvim_argv = { 33 M.nvim_prog, 34 '-u', 35 'NONE', 36 '-i', 37 'NONE', 38 -- XXX: find treesitter parsers. 39 '--cmd', 40 M.runtime_set, 41 '--cmd', 42 M.nvim_set, 43 -- Remove default user commands and mappings. 44 '--cmd', 45 'comclear | mapclear | mapclear!', 46 -- Make screentest work after changing to the new default color scheme 47 -- Source 'vim' color scheme without side effects 48 -- TODO: rewrite tests 49 '--cmd', 50 'lua dofile("runtime/colors/vim.lua")', 51 '--cmd', 52 'unlet g:colors_name', 53 '--embed', 54 } 55 if os.getenv('OSV_PORT') then 56 table.insert(M.nvim_argv, '--cmd') 57 table.insert( 58 M.nvim_argv, 59 string.format( 60 "lua require('osv').launch({ port = %s, blocking = true })", 61 os.getenv('OSV_PORT') 62 ) 63 ) 64 end 65 66 -- Directory containing nvim. 67 M.nvim_dir = M.nvim_prog:gsub('[/\\][^/\\]+$', '') 68 if M.nvim_dir == M.nvim_prog then 69 M.nvim_dir = '.' 70 end 71 72 local prepend_argv --- @type string[]? 73 74 if os.getenv('VALGRIND') then 75 local log_file = os.getenv('VALGRIND_LOG') or 'valgrind-%p.log' 76 prepend_argv = { 77 'valgrind', 78 '-q', 79 '--tool=memcheck', 80 '--leak-check=yes', 81 '--track-origins=yes', 82 '--show-possibly-lost=no', 83 '--suppressions=src/.valgrind.supp', 84 '--log-file=' .. log_file, 85 } 86 if os.getenv('GDB') then 87 table.insert(prepend_argv, '--vgdb=yes') 88 table.insert(prepend_argv, '--vgdb-error=0') 89 end 90 elseif os.getenv('GDB') then 91 local gdbserver_port = os.getenv('GDBSERVER_PORT') or '7777' 92 prepend_argv = { 'gdbserver', 'localhost:' .. gdbserver_port } 93 end 94 95 if prepend_argv then 96 local new_nvim_argv = {} --- @type string[] 97 local len = #prepend_argv 98 for i = 1, len do 99 new_nvim_argv[i] = prepend_argv[i] 100 end 101 for i = 1, #M.nvim_argv do 102 new_nvim_argv[i + len] = M.nvim_argv[i] 103 end 104 M.nvim_argv = new_nvim_argv 105 M.prepend_argv = prepend_argv 106 end 107 108 local session --- @type test.Session? 109 local loop_running --- @type boolean? 110 local last_error --- @type string? 111 local method_error --- @type string? 112 113 if not is_os('win') then 114 local sigpipe_handler = assert(uv.new_signal()) 115 uv.signal_start(sigpipe_handler, 'sigpipe', function() 116 print('warning: got SIGPIPE signal. Likely related to a crash in nvim') 117 end) 118 end 119 120 function M.get_session() 121 return session 122 end 123 124 function M.set_session(s) 125 session = s 126 end 127 128 --- @param method string 129 --- @param ... any 130 --- @return any 131 function M.request(method, ...) 132 assert(session, 'no Nvim session') 133 assert(not session.eof_err, 'sending request after EOF from Nvim') 134 local status, rv = session:request(method, ...) 135 if not status then 136 if loop_running then 137 --- @type string 138 last_error = rv[2] 139 session:stop() 140 else 141 error(rv[2]) 142 end 143 end 144 return rv 145 end 146 147 --- @param method string 148 --- @param ... any 149 --- @return any 150 function M.request_lua(method, ...) 151 return M.exec_lua([[return vim.api[...](select(2, ...))]], method, ...) 152 end 153 154 --- @param timeout? integer 155 --- @return string? 156 function M.next_msg(timeout) 157 assert(session) 158 return session:next_message(timeout or 10000) 159 end 160 161 function M.expect_twostreams(msgs1, msgs2) 162 local pos1, pos2 = 1, 1 163 while pos1 <= #msgs1 or pos2 <= #msgs2 do 164 local msg = M.next_msg() 165 if pos1 <= #msgs1 and pcall(eq, msgs1[pos1], msg) then 166 pos1 = pos1 + 1 167 elseif pos2 <= #msgs2 then 168 eq(msgs2[pos2], msg) 169 pos2 = pos2 + 1 170 else 171 -- already failed, but show the right error message 172 eq(msgs1[pos1], msg) 173 end 174 end 175 end 176 177 -- Expects a sequence of next_msg() results. If multiple sequences are 178 -- passed they are tried until one succeeds, in order of shortest to longest. 179 -- 180 -- Can be called with positional args (list of sequences only): 181 -- expect_msg_seq(seq1, seq2, ...) 182 -- or keyword args: 183 -- expect_msg_seq{ignore={...}, seqs={seq1, seq2, ...}} 184 -- 185 -- ignore: List of ignored event names. 186 -- seqs: List of one or more potential event sequences. 187 function M.expect_msg_seq(...) 188 if select('#', ...) < 1 then 189 error('need at least 1 argument') 190 end 191 local arg1 = select(1, ...) 192 if (arg1['seqs'] and select('#', ...) > 1) or type(arg1) ~= 'table' then 193 error('invalid args') 194 end 195 local ignore = arg1['ignore'] and arg1['ignore'] or {} 196 --- @type string[] 197 local seqs = arg1['seqs'] and arg1['seqs'] or { ... } 198 if type(ignore) ~= 'table' then 199 error("'ignore' arg must be a list of strings") 200 end 201 table.sort(seqs, function(a, b) -- Sort ascending, by (shallow) length. 202 return #a < #b 203 end) 204 205 local actual_seq = {} 206 local nr_ignored = 0 207 local final_error = '' 208 local function cat_err(err1, err2) 209 if err1 == nil then 210 return err2 211 end 212 return string.format('%s\n%s\n%s', err1, string.rep('=', 78), err2) 213 end 214 local msg_timeout = M.load_adjust(10000) -- Big timeout for ASAN/valgrind. 215 for anum = 1, #seqs do 216 local expected_seq = seqs[anum] 217 -- Collect enough messages to compare the next expected sequence. 218 while #actual_seq < #expected_seq do 219 local msg = M.next_msg(msg_timeout) 220 local msg_type = msg and msg[2] or nil 221 if msg == nil then 222 error( 223 cat_err( 224 final_error, 225 string.format( 226 'got %d messages (ignored %d), expected %d', 227 #actual_seq, 228 nr_ignored, 229 #expected_seq 230 ) 231 ) 232 ) 233 elseif vim.tbl_contains(ignore, msg_type) then 234 nr_ignored = nr_ignored + 1 235 else 236 table.insert(actual_seq, msg) 237 end 238 end 239 local status, result = pcall(eq, expected_seq, actual_seq) 240 if status then 241 return result 242 end 243 local message = result 244 if type(result) == 'table' then 245 -- 'eq' returns several things 246 --- @type string 247 message = result.message 248 end 249 final_error = cat_err(final_error, message) 250 end 251 error(final_error) 252 end 253 254 local function call_and_stop_on_error(lsession, ...) 255 local status, result = Session.safe_pcall(...) -- luacheck: ignore 256 if not status then 257 lsession:stop() 258 last_error = result 259 return '' 260 end 261 return result 262 end 263 264 function M.set_method_error(err) 265 method_error = err 266 end 267 268 --- Runs the event loop of the given session. 269 --- 270 --- @param lsession test.Session 271 --- @param request_cb function? 272 --- @param notification_cb function? 273 --- @param setup_cb function? 274 --- @param timeout integer 275 --- @return [integer, string] 276 function M.run_session(lsession, request_cb, notification_cb, setup_cb, timeout) 277 local on_request --- @type function? 278 local on_notification --- @type function? 279 local on_setup --- @type function? 280 281 if request_cb then 282 function on_request(method, args) 283 method_error = nil 284 local result = call_and_stop_on_error(lsession, request_cb, method, args) 285 if method_error ~= nil then 286 return method_error, true 287 end 288 return result 289 end 290 end 291 292 if notification_cb then 293 function on_notification(method, args) 294 call_and_stop_on_error(lsession, notification_cb, method, args) 295 end 296 end 297 298 if setup_cb then 299 function on_setup() 300 call_and_stop_on_error(lsession, setup_cb) 301 end 302 end 303 304 loop_running = true 305 lsession:run(on_request, on_notification, on_setup, timeout) 306 loop_running = false 307 if last_error then 308 local err = last_error 309 last_error = nil 310 error(err) 311 end 312 313 return lsession.eof_err 314 end 315 316 --- Runs the event loop of the current global session. 317 function M.run(request_cb, notification_cb, setup_cb, timeout) 318 assert(session) 319 return M.run_session(session, request_cb, notification_cb, setup_cb, timeout) 320 end 321 322 function M.stop() 323 if loop_running then 324 assert(session):stop() 325 end 326 end 327 328 -- Use for commands which expect nvim to quit. 329 -- The first argument can also be a timeout. 330 function M.expect_exit(fn_or_timeout, ...) 331 local eof_err_msg = 'EOF was received from Nvim. Likely the Nvim process crashed.' 332 if type(fn_or_timeout) == 'function' then 333 t.matches(vim.pesc(eof_err_msg), t.pcall_err(fn_or_timeout, ...)) 334 else 335 t.matches( 336 vim.pesc(eof_err_msg), 337 t.pcall_err(function(timeout, fn, ...) 338 fn(...) 339 assert(session) 340 while session:next_message(timeout) do 341 end 342 if session.eof_err then 343 error(session.eof_err[2]) 344 end 345 end, fn_or_timeout, ...) 346 ) 347 end 348 end 349 350 --- Executes a Vimscript function via Lua. 351 --- Fails on Vimscript error, but does not update v:errmsg. 352 --- @param name string 353 --- @param ... any 354 --- @return any 355 function M.call_lua(name, ...) 356 return M.exec_lua([[return vim.call(...)]], name, ...) 357 end 358 359 --- Sends user input to Nvim. 360 --- Does not fail on Vimscript error, but v:errmsg will be updated. 361 --- @param input string 362 local function nvim_feed(input) 363 while #input > 0 do 364 local written = M.request('nvim_input', input) 365 if written == nil then 366 M.assert_alive() 367 error('crash? (nvim_input returned nil)') 368 end 369 input = input:sub(written + 1) 370 end 371 end 372 373 --- @param ... string 374 function M.feed(...) 375 for _, v in ipairs({ ... }) do 376 nvim_feed(v) 377 end 378 end 379 380 ---@param ... string[]? 381 ---@return string[] 382 function M.merge_args(...) 383 local i = 1 384 local argv = {} --- @type string[] 385 for anum = 1, select('#', ...) do 386 --- @type string[]? 387 local args = select(anum, ...) 388 if args then 389 for _, arg in ipairs(args) do 390 argv[i] = arg 391 i = i + 1 392 end 393 end 394 end 395 return argv 396 end 397 398 --- Removes Nvim startup args from `args` matching items in `args_rm`. 399 --- 400 --- - Special case: "-u", "-i", "--cmd" are treated specially: their "values" are also removed. 401 --- - Special case: "runtimepath" will remove only { '--cmd', 'set runtimepath^=…', } 402 --- 403 --- Example: 404 --- args={'--headless', '-u', 'NONE'} 405 --- args_rm={'--cmd', '-u'} 406 --- Result: 407 --- {'--headless'} 408 --- 409 --- All matching cases are removed. 410 --- 411 --- Example: 412 --- args={'--cmd', 'foo', '-N', '--cmd', 'bar'} 413 --- args_rm={'--cmd', '-u'} 414 --- Result: 415 --- {'-N'} 416 --- @param args string[] 417 --- @param args_rm string[] 418 --- @return string[] 419 local function remove_args(args, args_rm) 420 local new_args = {} --- @type string[] 421 local skip_following = { '-u', '-i', '-c', '--cmd', '-s', '--listen' } 422 if not args_rm or #args_rm == 0 then 423 return { unpack(args) } 424 end 425 for _, v in ipairs(args_rm) do 426 assert(type(v) == 'string') 427 end 428 local last = '' 429 for _, arg in ipairs(args) do 430 if vim.tbl_contains(skip_following, last) then 431 last = '' 432 elseif vim.tbl_contains(args_rm, arg) then 433 last = arg 434 elseif arg == M.runtime_set and vim.tbl_contains(args_rm, 'runtimepath') then 435 table.remove(new_args) -- Remove the preceding "--cmd". 436 last = '' 437 else 438 table.insert(new_args, arg) 439 end 440 end 441 return new_args 442 end 443 444 function M.check_close(noblock) 445 if not session then 446 return 447 end 448 449 session:close(nil, noblock) 450 session = nil 451 end 452 453 -- Creates a new Session connected by domain socket (named pipe) or TCP. 454 function M.connect(file_or_address) 455 local addr, port = string.match(file_or_address, '(.*):(%d+)') 456 local stream = (addr and port) and SocketStream.connect(addr, port) 457 or SocketStream.open(file_or_address) 458 return Session.new(stream) 459 end 460 461 --- Starts a new, global Nvim session and clears the current one. 462 --- 463 --- Note: 464 --- - Use `new_session()` to start a session without replacing the current one. 465 --- - Use `spawn_wait()` to start Nvim without connecting a RPC session. 466 --- 467 --- Parameters are interpreted as startup args, OR a map with these keys: 468 --- - args: List: Args appended to the default `nvim_argv` set. 469 --- - args_rm: List: Args removed from the default set. All cases are 470 --- removed, e.g. args_rm={'--cmd'} removes all cases of "--cmd" 471 --- (and its value) from the default set. 472 --- - env: Map: Defines the environment of the new session. 473 --- 474 --- Example: 475 --- ``` 476 --- clear('-e') 477 --- clear{args={'-e'}, args_rm={'-i'}, env={TERM=term}} 478 --- ``` 479 --- 480 --- @param ... string Nvim CLI args 481 --- @return test.Session 482 --- @overload fun(opts: test.session.Opts): test.Session 483 function M.clear(...) 484 M.set_session(M.new_session(false, ...)) 485 return M.get_session() 486 end 487 488 local n_processes = 0 489 490 --- Starts a new Nvim process with the given args and returns a msgpack-RPC session. 491 --- 492 --- Does not replace the current global session, unlike `clear()`. 493 --- 494 --- @param keep boolean (default: false) Don't close the current global session. 495 --- @param ... string Nvim CLI args 496 --- @return test.Session 497 --- @overload fun(keep: boolean, opts: test.session.Opts): test.Session 498 function M.new_session(keep, ...) 499 local test_id = _G._nvim_test_id 500 if not keep and session ~= nil then 501 -- Don't block for the previous session's exit if it's from a different test. 502 session:close(nil, session.data and session.data.test_id ~= test_id) 503 session = nil 504 end 505 506 local argv, env, io_extra = M._new_argv(...) 507 508 local proc = ProcStream.spawn(argv, env, io_extra, function(closed) 509 n_processes = n_processes - 1 510 local delta = 0 511 if closed then 512 uv.update_time() -- Update cached value of uv.now() (libuv: uv_now()). 513 delta = uv.now() - closed 514 end 515 if delta > 500 then 516 print( 517 ('\nNvim session %s took %d milliseconds to exit\n'):format(test_id, delta) 518 .. 'This indicates a likely problem with the test even if it passed!' 519 ) 520 io.stdout:flush() 521 end 522 end, true) 523 n_processes = n_processes + 1 524 525 local new_session = Session.new(proc) 526 -- Make it possible to check whether two sessions are from the same test. 527 new_session.data = { test_id = test_id } 528 return new_session 529 end 530 531 busted.subscribe({ 'suite', 'end' }, function() 532 M.check_close(true) 533 local timed_out = false 534 local timer = assert(vim.uv.new_timer()) 535 timer:start(10000, 0, function() 536 timed_out = true 537 end) 538 while n_processes > 0 and not timed_out do 539 uv.run('once') 540 end 541 timer:close() 542 if timed_out then 543 print(('warning: %d dangling Nvim processes'):format(n_processes)) 544 io.stdout:flush() 545 end 546 end) 547 548 --- Starts a (non-RPC, `--headless --listen "Tx"`) Nvim process, waits for exit, and returns result. 549 --- 550 --- @param ... string Nvim CLI args, or `test.session.Opts` table. 551 --- @return test.ProcStream 552 --- @overload fun(opts: test.session.Opts): test.ProcStream 553 function M.spawn_wait(...) 554 local opts = type(...) == 'string' and { args = { ... } } or ... 555 opts.args_rm = opts.args_rm and opts.args_rm or {} 556 table.insert(opts.args_rm, '--embed') 557 local argv, env, io_extra = M._new_argv(opts) 558 local proc = ProcStream.spawn(argv, env, io_extra) 559 proc.collect_text = true 560 proc:read_start() 561 proc:wait() 562 proc:close() 563 return proc 564 end 565 566 --- @class test.session.Opts 567 --- Nvim CLI args 568 --- @field args? string[] 569 --- Remove these args from the default `nvim_argv` args set. Ignored if `merge=false`. 570 --- @field args_rm? string[] 571 --- (default: true) Merge `args` with the default set. Else use only the provided `args`. 572 --- @field merge? boolean 573 --- Environment variables 574 --- @field env? table<string,string> 575 --- Used for stdin_fd, see `:help ui-option` 576 --- @field io_extra? uv.uv_pipe_t 577 578 --- @private 579 --- 580 --- Builds an argument list for use in `new_session()`, `clear()`, and `spawn_wait()`. 581 --- 582 --- @param ... string Nvim CLI args, or `test.session.Opts` table. 583 --- @return string[] 584 --- @return string[]? 585 --- @return uv.uv_pipe_t? 586 --- @overload fun(opts: test.session.Opts): string[], string[]?, uv.uv_pipe_t? 587 function M._new_argv(...) 588 --- @type test.session.Opts|string 589 local opts = select(1, ...) 590 local merge = type(opts) ~= 'table' and true or opts.merge ~= false 591 592 local args = merge and { unpack(M.nvim_argv) } or { M.nvim_prog } 593 if merge then 594 table.insert(args, '--headless') 595 if _G._nvim_test_id then 596 -- Set the server name to the test-id for logging. #8519 597 table.insert(args, '--listen') 598 table.insert(args, _G._nvim_test_id) 599 end 600 end 601 602 local new_args --- @type string[] 603 local io_extra --- @type uv.uv_pipe_t? 604 local env --- @type string[]? List of "key=value" env vars. 605 606 if type(opts) ~= 'table' then 607 new_args = { ... } 608 else 609 args = merge and remove_args(args, opts.args_rm) or args 610 if opts.env then 611 local env_opt = {} --- @type table<string,string> 612 for k, v in pairs(opts.env) do 613 assert(type(k) == 'string') 614 assert(type(v) == 'string') 615 env_opt[k] = v 616 end 617 -- Set these from the environment unless the caller defined them. 618 for _, k in ipairs({ 619 'ASAN_OPTIONS', 620 'GCOV_ERROR_FILE', 621 'HOME', 622 'LD_LIBRARY_PATH', 623 'MSAN_OPTIONS', 624 'NVIM_TEST', 625 'NVIM_LOG_FILE', 626 'NVIM_RPLUGIN_MANIFEST', 627 'PATH', 628 'TMPDIR', 629 'TSAN_OPTIONS', 630 'VIMRUNTIME', 631 'XDG_DATA_DIRS', 632 }) do 633 if not env_opt[k] then 634 env_opt[k] = os.getenv(k) 635 end 636 end 637 env = {} 638 for k, v in pairs(env_opt) do 639 env[#env + 1] = k .. '=' .. v 640 end 641 end 642 new_args = opts.args or {} 643 io_extra = opts.io_extra 644 end 645 for _, arg in ipairs(new_args) do 646 table.insert(args, arg) 647 end 648 return args, env, io_extra 649 end 650 651 --- Dedents string arguments and inserts the resulting text into the current buffer. 652 --- @param ... string 653 function M.insert(...) 654 nvim_feed('i') 655 for _, v in ipairs({ ... }) do 656 local escaped = v:gsub('<', '<lt>') 657 nvim_feed(dedent(escaped)) 658 end 659 nvim_feed('<ESC>') 660 end 661 662 --- @deprecated Use `command()` or `feed()` instead. 663 --- 664 --- Executes an ex-command by user input. Because nvim_input() is used, Vimscript 665 --- errors will not manifest as client (lua) errors. Use command() for that. 666 --- @param ... string 667 function M.feed_command(...) 668 for _, v in ipairs({ ... }) do 669 if v:sub(1, 1) ~= '/' then 670 -- not a search command, prefix with colon 671 nvim_feed(':') 672 end 673 nvim_feed(v:gsub('<', '<lt>')) 674 nvim_feed('<CR>') 675 end 676 end 677 678 -- @deprecated use nvim_exec2() 679 function M.source(code) 680 M.exec(dedent(code)) 681 end 682 683 function M.has_powershell() 684 return M.eval('executable("pwsh")') == 1 685 end 686 687 --- Sets Nvim shell to powershell. 688 --- 689 --- @param fake boolean? Use a fake if powershell is not found on the system. 690 --- @returns true if powershell was found on the system, else false. 691 function M.set_shell_powershell(fake) 692 local found = M.has_powershell() 693 if not fake then 694 assert(found) 695 end 696 local shell = found and 'pwsh' or M.testprg('pwsh-test') 697 local cmd = 'Remove-Item -Force ' 698 .. table.concat( 699 is_os('win') and { 'alias:cat', 'alias:echo', 'alias:sleep', 'alias:sort', 'alias:tee' } 700 or { 'alias:echo' }, 701 ',' 702 ) 703 .. ';' 704 M.exec([[ 705 let &shell = ']] .. shell .. [[' 706 set shellquote= shellxquote= 707 let &shellcmdflag = '-NoLogo -NoProfile -ExecutionPolicy RemoteSigned -Command ' 708 let &shellcmdflag .= '[Console]::InputEncoding=[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new();' 709 let &shellcmdflag .= '$PSDefaultParameterValues[''Out-File:Encoding'']=''utf8'';' 710 let &shellcmdflag .= ']] .. cmd .. [[' 711 let &shellredir = '2>&1 | %%{ "$_" } | Out-File %s; exit $LastExitCode' 712 let &shellpipe = '> %s 2>&1' 713 ]]) 714 return found 715 end 716 717 ---@param func function 718 ---@return table<string,function> 719 function M.create_callindex(func) 720 return setmetatable({}, { 721 --- @param tbl table<any,function> 722 --- @param arg1 string 723 --- @return function 724 __index = function(tbl, arg1) 725 local ret = function(...) 726 return func(arg1, ...) 727 end 728 tbl[arg1] = ret 729 return ret 730 end, 731 }) 732 end 733 734 --- @param method string 735 --- @param ... any 736 function M.nvim_async(method, ...) 737 assert(session, 'no Nvim session') 738 assert(not session.eof_err, 'sending notification after EOF from Nvim') 739 session:notify(method, ...) 740 end 741 742 --- Executes a Vimscript function via RPC. 743 --- Fails on Vimscript error, but does not update v:errmsg. 744 --- @param name string 745 --- @param ... any 746 --- @return any 747 function M.call(name, ...) 748 return M.request('nvim_call_function', name, { ... }) 749 end 750 751 M.async_meths = M.create_callindex(M.nvim_async) 752 753 M.rpc = { 754 fn = M.create_callindex(M.call), 755 api = M.create_callindex(M.request), 756 } 757 758 M.lua = { 759 fn = M.create_callindex(M.call_lua), 760 api = M.create_callindex(M.request_lua), 761 } 762 763 M.describe_lua_and_rpc = function(describe) 764 return function(what, tests) 765 local function d(flavour) 766 describe(string.format('%s (%s)', what, flavour), function(...) 767 return tests(M[flavour].api, ...) 768 end) 769 end 770 771 d('rpc') 772 d('lua') 773 end 774 end 775 776 --- add for typing. The for loop after will overwrite this 777 M.api = vim.api 778 M.fn = vim.fn 779 780 for name, fns in pairs(M.rpc) do 781 --- @diagnostic disable-next-line:no-unknown 782 M[name] = fns 783 end 784 785 -- Executes an ex-command. Vimscript errors manifest as client (lua) errors, but 786 -- v:errmsg will not be updated. 787 M.command = M.api.nvim_command 788 789 -- Evaluates a Vimscript expression. 790 -- Fails on Vimscript error, but does not update v:errmsg. 791 M.eval = M.api.nvim_eval 792 793 function M.poke_eventloop() 794 -- Execute 'nvim_eval' (a deferred function) to 795 -- force at least one main_loop iteration 796 M.api.nvim_eval('1') 797 end 798 799 function M.buf_lines(bufnr) 800 return M.exec_lua('return vim.api.nvim_buf_get_lines((...), 0, -1, false)', bufnr) 801 end 802 803 ---@see buf_lines() 804 function M.curbuf_contents() 805 M.poke_eventloop() -- Before inspecting the buffer, do whatever. 806 return table.concat(M.api.nvim_buf_get_lines(0, 0, -1, true), '\n') 807 end 808 809 function M.expect(contents) 810 return eq(dedent(contents), M.curbuf_contents()) 811 end 812 813 function M.expect_any(contents) 814 contents = dedent(contents) 815 return ok(nil ~= string.find(M.curbuf_contents(), contents, 1, true)) 816 end 817 818 -- Checks that the Nvim session did not terminate. 819 function M.assert_alive() 820 assert(2 == M.eval('1+1'), 'crash? request failed') 821 end 822 823 -- Asserts that buffer is loaded and visible in the current tabpage. 824 function M.assert_visible(bufnr, visible) 825 assert(type(visible) == 'boolean') 826 eq(visible, M.api.nvim_buf_is_loaded(bufnr)) 827 if visible then 828 assert( 829 -1 ~= M.fn.bufwinnr(bufnr), 830 'expected buffer to be visible in current tabpage: ' .. tostring(bufnr) 831 ) 832 else 833 assert( 834 -1 == M.fn.bufwinnr(bufnr), 835 'expected buffer NOT visible in current tabpage: ' .. tostring(bufnr) 836 ) 837 end 838 end 839 840 local start_dir = uv.cwd() 841 842 function M.rmdir(path) 843 local ret, _ = pcall(vim.fs.rm, path, { recursive = true, force = true }) 844 if not ret and is_os('win') then 845 -- Maybe "Permission denied"; try again after changing the nvim 846 -- process to the top-level directory. 847 M.command([[exe 'cd '.fnameescape(']] .. start_dir .. "')") 848 ret, _ = pcall(vim.fs.rm, path, { recursive = true, force = true }) 849 end 850 -- During teardown, the nvim process may not exit quickly enough, then rmdir() 851 -- will fail (on Windows). 852 if not ret then -- Try again. 853 sleep(1000) 854 vim.fs.rm(path, { recursive = true, force = true }) 855 end 856 end 857 858 --- @deprecated Use `t.pcall_err()` to check failure, or `n.command()` to check success. 859 function M.exc_exec(cmd) 860 M.command(([[ 861 try 862 execute "%s" 863 catch 864 let g:__exception = v:exception 865 endtry 866 ]]):format(cmd:gsub('\n', '\\n'):gsub('[\\"]', '\\%0'))) 867 local ret = M.eval('get(g:, "__exception", 0)') 868 M.command('unlet! g:__exception') 869 return ret 870 end 871 872 function M.exec(code) 873 M.api.nvim_exec2(code, {}) 874 end 875 876 --- @param code string 877 --- @return string 878 function M.exec_capture(code) 879 return M.api.nvim_exec2(code, { output = true }).output 880 end 881 882 --- Execute Lua code in the wrapped Nvim session. 883 --- 884 --- When `code` is passed as a function, it is converted into Lua byte code. 885 --- 886 --- Direct upvalues are copied over, however upvalues contained 887 --- within nested functions are not. Upvalues are also copied back when `code` 888 --- finishes executing. See `:help lua-upvalue`. 889 --- 890 --- Only types which can be serialized can be transferred over, e.g: 891 --- `table`, `number`, `boolean`, `string`. 892 --- 893 --- `code` runs with a different environment and thus will have a different global 894 --- environment. See `:help lua-environments`. 895 --- 896 --- Example: 897 --- ```lua 898 --- local upvalue1 = 'upvalue1' 899 --- exec_lua(function(a, b, c) 900 --- print(upvalue1, a, b, c) 901 --- (function() 902 --- print(upvalue2) 903 --- end)() 904 --- end, 'a', 'b', 'c' 905 --- ``` 906 --- Prints: 907 --- ``` 908 --- upvalue1 a b c 909 --- nil 910 --- ``` 911 --- 912 --- Not supported: 913 --- ```lua 914 --- local a = vim.uv.new_timer() 915 --- exec_lua(function() 916 --- print(a) -- Error: a is of type 'userdata' which cannot be serialized. 917 --- end) 918 --- ``` 919 --- @param code string|function 920 --- @param ... any 921 --- @return any 922 function M.exec_lua(code, ...) 923 if type(code) == 'string' then 924 return M.api.nvim_exec_lua(code, { ... }) 925 end 926 927 assert(session, 'no Nvim session') 928 return require('test.functional.testnvim.exec_lua')(session, 2, code, ...) 929 end 930 931 function M.get_pathsep() 932 return is_os('win') and '\\' or '/' 933 end 934 935 --- Gets the filesystem root dir, namely "/" or "C:/". 936 function M.pathroot() 937 local pathsep = package.config:sub(1, 1) 938 return is_os('win') and (M.nvim_dir:sub(1, 2) .. pathsep) or '/' 939 end 940 941 --- Gets the full `…/build/bin/{name}` path of a test program produced by 942 --- `test/functional/fixtures/CMakeLists.txt`. 943 --- 944 --- @param name (string) Name of the test program. 945 function M.testprg(name) 946 local ext = is_os('win') and '.exe' or '' 947 return ('%s/%s%s'):format(M.nvim_dir, name, ext) 948 end 949 950 --- Returns a valid, platform-independent Nvim listen address. 951 --- Useful for communicating with child instances. 952 --- 953 --- @return string 954 function M.new_pipename() 955 -- HACK: Start a server temporarily, get the name, then stop it. 956 local pipename = M.eval('serverstart()') 957 M.fn.serverstop(pipename) 958 -- Remove the pipe so that trying to connect to it without a server listening 959 -- will be an error instead of a hang. 960 os.remove(pipename) 961 return pipename 962 end 963 964 --- @param provider string 965 --- @return string|boolean? 966 function M.missing_provider(provider) 967 if provider == 'ruby' or provider == 'perl' then 968 --- @type string? 969 local e = M.exec_lua("return {require('vim.provider." .. provider .. "').detect()}")[2] 970 return e ~= '' and e or false 971 elseif provider == 'node' then 972 --- @type string? 973 local e = M.fn['provider#node#Detect']()[2] 974 return e ~= '' and e or false 975 elseif provider == 'python' then 976 return M.exec_lua([[return {require('vim.provider.python').detect_by_module('neovim')}]])[2] 977 end 978 assert(false, 'Unknown provider: ' .. provider) 979 end 980 981 local load_factor = 1 982 if t.is_ci() then 983 -- Compute load factor only once (but outside of any tests). 984 M.clear() 985 M.request('nvim_command', 'source test/old/testdir/load.vim') 986 load_factor = M.request('nvim_eval', 'g:test_load_factor') 987 end 988 989 --- @param num number 990 --- @return number 991 function M.load_adjust(num) 992 return math.ceil(num * load_factor) 993 end 994 995 --- @param ctx table<string,any> 996 --- @return table 997 function M.parse_context(ctx) 998 local parsed = {} --- @type table<string,any> 999 for _, item in ipairs({ 'regs', 'jumps', 'bufs', 'gvars' }) do 1000 --- @param v any 1001 parsed[item] = vim.tbl_filter(function(v) 1002 return type(v) == 'table' 1003 end, M.call('msgpackparse', ctx[item])) 1004 end 1005 parsed['bufs'] = parsed['bufs'][1] 1006 --- @param v any 1007 return vim.tbl_map(function(v) 1008 if #v == 0 then 1009 return nil 1010 end 1011 return v 1012 end, parsed) 1013 end 1014 1015 function M.add_builddir_to_rtp() 1016 -- Add runtime from build dir for doc/tags (used with :help). 1017 M.command(string.format([[set rtp+=%s/runtime]], t.paths.test_build_dir)) 1018 end 1019 1020 --- Create folder with non existing parents 1021 --- 1022 --- TODO(justinmk): lift this and `t.mkdir()` into vim.fs. 1023 --- 1024 --- @param path string 1025 --- @return boolean? 1026 function M.mkdir_p(path) 1027 return os.execute( 1028 (is_os('win') and 'mkdir ' .. string.gsub(path, '/', '\\') or 'mkdir -p ' .. path) 1029 ) 1030 end 1031 1032 local testid = (function() 1033 local id = 0 1034 return function() 1035 id = id + 1 1036 return id 1037 end 1038 end)() 1039 1040 return function() 1041 local g = getfenv(2) 1042 1043 --- @type function? 1044 local before_each = g.before_each 1045 --- @type function? 1046 local after_each = g.after_each 1047 1048 if before_each then 1049 before_each(function() 1050 local id = ('T%d'):format(testid()) 1051 _G._nvim_test_id = id 1052 end) 1053 end 1054 1055 if after_each then 1056 after_each(function() 1057 if not vim.endswith(_G._nvim_test_id, 'x') then 1058 -- Use a different test ID for skipped tests as well as Nvim instances spawned 1059 -- between this after_each() and the next before_each() (e.g. in setup()). 1060 _G._nvim_test_id = _G._nvim_test_id .. 'x' 1061 end 1062 check_logs() 1063 check_cores('build/bin/nvim') 1064 if session then 1065 local msg = session:next_message(0) 1066 if msg then 1067 if msg[1] == 'notification' and msg[2] == 'nvim_error_event' then 1068 error(msg[3][2]) 1069 end 1070 end 1071 end 1072 end) 1073 end 1074 1075 return M 1076 end