testutil.lua (24551B)
1 local luaassert = require('luassert') 2 local busted = require('busted') 3 local uv = vim.uv 4 local Paths = require('test.cmakeconfig.paths') 5 6 luaassert:set_parameter('TableFormatLevel', 100) 7 8 --- Functions executing in the context of the test runner (not the current nvim test session). 9 --- @class test.testutil 10 local M = { 11 paths = Paths, 12 } 13 14 --- @param path string 15 --- @return boolean 16 function M.isdir(path) 17 if not path then 18 return false 19 end 20 local stat = uv.fs_stat(path) 21 if not stat then 22 return false 23 end 24 return stat.type == 'directory' 25 end 26 27 --- (Only on Windows) Replaces yucky "\\" slashes with delicious "/" slashes in a string, or all 28 --- string values in a table (recursively). 29 --- 30 --- @generic T: string|table 31 --- @param obj T 32 --- @return T|nil 33 function M.fix_slashes(obj) 34 if not M.is_os('win') then 35 return obj 36 end 37 if type(obj) == 'string' then 38 local ret = string.gsub(obj, '\\', '/') 39 return ret 40 elseif type(obj) == 'table' then 41 --- @cast obj table<any,any> 42 local ret = {} --- @type table<any,any> 43 for k, v in pairs(obj) do 44 ret[k] = M.fix_slashes(v) 45 end 46 return ret 47 end 48 assert(false, 'expected string or table of strings, got ' .. type(obj)) 49 end 50 51 --- @param ... string|string[] 52 --- @return string[] 53 function M.argss_to_cmd(...) 54 local cmd = {} --- @type string[] 55 for i = 1, select('#', ...) do 56 local arg = select(i, ...) 57 if type(arg) == 'string' then 58 cmd[#cmd + 1] = arg 59 else 60 --- @cast arg string[] 61 for _, subarg in ipairs(arg) do 62 cmd[#cmd + 1] = subarg 63 end 64 end 65 end 66 return cmd 67 end 68 69 --- Calls fn() until it succeeds, up to `max` times or until `max_ms` 70 --- milliseconds have passed. 71 --- @param max integer? 72 --- @param max_ms integer? 73 --- @param fn function 74 --- @return any 75 function M.retry(max, max_ms, fn) 76 assert(max == nil or max > 0) 77 assert(max_ms == nil or max_ms > 0) 78 local tries = 1 79 local timeout = (max_ms and max_ms or 10000) 80 local start_time = uv.now() 81 while true do 82 --- @type boolean, any 83 local status, result = pcall(fn) 84 if status then 85 return result 86 end 87 uv.update_time() -- Update cached value of luv.now() (libuv: uv_now()). 88 if (max and tries >= max) or (uv.now() - start_time > timeout) then 89 busted.fail(string.format('retry() attempts: %d\n%s', tries, tostring(result)), 2) 90 end 91 tries = tries + 1 92 uv.sleep(20) -- Avoid hot loop... 93 end 94 end 95 96 local check_logs_useless_lines = { 97 ['Warning: noted but unhandled ioctl'] = 1, 98 ['could cause spurious value errors to appear'] = 2, 99 ['See README_MISSING_SYSCALL_OR_IOCTL for guidance'] = 3, 100 } 101 102 function M.eq(expected, actual, context) 103 return luaassert.are.same(expected, actual, context) 104 end 105 function M.neq(expected, actual, context) 106 return luaassert.are_not.same(expected, actual, context) 107 end 108 109 --- Asserts that `cond` is true, or prints a message. 110 --- 111 --- @param cond (boolean) expression to assert 112 --- @param expected (any) description of expected result 113 --- @param actual (any) description of actual result 114 function M.ok(cond, expected, actual) 115 assert( 116 (not expected and not actual) or (expected and actual), 117 'if "expected" is given, "actual" is also required' 118 ) 119 local msg = expected and ('expected %s, got: %s'):format(expected, tostring(actual)) or nil 120 return assert(cond, msg) 121 end 122 123 local function epicfail(state, arguments, _) 124 state.failure_message = arguments[1] 125 return false 126 end 127 luaassert:register('assertion', 'epicfail', epicfail) 128 function M.fail(msg) 129 return luaassert.epicfail(msg) 130 end 131 132 --- @param pat string 133 --- @param actual string 134 --- @return boolean 135 function M.matches(pat, actual) 136 assert(pat and pat ~= '', 'pat must be a non-empty string') 137 if nil ~= string.match(actual, pat) then 138 return true 139 end 140 error(string.format('Pattern does not match.\nPattern:\n%s\nActual:\n%s', pat, actual)) 141 end 142 143 --- Asserts that `pat` matches (or *not* if inverse=true) any text in the tail of `logfile`. 144 --- 145 --- Matches are not restricted to a single line. 146 --- 147 --- Retries for 1 second in case of filesystem delay. 148 --- 149 ---@param pat (string) Lua pattern to match text in the log file 150 ---@param logfile? (string) Full path to log file (default=$NVIM_LOG_FILE) 151 ---@param nrlines? (number) Search up to this many log lines (default 10) 152 ---@param inverse? (boolean) Assert that the pattern does NOT match. 153 function M.assert_log(pat, logfile, nrlines, inverse) 154 logfile = logfile or os.getenv('NVIM_LOG_FILE') or 'nvim.log' 155 assert(logfile ~= nil, 'no logfile') 156 nrlines = nrlines or 10 157 158 M.retry(nil, 1000, function() 159 local lines = M.read_file_list(logfile, -nrlines) or {} 160 local text = table.concat(lines, '\n') 161 local ismatch = not not text:match(pat) 162 if (ismatch and inverse) or not (ismatch or inverse) then 163 local msg = string.format( 164 'Pattern %s %sfound in log (last %d lines): %q:\n%s', 165 vim.inspect(pat), 166 (inverse and '' or 'not '), 167 nrlines, 168 logfile, 169 vim.text.indent(4, text) 170 ) 171 error(msg) 172 end 173 end) 174 end 175 176 --- Asserts that `pat` does NOT match any line in the tail of `logfile`. 177 --- 178 --- @see assert_log 179 --- @param pat (string) Lua pattern to match lines in the log file 180 --- @param logfile? (string) Full path to log file (default=$NVIM_LOG_FILE) 181 --- @param nrlines? (number) Search up to this many log lines 182 function M.assert_nolog(pat, logfile, nrlines) 183 return M.assert_log(pat, logfile, nrlines, true) 184 end 185 186 --- @param fn fun(...): any 187 --- @param ... any 188 --- @return boolean, any 189 function M.pcall(fn, ...) 190 assert(type(fn) == 'function') 191 local status, rv = pcall(fn, ...) 192 if status then 193 return status, rv 194 end 195 196 -- From: 197 -- C:/long/path/foo.lua:186: Expected string, got number 198 -- to: 199 -- .../foo.lua:0: Expected string, got number 200 local errmsg = tostring(rv) 201 :gsub('([%s<])vim[/\\]([^%s:/\\]+):%d+', '%1\xffvim\xff%2:0') 202 :gsub('[^%s<]-[/\\]([^%s:/\\]+):%d+', '.../%1:0') 203 :gsub('\xffvim\xff', 'vim/') 204 205 -- Scrub numbers in paths/stacktraces: 206 -- shared.lua:0: in function 'gsplit' 207 -- shared.lua:0: in function <shared.lua:0>' 208 errmsg = errmsg:gsub('([^%s].lua):%d+', '%1:0') 209 -- [string "<nvim>"]:0: 210 -- [string ":lua"]:0: 211 -- [string ":luado"]:0: 212 errmsg = errmsg:gsub('(%[string "[^"]+"%]):%d+', '%1:0') 213 214 -- Scrub tab chars: 215 errmsg = errmsg:gsub('\t', ' ') 216 -- In Lua 5.1, we sometimes get a "(tail call): ?" on the last line. 217 -- We remove this so that the tests are not lua dependent. 218 errmsg = errmsg:gsub('%s*%(tail call%): %?', '') 219 220 return status, errmsg 221 end 222 223 -- Invokes `fn` and returns the error string (with truncated paths), or raises 224 -- an error if `fn` succeeds. 225 -- 226 -- Replaces line/column numbers with zero: 227 -- shared.lua:0: in function 'gsplit' 228 -- shared.lua:0: in function <shared.lua:0>' 229 -- 230 -- Usage: 231 -- -- Match exact string. 232 -- eq('e', pcall_err(function(a, b) error('e') end, 'arg1', 'arg2')) 233 -- -- Match Lua pattern. 234 -- matches('e[or]+$', pcall_err(function(a, b) error('some error') end, 'arg1', 'arg2')) 235 -- 236 --- @param fn function 237 --- @return string 238 function M.pcall_err_withfile(fn, ...) 239 assert(type(fn) == 'function') 240 local status, rv = M.pcall(fn, ...) 241 if status == true then 242 error('expected failure, but got success') 243 end 244 return rv 245 end 246 247 --- @param fn function 248 --- @param ... any 249 --- @return string 250 function M.pcall_err_withtrace(fn, ...) 251 local errmsg = M.pcall_err_withfile(fn, ...) 252 253 return ( 254 errmsg 255 :gsub('^%.%.%./testnvim%.lua:0: ', '') 256 :gsub('^Lua:- ', '') 257 :gsub('^%[string "<nvim>"%]:0: ', '') 258 ) 259 end 260 261 --- @param fn function 262 --- @param ... any 263 --- @return string 264 function M.pcall_err(fn, ...) 265 return M.remove_trace(M.pcall_err_withtrace(fn, ...)) 266 end 267 268 --- @param s string 269 --- @return string 270 function M.remove_trace(s) 271 return (s:gsub('\n%s*stack traceback:.*', '')) 272 end 273 274 -- initial_path: directory to recurse into 275 -- re: include pattern (string) 276 -- exc_re: exclude pattern(s) (string or table) 277 function M.glob(initial_path, re, exc_re) 278 exc_re = type(exc_re) == 'table' and exc_re or { exc_re } 279 local paths_to_check = { initial_path } --- @type string[] 280 local ret = {} --- @type string[] 281 local checked_files = {} --- @type table<string,true> 282 local function is_excluded(path) 283 for _, pat in pairs(exc_re) do 284 if path:match(pat) then 285 return true 286 end 287 end 288 return false 289 end 290 291 if is_excluded(initial_path) then 292 return ret 293 end 294 while #paths_to_check > 0 do 295 local cur_path = paths_to_check[#paths_to_check] 296 paths_to_check[#paths_to_check] = nil 297 for e in vim.fs.dir(cur_path) do 298 local full_path = cur_path .. '/' .. e 299 local checked_path = full_path:sub(#initial_path + 1) 300 if (not is_excluded(checked_path)) and e:sub(1, 1) ~= '.' then 301 local stat = uv.fs_stat(full_path) 302 if stat then 303 local check_key = stat.dev .. ':' .. tostring(stat.ino) 304 if not checked_files[check_key] then 305 checked_files[check_key] = true 306 if stat.type == 'directory' then 307 paths_to_check[#paths_to_check + 1] = full_path 308 elseif not re or checked_path:match(re) then 309 ret[#ret + 1] = full_path 310 end 311 end 312 end 313 end 314 end 315 end 316 return ret 317 end 318 319 function M.check_logs() 320 local log_dir = os.getenv('LOG_DIR') 321 local runtime_errors = {} 322 if log_dir and M.isdir(log_dir) then 323 for tail in vim.fs.dir(log_dir) do 324 if tail:sub(1, 30) == 'valgrind-' or tail:find('san%.') then 325 local file = log_dir .. '/' .. tail 326 local fd = assert(io.open(file)) 327 local start_msg = ('='):rep(20) .. ' File ' .. file .. ' ' .. ('='):rep(20) 328 local lines = {} --- @type string[] 329 local warning_line = 0 330 for line in fd:lines() do 331 local cur_warning_line = check_logs_useless_lines[line] 332 if cur_warning_line == warning_line + 1 then 333 warning_line = cur_warning_line 334 else 335 lines[#lines + 1] = line 336 end 337 end 338 fd:close() 339 if #lines > 0 then 340 --- @type boolean?, file*? 341 local status, f 342 local out = io.stdout 343 if os.getenv('SYMBOLIZER') then 344 status, f = pcall(M.repeated_read_cmd, os.getenv('SYMBOLIZER'), '-l', file) 345 end 346 out:write(start_msg .. '\n') 347 if status then 348 assert(f) 349 for line in f:lines() do 350 out:write('= ' .. line .. '\n') 351 end 352 f:close() 353 else 354 out:write('= ' .. table.concat(lines, '\n= ') .. '\n') 355 end 356 out:write(select(1, start_msg:gsub('.', '=')) .. '\n') 357 table.insert(runtime_errors, file) 358 end 359 os.remove(file) 360 end 361 end 362 end 363 luaassert( 364 0 == #runtime_errors, 365 string.format('Found runtime errors in logfile(s): %s', table.concat(runtime_errors, ', ')) 366 ) 367 end 368 369 local sysname = uv.os_uname().sysname:lower() 370 371 --- @param s 'win'|'mac'|'linux'|'freebsd'|'openbsd'|'bsd' 372 --- @return boolean 373 function M.is_os(s) 374 if 375 not (s == 'win' or s == 'mac' or s == 'linux' or s == 'freebsd' or s == 'openbsd' or s == 'bsd') 376 then 377 error('unknown platform: ' .. tostring(s)) 378 end 379 return not not ( 380 (s == 'win' and (sysname:find('windows') or sysname:find('mingw'))) 381 or (s == 'mac' and sysname == 'darwin') 382 or (s == 'linux' and sysname == 'linux') 383 or (s == 'freebsd' and sysname == 'freebsd') 384 or (s == 'openbsd' and sysname == 'openbsd') 385 or (s == 'bsd' and sysname:find('bsd')) 386 ) 387 end 388 389 local architecture = uv.os_uname().machine 390 391 --- @param s 'x86_64'|'arm64' 392 --- @return boolean 393 function M.is_arch(s) 394 if not (s == 'x86_64' or s == 'arm64') then 395 error('unknown architecture: ' .. tostring(s)) 396 end 397 return s == architecture 398 end 399 400 function M.is_asan() 401 return M.paths.is_asan 402 end 403 404 function M.is_zig_build() 405 return M.paths.is_zig_build 406 end 407 408 local tmpname_id = 0 409 local tmpdir = os.getenv('TMPDIR') or os.getenv('TEMP') 410 local tmpdir_is_local = not not (tmpdir and tmpdir:find('Xtest')) 411 412 local function get_tmpname() 413 if tmpdir_is_local then 414 -- Cannot control os.tmpname() dir, so hack our own tmpname() impl. 415 tmpname_id = tmpname_id + 1 416 -- "…/Xtest_tmpdir/T42.7" 417 return ('%s/%s.%d'):format(tmpdir, (_G._nvim_test_id or 'nvim-test'), tmpname_id) 418 end 419 420 local fname = os.tmpname() 421 422 if M.is_os('win') and fname:sub(1, 2) == '\\s' then 423 -- In Windows tmpname() returns a filename starting with 424 -- special sequence \s, prepend $TEMP path 425 return tmpdir .. fname 426 elseif M.is_os('mac') and fname:match('^/tmp') then 427 -- In OS X /tmp links to /private/tmp 428 return '/private' .. fname 429 end 430 return fname 431 end 432 433 --- Generates a unique filepath for use by tests, in a test-specific "…/Xtest_tmpdir/T42.7" 434 --- directory (which is cleaned up by the test runner). 435 --- 436 --- @param create? boolean (default true) Create the file. 437 --- @return string 438 function M.tmpname(create) 439 local fname = get_tmpname() 440 os.remove(fname) 441 if create ~= false then 442 assert(io.open(fname, 'w')):close() 443 end 444 return fname 445 end 446 447 local function deps_prefix() 448 local env = os.getenv('DEPS_PREFIX') 449 return (env and env ~= '') and env or '.deps/usr' 450 end 451 452 local tests_skipped = 0 453 454 function M.check_cores(app, force) -- luacheck: ignore 455 -- Temporary workaround: skip core check as it interferes with CI. 456 if true then 457 return 458 end 459 app = app or 'build/bin/nvim' -- luacheck: ignore 460 --- @type string, string?, string[] 461 local initial_path, re, exc_re 462 local gdb_db_cmd = 463 'gdb -n -batch -ex "thread apply all bt full" "$_NVIM_TEST_APP" -c "$_NVIM_TEST_CORE"' 464 local lldb_db_cmd = 'lldb -Q -o "bt all" -f "$_NVIM_TEST_APP" -c "$_NVIM_TEST_CORE"' 465 local random_skip = false 466 -- Workspace-local $TMPDIR, scrubbed and pattern-escaped. 467 -- "./Xtest-tmpdir/" => "Xtest%-tmpdir" 468 local local_tmpdir = nil 469 if tmpdir_is_local and tmpdir then 470 local_tmpdir = 471 vim.pesc(vim.fs.relpath(assert(vim.uv.cwd()), tmpdir):gsub('^[ ./]+', ''):gsub('%/+$', '')) 472 end 473 474 local db_cmd --- @type string 475 local test_glob_dir = os.getenv('NVIM_TEST_CORE_GLOB_DIRECTORY') 476 if test_glob_dir and test_glob_dir ~= '' then 477 initial_path = test_glob_dir 478 re = os.getenv('NVIM_TEST_CORE_GLOB_RE') 479 exc_re = { os.getenv('NVIM_TEST_CORE_EXC_RE'), local_tmpdir } 480 db_cmd = os.getenv('NVIM_TEST_CORE_DB_CMD') or gdb_db_cmd 481 random_skip = os.getenv('NVIM_TEST_CORE_RANDOM_SKIP') ~= '' 482 elseif M.is_os('mac') then 483 initial_path = '/cores' 484 re = nil 485 exc_re = { local_tmpdir } 486 db_cmd = lldb_db_cmd 487 else 488 initial_path = '.' 489 if M.is_os('freebsd') then 490 re = '/nvim.core$' 491 else 492 re = '/core[^/]*$' 493 end 494 exc_re = { '^/%.deps$', '^/%' .. deps_prefix() .. '$', local_tmpdir, '^/%node_modules$' } 495 db_cmd = gdb_db_cmd 496 random_skip = true 497 end 498 -- Finding cores takes too much time on linux 499 if not force and random_skip and math.random() < 0.9 then 500 tests_skipped = tests_skipped + 1 501 return 502 end 503 local cores = M.glob(initial_path, re, exc_re) 504 local found_cores = 0 505 local out = io.stdout 506 for _, core in ipairs(cores) do 507 local len = 80 - #core - #'Core file ' - 2 508 local esigns = ('='):rep(len / 2) 509 out:write(('\n%s Core file %s %s\n'):format(esigns, core, esigns)) 510 out:flush() 511 os.execute(db_cmd:gsub('%$_NVIM_TEST_APP', app):gsub('%$_NVIM_TEST_CORE', core) .. ' 2>&1') 512 out:write('\n') 513 found_cores = found_cores + 1 514 os.remove(core) 515 end 516 if found_cores ~= 0 then 517 out:write(('\nTests covered by this check: %u\n'):format(tests_skipped + 1)) 518 end 519 tests_skipped = 0 520 if found_cores > 0 then 521 error('crash detected (see above)') 522 end 523 end 524 525 --- @return string? 526 function M.repeated_read_cmd(...) 527 local cmd = M.argss_to_cmd(...) 528 local data = {} 529 local got_code = nil 530 local stdout = assert(vim.uv.new_pipe(false)) 531 local handle = assert( 532 vim.uv.spawn( 533 cmd[1], 534 { args = vim.list_slice(cmd, 2), stdio = { nil, stdout, 2 }, hide = true }, 535 function(code, _signal) 536 got_code = code 537 end 538 ) 539 ) 540 stdout:read_start(function(err, chunk) 541 if err or chunk == nil then 542 stdout:read_stop() 543 stdout:close() 544 else 545 table.insert(data, chunk) 546 end 547 end) 548 549 while not stdout:is_closing() or got_code == nil do 550 vim.uv.run('once') 551 end 552 553 if got_code ~= 0 then 554 error('command ' .. vim.inspect(cmd) .. 'unexpectedly exited with status ' .. got_code) 555 end 556 handle:close() 557 return table.concat(data) 558 end 559 560 --- @generic T 561 --- @param orig T 562 --- @return T 563 function M.shallowcopy(orig) 564 if type(orig) ~= 'table' then 565 return orig 566 end 567 --- @cast orig table<any,any> 568 local copy = {} --- @type table<any,any> 569 for orig_key, orig_value in pairs(orig) do 570 copy[orig_key] = orig_value 571 end 572 return copy 573 end 574 575 --- @param d1 table<any,any> 576 --- @param d2 table<any,any> 577 --- @return table<any,any> 578 function M.mergedicts_copy(d1, d2) 579 local ret = M.shallowcopy(d1) 580 for k, v in pairs(d2) do 581 if d2[k] == vim.NIL then 582 ret[k] = nil 583 elseif type(d1[k]) == 'table' and type(v) == 'table' then 584 ret[k] = M.mergedicts_copy(d1[k], v) 585 else 586 ret[k] = v 587 end 588 end 589 return ret 590 end 591 592 --- dictdiff: find a diff so that mergedicts_copy(d1, diff) is equal to d2 593 --- 594 --- Note: does not do copies of d2 values used. 595 --- @param d1 table<any,any> 596 --- @param d2 table<any,any> 597 function M.dictdiff(d1, d2) 598 local ret = {} --- @type table<any,any> 599 local hasdiff = false 600 for k, v in pairs(d1) do 601 if d2[k] == nil then 602 hasdiff = true 603 ret[k] = vim.NIL 604 elseif type(v) == type(d2[k]) then 605 if type(v) == 'table' then 606 local subdiff = M.dictdiff(v, d2[k]) 607 if subdiff ~= nil then 608 hasdiff = true 609 ret[k] = subdiff 610 end 611 elseif v ~= d2[k] then 612 ret[k] = d2[k] 613 hasdiff = true 614 end 615 else 616 ret[k] = d2[k] 617 hasdiff = true 618 end 619 end 620 local shallowcopy = M.shallowcopy 621 for k, v in pairs(d2) do 622 if d1[k] == nil then 623 ret[k] = shallowcopy(v) 624 hasdiff = true 625 end 626 end 627 if hasdiff then 628 return ret 629 else 630 return nil 631 end 632 end 633 634 -- Concat list-like tables. 635 function M.concat_tables(...) 636 local ret = {} --- @type table<any,any> 637 for i = 1, select('#', ...) do 638 --- @type table<any,any> 639 local tbl = select(i, ...) 640 if tbl then 641 for _, v in ipairs(tbl) do 642 ret[#ret + 1] = v 643 end 644 end 645 end 646 return ret 647 end 648 649 --- Get all permutations of an array. 650 --- 651 --- @param arr any[] 652 --- @return any[][] 653 function M.permutations(arr) 654 local res = {} --- @type any[][] 655 --- @param a any[] 656 --- @param n integer 657 local function gen(a, n) 658 if n == 0 then 659 res[#res + 1] = M.shallowcopy(a) 660 return 661 end 662 for i = 1, n do 663 a[n], a[i] = a[i], a[n] 664 gen(a, n - 1) 665 a[n], a[i] = a[i], a[n] 666 end 667 end 668 gen(M.shallowcopy(arr), #arr) 669 return res 670 end 671 672 --- @param str string 673 --- @param leave_indent? integer 674 --- @return string 675 function M.dedent(str, leave_indent) 676 -- Last blank line often has non-matching indent, so remove it. 677 str = str:gsub('\n[ ]+$', '\n') 678 return (vim.text.indent(leave_indent or 0, str)) 679 end 680 681 function M.intchar2lua(ch) 682 ch = tonumber(ch) 683 return (20 <= ch and ch < 127) and ('%c'):format(ch) or ch 684 end 685 686 --- @param str string 687 --- @return string 688 function M.hexdump(str) 689 local len = string.len(str) 690 local dump = '' 691 local hex = '' 692 local asc = '' 693 694 for i = 1, len do 695 if 1 == i % 8 then 696 dump = dump .. hex .. asc .. '\n' 697 hex = string.format('%04x: ', i - 1) 698 asc = '' 699 end 700 701 local ord = string.byte(str, i) 702 hex = hex .. string.format('%02x ', ord) 703 if ord >= 32 and ord <= 126 then 704 asc = asc .. string.char(ord) 705 else 706 asc = asc .. '.' 707 end 708 end 709 710 return dump .. hex .. string.rep(' ', 8 - len % 8) .. asc 711 end 712 713 --- Reads text lines from `filename` into a table. 714 --- @param filename string path to file 715 --- @param start? integer start line (1-indexed), negative means "lines before end" (tail) 716 --- @return string[]? 717 function M.read_file_list(filename, start) 718 local lnum = (start ~= nil and type(start) == 'number') and start or 1 719 local tail = (lnum < 0) 720 local maxlines = tail and math.abs(lnum) or nil 721 local file = io.open(filename, 'r') 722 if not file then 723 return nil 724 end 725 726 -- There is no need to read more than the last 2MB of the log file, so seek 727 -- to that. 728 local file_size = file:seek('end') 729 local offset = file_size - 2000000 730 if offset < 0 then 731 offset = 0 732 end 733 file:seek('set', offset) 734 735 local lines = {} 736 local i = 1 737 local line = file:read('*l') 738 while line ~= nil do 739 if i >= start then 740 table.insert(lines, line) 741 if #lines > maxlines then 742 table.remove(lines, 1) 743 end 744 end 745 i = i + 1 746 line = file:read('*l') 747 end 748 file:close() 749 return lines 750 end 751 752 --- Reads the entire contents of `filename` into a string. 753 --- @param filename string 754 --- @return string? 755 function M.read_file(filename) 756 local file = io.open(filename, 'r') 757 if not file then 758 return nil 759 end 760 local ret = file:read('*a') 761 file:close() 762 return ret 763 end 764 765 -- Dedent the given text and write it to the file name. 766 function M.write_file(name, text, no_dedent, append) 767 local file = assert(io.open(name, (append and 'a' or 'w'))) 768 if type(text) == 'table' then 769 -- Byte blob 770 --- @type string[] 771 local bytes = text 772 text = '' 773 for _, char in ipairs(bytes) do 774 text = ('%s%c'):format(text, char) 775 end 776 elseif not no_dedent then 777 text = M.dedent(text) 778 end 779 file:write(text) 780 file:flush() 781 file:close() 782 end 783 784 --- @param name? 'cirrus'|'github' 785 --- @return boolean 786 function M.is_ci(name) 787 local any = (name == nil) 788 assert(any or name == 'github' or name == 'cirrus') 789 local gh = ((any or name == 'github') and nil ~= os.getenv('GITHUB_ACTIONS')) 790 local cirrus = ((any or name == 'cirrus') and nil ~= os.getenv('CIRRUS_CI')) 791 return gh or cirrus 792 end 793 794 -- Gets the (tail) contents of `logfile`. 795 -- Also moves the file to "${NVIM_LOG_FILE}.displayed" on CI environments. 796 function M.read_nvim_log(logfile, ci_rename) 797 logfile = logfile or os.getenv('NVIM_LOG_FILE') or 'nvim.log' 798 assert(uv.fs_stat(logfile), ('logfile not found: %q'):format(logfile)) 799 local is_ci = M.is_ci() 800 local keep = is_ci and 100 or 10 801 local lines = M.read_file_list(logfile, -keep) or {} 802 local log = ( 803 ('-'):rep(78) 804 .. '\n' 805 .. string.format('$NVIM_LOG_FILE: %s\n', logfile) 806 .. (#lines > 0 and '(last ' .. tostring(keep) .. ' lines)\n' or '(empty)\n') 807 ) 808 for _, line in ipairs(lines) do 809 log = log .. line .. '\n' 810 end 811 log = log .. ('-'):rep(78) .. '\n' 812 if is_ci and ci_rename then 813 os.rename(logfile, logfile .. '.displayed') 814 end 815 return log 816 end 817 818 --- @param path string 819 --- @return boolean? 820 function M.mkdir(path) 821 -- 493 is 0755 in decimal 822 return (uv.fs_mkdir(path, 493)) 823 end 824 825 --- @param expected any[] 826 --- @param received any[] 827 --- @param kind string 828 --- @return any 829 function M.expect_events(expected, received, kind) 830 if not pcall(M.eq, expected, received) then 831 local msg = 'unexpected ' .. kind .. ' received.\n\n' 832 833 msg = msg .. 'received events:\n' 834 for _, e in ipairs(received) do 835 msg = msg .. ' ' .. vim.inspect(e) .. ';\n' 836 end 837 msg = msg .. '\nexpected events:\n' 838 for _, e in ipairs(expected) do 839 msg = msg .. ' ' .. vim.inspect(e) .. ';\n' 840 end 841 M.fail(msg) 842 end 843 return received 844 end 845 846 --- @param cond boolean 847 --- @param reason? string 848 --- @return boolean 849 function M.skip(cond, reason) 850 if cond then 851 --- @type fun(reason: string) 852 local pending = getfenv(2).pending 853 pending(reason or 'FIXME') 854 return true 855 end 856 return false 857 end 858 859 -- Calls pending() and returns `true` if the system is too slow to 860 -- run fragile or expensive tests. Else returns `false`. 861 function M.skip_fragile(pending_fn, cond) 862 if pending_fn == nil or type(pending_fn) ~= type(function() end) then 863 error('invalid pending_fn') 864 end 865 if cond then 866 pending_fn('skipped (test is fragile on this system)', function() end) 867 return true 868 elseif os.getenv('TEST_SKIP_FRAGILE') then 869 pending_fn('skipped (TEST_SKIP_FRAGILE)', function() end) 870 return true 871 end 872 return false 873 end 874 875 function M.translations_enabled() 876 return M.paths.translations_enabled 877 end 878 879 return M