From e4c872f28a3cf3ab86aa2785a241334f0f538f84 Mon Sep 17 00:00:00 2001 From: MisanthropicBit Date: Sat, 29 Jun 2024 11:18:17 +0200 Subject: [PATCH] Debugger support (#9) --- README.md | 8 +- lua/neotest-busted/init.lua | 180 ++++++++++++++++++++++------- lua/neotest-busted/start_debug.lua | 3 + lua/neotest-busted/types.lua | 18 ++- lua/neotest-busted/util.lua | 4 +- scripts/test-runner.lua | 24 ++-- tests/adapter_build_spec_spec.lua | 108 +++++++++++++++++ tests/adapter_results_spec.lua | 21 ++-- tests/util_spec.lua | 1 + 9 files changed, 300 insertions(+), 67 deletions(-) create mode 100644 lua/neotest-busted/start_debug.lua diff --git a/README.md b/README.md index 4355583..47ede5b 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ neovim as the lua interpreter. - [Defining tests](#defining-tests) - [Luarocks and Busted](#luarocks-and-busted) - [Running from the command line](#running-from-the-command-line) +- [Debugging tests](#debugging-tests) - [FAQ](#faq) ## Requirements @@ -156,7 +157,7 @@ the command will automatically try to find your tests in a `spec/`, `test/`, or `tests/` directory. ```shell -$ nvim -u NONE -l ./scripts/test-runner.lua tests/my_spec.lua +$ nvim -l ./scripts/test-runner.lua tests/my_spec.lua ``` #### Test via rockspec @@ -173,6 +174,11 @@ test = { } ``` +## Debugging tests + +`neotest-busted` has support for debugging tests via [`local-lua-debugger-vscode`](https://github.com/tomblind/local-lua-debugger-vscode) +which can be set up via [`nvim-dap`](https://github.com/mfussenegger/nvim-dap/wiki/Debug-Adapter-installation#lua). Once set up, you can set a breakpoint and run the test with the `dap` strategy. Please refer to the [`neotest`](https://github.com/nvim-neotest/neotest) documentation for more information. + ## FAQ #### Q: Can I run async tests with neotest-busted? diff --git a/lua/neotest-busted/init.lua b/lua/neotest-busted/init.lua index 97a8776..11a94f4 100644 --- a/lua/neotest-busted/init.lua +++ b/lua/neotest-busted/init.lua @@ -6,13 +6,24 @@ local lib = require("neotest.lib") local logger = require("neotest.logging") local types = require("neotest.types") -local function get_strategy_config(strategy) - local strategy_configs = { - dap = nil, -- TODO: Add dap config - } - if strategy_configs[strategy] then - return strategy_configs[strategy]() +local log_methods = { + "debug", + "info", + "warn", + "error", +} + +---@param message string +---@param level 1 | 2 | 3 | 4 +local function log_and_notify(message, level) + local log_method = log_methods[level] + + if not log_method then + return end + + logger[log_method](message) + vim.notify(message, level) end ---@type neotest.Adapter @@ -120,6 +131,11 @@ local function get_reporter_path() return table.concat({ script_path(), "output_handler.lua" }) end +---@return string +local function get_debug_start_script() + return table.concat({ script_path(), "start_debug.lua" }) +end + --- Escape special characters in a lua pattern ---@param filter string ---@return string @@ -141,22 +157,27 @@ local function escape_test_pattern_filter(filter) ) end +---@param string string +local function quote_string(string) + return '"' .. string .. '"' +end + ---@param results_path string? ---@param paths string[] ---@param filters string[] ----@param options neotest-busted.BustedCommandOptions? ----@return neotest-busted.BustedCommandConfig? -function BustedNeotestAdapter.create_busted_command(results_path, paths, filters, options) +---@param options neotest-busted.TestCommandOptions? +---@return neotest-busted.TestCommandConfig? +function BustedNeotestAdapter.create_test_command(results_path, paths, filters, options) local busted = BustedNeotestAdapter.find_busted_command() if not busted then + log_and_notify("Could not find a busted command", vim.log.levels.ERROR) return end -- stylua: ignore start ---@type string[] - local command = { - vim.loop.exepath(), + local arguments = { "--headless", "-i", "NONE", -- no shada "-n", -- no swapfile, always in-memory @@ -167,6 +188,7 @@ function BustedNeotestAdapter.create_busted_command(results_path, paths, filters ---@type string[], string[] local lua_paths, lua_cpaths = {}, {} + -- TODO: Should paths be quoted? Try seeing if a path with a space works -- Append custom paths from config if vim.tbl_islist(config.busted_paths) then vim.list_extend(lua_paths, config.busted_paths) @@ -185,8 +207,8 @@ function BustedNeotestAdapter.create_busted_command(results_path, paths, filters vim.list_extend(lua_cpaths, busted.lua_cpaths) -- Create '-c' arguments for updating package paths in neovim - vim.list_extend(command, util.create_package_path_argument("package.path", lua_paths)) - vim.list_extend(command, util.create_package_path_argument("package.cpath", lua_cpaths)) + vim.list_extend(arguments, util.create_package_path_argument("package.path", lua_paths)) + vim.list_extend(arguments, util.create_package_path_argument("package.cpath", lua_cpaths)) local _options = options or {} @@ -199,15 +221,15 @@ function BustedNeotestAdapter.create_busted_command(results_path, paths, filters "--verbose", } - if _options.output_handler then + if _options.busted_output_handler then vim.list_extend(busted_command, { "--output", - _options.output_handler, + _options.busted_output_handler, }) - if _options.output_handler_options then + if _options.busted_output_handler_options then table.insert(busted_command, "-Xoutput") - vim.list_extend(busted_command, _options.output_handler_options) + vim.list_extend(busted_command, _options.busted_output_handler_options) end else if not results_path then @@ -222,27 +244,90 @@ function BustedNeotestAdapter.create_busted_command(results_path, paths, filters }) end - vim.list_extend(command, busted_command) + vim.list_extend(arguments, busted_command) if vim.tbl_islist(config.busted_args) and #config.busted_args > 0 then - vim.list_extend(command, config.busted_args) + vim.list_extend(arguments, config.busted_args) end -- Add test filters for _, filter in ipairs(filters) do - vim.list_extend(command, { "--filter", escape_test_pattern_filter(filter) }) + local escaped_filter = escape_test_pattern_filter(filter) + + if _options.quote_strings then + escaped_filter = quote_string(escaped_filter) + end + + vim.list_extend(arguments, { "--filter", escaped_filter }) end -- Add test files - vim.list_extend(command, paths) + if _options.quote_strings then + vim.list_extend(arguments, vim.tbl_map(quote_string, paths)) + else + vim.list_extend(arguments, paths) + end return { - command = command, - path = lua_paths, - cpath = lua_cpaths, + nvim_command = vim.loop.exepath(), + arguments = arguments, + paths = lua_paths, + cpaths = lua_cpaths, } end +---@param strategy string +---@param results_path string +---@param paths string[] +---@param filters string[] +---@return table? +local function get_strategy_config(strategy, results_path, paths, filters) + if strategy == "dap" then + vim.list_extend(paths, { "--helper", get_debug_start_script() }, 1) + + local test_command_info = BustedNeotestAdapter.create_test_command( + results_path, + paths, + filters, + -- NOTE: When run via dap, passing arguments such as the one for + -- busted's '--filter' need to be escaped since the command is run + -- using node's child_process.spawn with { shell: true } that will + -- run via a shell and split arguments on spaces. This will break + -- the command if a filter contains spaces. + -- + -- On the other hand, we don't need to quote when running the integrated + -- strategy (through vim.fn.jobstart) because it runs with command as a + -- list which does not run through a shell + { quote_strings = true } + ) + + if not test_command_info then + log_and_notify("Failed to construct test command for debugging", vim.log.levels.ERROR) + return nil + end + + local lua_paths = util.normalize_and_create_lua_path(unpack(test_command_info.paths)) + local lua_cpaths = util.normalize_and_create_lua_path(unpack(test_command_info.cpaths)) + + return { + name = "Debug busted tests", + type = "local-lua", + cwd = "${workspaceFolder}", + request = "launch", + env = { + LUA_PATH = lua_paths, + LUA_CPATH = lua_cpaths, + }, + program = { + command = test_command_info.nvim_command, + }, + args = test_command_info.arguments, + } + end + + return nil +end + BustedNeotestAdapter.root = lib.files.match_root_pattern(".busted", ".luarocks", "lua_modules", "*.rockspec") @@ -380,27 +465,29 @@ function BustedNeotestAdapter.build_spec(args) end local results_path = async.fn.tempname() .. ".json" - local busted = BustedNeotestAdapter.create_busted_command(results_path, paths, filters) - - if not busted then - local message = "Could not find a busted executable" - logger.error(message) - vim.notify(message, vim.log.levels.ERROR) + local test_command = BustedNeotestAdapter.create_test_command(results_path, paths, filters) + if not test_command then + log_and_notify("Could not find a busted executable", vim.log.levels.ERROR) return end + ---@type string[] + local command = vim.list_extend({ test_command.nvim_command }, test_command.arguments) + + -- Extra arguments for busted if vim.tbl_islist(args.extra_args) then - vim.list_extend(busted.command, args.extra_args) + vim.list_extend(command, args.extra_args) end return { - command = busted.command, + command = command, context = { results_path = results_path, pos = pos, position_ids = position_ids, }, + strategy = get_strategy_config(args.strategy, results_path, paths, filters), } end @@ -423,11 +510,19 @@ local function create_error_info(test_result) return nil end ----@param test_result neotest-busted.BustedResult | neotest-busted.BustedFailureResult +---@param test_result neotest-busted.BustedResult | neotest-busted.BustedFailureResult | neotest-busted.BustedErrorResult ---@param status neotest.ResultStatus ---@param output string ---@param is_error boolean local function test_result_to_neotest_result(test_result, status, output, is_error) + if test_result.isError == true then + -- This is an internal error in busted, not a test that threw + return nil, { + message = test_result.message, + line = 0, + } + end + local pos_id = create_pos_id_key( test_result.element.trace.source:sub(2), -- Strip the "@" from the source path test_result.name, @@ -441,6 +536,7 @@ local function test_result_to_neotest_result(test_result, status, output, is_err } if is_error then + ---@cast test_result -neotest-busted.BustedErrorResult result.errors = create_error_info(test_result) end @@ -458,7 +554,10 @@ function BustedNeotestAdapter.results(spec, strategy_result, tree) local ok, data = pcall(lib.files.read, results_path) if not ok then - logger.error("Failed to read json test output file ", results_path, " with error: ", data) + log_and_notify( + ("Failed to read json test output file %s with error: %s"):format(results_path, data), + vim.log.levels.ERROR + ) return {} end @@ -466,11 +565,9 @@ function BustedNeotestAdapter.results(spec, strategy_result, tree) local json_ok, parsed = pcall(vim.json.decode, data, { luanil = { object = true } }) if not json_ok then - logger.error( - "Failed to parse json test output file ", - results_path, - " with error: ", - parsed + log_and_notify( + ("Failed to parse json test output file %s with error: %s"):format(results_path, parsed), + vim.log.levels.ERROR ) return {} end @@ -502,7 +599,10 @@ function BustedNeotestAdapter.results(spec, strategy_result, tree) local pos_id = position_ids[pos_id_key] if not pos_id then - logger.error("Failed to find matching position id for key ", pos_id_key) + log_and_notify( + ("Failed to find matching position id for key %s"):format(pos_id_key), + vim.log.levels.ERROR + ) else results[position_ids[pos_id_key]] = result end diff --git a/lua/neotest-busted/start_debug.lua b/lua/neotest-busted/start_debug.lua new file mode 100644 index 0000000..ff0fba8 --- /dev/null +++ b/lua/neotest-busted/start_debug.lua @@ -0,0 +1,3 @@ +if os.getenv("LOCAL_LUA_DEBUGGER_VSCODE") == "1" then + require("lldebugger").start() +end diff --git a/lua/neotest-busted/types.lua b/lua/neotest-busted/types.lua index f03e246..09338d3 100644 --- a/lua/neotest-busted/types.lua +++ b/lua/neotest-busted/types.lua @@ -11,9 +11,16 @@ ---@field lua_paths string[] ---@field lua_cpaths string[] ----@class neotest-busted.BustedCommandOptions ----@field output_handler string? ----@field output_handler_options string[]? +---@class neotest-busted.TestCommandOptions +---@field busted_output_handler string? +---@field busted_output_handler_options string[]? +---@field quote_strings boolean + +---@class neotest-busted.TestCommandConfig +---@field nvim_command string +---@field arguments string[] +---@field paths string[] +---@field cpaths string[] ---@class neotest-busted.BustedTrace ---@field what string @@ -47,6 +54,11 @@ ---@field trace neotest-busted.BustedTrace ---@field element neotest-busted.BustedElement +---@class neotest-busted.BustedErrorResult +---@field isError true +---@field message string +---@field name string + ---@class neotest-busted.BustedResultObject ---@field errors neotest-busted.BustedFailureResult[] ---@field pendings neotest-busted.BustedResult[] diff --git a/lua/neotest-busted/util.lua b/lua/neotest-busted/util.lua index b6208dd..64ceb0d 100644 --- a/lua/neotest-busted/util.lua +++ b/lua/neotest-busted/util.lua @@ -40,7 +40,7 @@ end ---@param ... string ---@return string -local function normalize_and_create_lua_path(...) +function util.normalize_and_create_lua_path(...) return table.concat(vim.tbl_map(vim.fs.normalize, { ... }), ";") end @@ -49,7 +49,7 @@ end ---@return string[] function util.create_package_path_argument(package_path, paths) if paths and #paths > 0 then - local _path = normalize_and_create_lua_path(unpack(paths)) + local _path = util.normalize_and_create_lua_path(unpack(paths)) return { "-c", ([[lua %s = '%s;' .. %s]]):format(package_path, _path, package_path) } end diff --git a/scripts/test-runner.lua b/scripts/test-runner.lua index d757c07..96367b8 100644 --- a/scripts/test-runner.lua +++ b/scripts/test-runner.lua @@ -92,10 +92,11 @@ local function require_checked(module_name) end ---@return string[] -local function parse_paths() - return _G.arg +local function parse_args() + return vim.list_slice(_G.arg, 1) end +---@return string[] local function collect_tests() local tests = {} local util = require("neotest-busted.util") @@ -113,6 +114,7 @@ local function run() return end + local paths = parse_args() local minimal_init = find_minimal_init() if not minimal_init then @@ -122,9 +124,9 @@ local function run() vim.cmd.source(minimal_init) - local adapter_or_error = require_checked("neotest-busted") + local ok, adapter_or_error = pcall(require, "neotest-busted") - if not adapter_or_error then + if not ok then print_level( "neotest-busted could not be loaded. Set up 'runtimepath', provide a minimal configuration via '-u', or create a 'minimal_init.lua' file: " .. adapter_or_error, @@ -133,11 +135,13 @@ local function run() return end - local paths = parse_paths() or collect_tests() + if #paths == 0 then + paths = collect_tests() + end - local busted = adapter_or_error.create_busted_command(nil, paths, {}, { - output_handler = "utfTerminal", - output_handler_options = { "--color" }, + local busted = adapter_or_error.create_test_command(nil, paths, {}, { + busted_output_handler = "utfTerminal", + busted_output_handler_options = { "--color" }, }) if not busted then @@ -145,8 +149,10 @@ local function run() return end + local command = vim.list_extend({ busted.nvim_command }, busted.arguments) + io.stdout:write( - vim.fn.system(table.concat(vim.tbl_map(vim.fn.shellescape, busted.command), " ")) + vim.fn.system(table.concat(vim.tbl_map(vim.fn.shellescape, command), " ")) ) end diff --git a/tests/adapter_build_spec_spec.lua b/tests/adapter_build_spec_spec.lua index 4554720..cdda53e 100644 --- a/tests/adapter_build_spec_spec.lua +++ b/tests/adapter_build_spec_spec.lua @@ -30,6 +30,11 @@ describe("adapter.build_spec", function() vim.endswith(spec_command[idx], "lua/neotest-busted/output_handler.lua") ) idx = idx + 1 + elseif item == '"--helper"' then + assert.is_true( + vim.endswith(spec_command[idx], 'lua/neotest-busted/start_debug.lua"') + ) + idx = idx + 1 end end end @@ -286,6 +291,7 @@ describe("adapter.build_spec", function() }, }) end) + async.it("escapes special characters in pattern in command", function() package.loaded["neotest-busted"] = nil @@ -335,6 +341,108 @@ describe("adapter.build_spec", function() }) end) + async.it("builds command for debugging file test", function() + package.loaded["neotest-busted"] = nil + + local busted_paths = { "~/.luarocks/share/lua/5.1/?.lua" } + local busted_cpaths = { "~/.luarocks/lib/lua/5.1/?.so" } + + local adapter = require("neotest-busted")({ + busted_command = "./busted", + busted_args = { "--shuffle-lists" }, + busted_paths = busted_paths, + busted_cpaths = busted_cpaths, + minimal_init = nil, + }) + local tree = create_tree(adapter) + local spec = adapter.build_spec({ tree = tree, strategy = "dap" }) + + assert.is_not_nil(spec) + + local lua_paths = table.concat({ + vim.fs.normalize(busted_paths[1]), + "lua/?.lua", + "lua/?/init.lua", + }, ";") + + local arguments = { + "--headless", + "-i", + "NONE", + "-n", + "-u", + "tests/minimal_init.lua", + "-c", + ("lua package.path = '%s;' .. package.path"):format(lua_paths), + "-c", + ("lua package.cpath = '%s;' .. package.cpath"):format( + vim.fs.normalize(busted_cpaths[1]) + ), + "-l", + "./busted", + "--verbose", + "--output", + "./lua/neotest-busted/output_handler.lua", + "-Xoutput", + "test-output.json", + "--shuffle-lists", + "./test_files/test1_spec.lua", + } + + assert_spec_command(spec.command, vim.list_extend({ vim.loop.exepath() }, arguments)) + + assert.are.same(spec.context, { + results_path = "test-output.json", + pos = { + id = "./test_files/test1_spec.lua", + name = "test1_spec.lua", + path = "./test_files/test1_spec.lua", + range = { 0, 0, 21, 0 }, + type = "file", + }, + position_ids = { + ["./test_files/test1_spec.lua::top-level namespace 1 nested namespace 1 test 1::3"] = './test_files/test1_spec.lua::"top-level namespace 1"::"nested namespace 1"::"test 1"', + ["./test_files/test1_spec.lua::top-level namespace 1 nested namespace 1 test 2::7"] = './test_files/test1_spec.lua::"top-level namespace 1"::"nested namespace 1"::"test 2"', + ["./test_files/test1_spec.lua::^top-le[ve]l (na*m+e-sp?ac%e) 2$ test 3::14"] = './test_files/test1_spec.lua::"^top-le[ve]l (na*m+e-sp?ac%e) 2$"::"test 3"', + ["./test_files/test1_spec.lua::^top-le[ve]l (na*m+e-sp?ac%e) 2$ test 4::18"] = './test_files/test1_spec.lua::"^top-le[ve]l (na*m+e-sp?ac%e) 2$"::"test 4"', + }, + }) + + local debug_arguments = vim.list_slice(arguments, 1, #arguments - 1) + vim.list_extend(debug_arguments, { + '"./test_files/test1_spec.lua"', + '"--helper"', + '"./lua/neotest-busted/start_debug.lua"', + }) + + local strategy_keys = vim.tbl_keys(spec.strategy) + table.sort(strategy_keys) + + assert.are.same(strategy_keys, { + "args", + "cwd", + "env", + "name", + "program", + "request", + "type", + }) + + assert.are.same(spec.strategy.name, "Debug busted tests") + assert.are.same(spec.strategy.type, "local-lua") + assert.are.same(spec.strategy.cwd, "${workspaceFolder}") + assert.are.same(spec.strategy.request, "launch") + assert.are.same(spec.strategy.env, { + LUA_PATH = lua_paths, + LUA_CPATH = vim.fs.normalize(busted_cpaths[1]), + }) + assert.are.same(spec.strategy.program, { + command = vim.loop.exepath(), + }) + + assert_spec_command(spec.strategy.args, debug_arguments) + end) + -- async.it("handles failure to find a busted command", function() -- adapter({ -- busted_command = false, diff --git a/tests/adapter_results_spec.lua b/tests/adapter_results_spec.lua index ee5c564..a2882c9 100644 --- a/tests/adapter_results_spec.lua +++ b/tests/adapter_results_spec.lua @@ -156,12 +156,11 @@ describe("adapter.results", function() assert.are.same(neotest_results, {}) assert.stub(lib.files.read).was.called_with(spec.context.results_path) - assert.stub(logger.error).was.called_with( - "Failed to read json test output file ", - "test_output.json", - " with error: ", - "Could not read file" - ) + assert + .stub(logger.error).was + .called_with( + "Failed to read json test output file test_output.json with error: Could not read file" + ) end) it("handles failure to decode json", function() @@ -175,10 +174,7 @@ describe("adapter.results", function() assert.stub(lib.files.read).was.called_with(spec.context.results_path) assert.stub(logger.error).was.called_with( - "Failed to parse json test output file ", - "test_output.json", - " with error: ", - "Expected value but found invalid token at character 1" + "Failed to parse json test output file test_output.json with error: Expected value but found invalid token at character 1" ) vim.json.decode:revert() @@ -215,8 +211,9 @@ describe("adapter.results", function() assert.stub(lib.files.read).was.called_with(spec.context.results_path) assert.stub(logger.error).was.called_with( - "Failed to find matching position id for key ", - test_path .. "::namespace tests a failing test::7" + "Failed to find matching position id for key " + .. test_path + .. "::namespace tests a failing test::7" ) end) end) diff --git a/tests/util_spec.lua b/tests/util_spec.lua index b5f8447..f9f52ad 100644 --- a/tests/util_spec.lua +++ b/tests/util_spec.lua @@ -28,6 +28,7 @@ describe("util", function() "lua/neotest-busted/health.lua", "lua/neotest-busted/init.lua", "lua/neotest-busted/output_handler.lua", + "lua/neotest-busted/start_debug.lua", "lua/neotest-busted/types.lua", "lua/neotest-busted/util.lua", })