├── .github └── workflows │ └── workflow.yaml ├── .gitignore ├── .gitmodules ├── LICENSE ├── README.md ├── lua ├── neotest-elixir │ ├── base.lua │ ├── core.lua │ └── init.lua └── neotest │ └── client │ └── strategies │ └── iex │ └── init.lua ├── neotest_elixir ├── formatter.ex ├── json_encoder.ex └── test_interactive_runner.ex ├── stylua.toml └── tests ├── sample_proj ├── .formatter.exs ├── .gitignore ├── .nvim.lua ├── README.md ├── lib │ └── sample_proj.ex ├── mix.exs ├── mix.lock └── test │ ├── sample_proj │ └── parse_test.exs │ ├── sample_proj_test.exs │ └── test_helper.exs └── unit └── core_spec.lua /.github/workflows/workflow.yaml: -------------------------------------------------------------------------------- 1 | name: neotest-elixir Workflow 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: ~ 7 | jobs: 8 | lua-style: 9 | name: Lua style 10 | runs-on: ubuntu-20.04 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: JohnnyMorganz/stylua-action@v4 14 | with: 15 | token: ${{ secrets.GITHUB_TOKEN }} 16 | version: latest 17 | args: --check lua/ 18 | 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Lua sources 2 | luac.out 3 | 4 | # luarocks build files 5 | *.src.rock 6 | *.zip 7 | *.tar.gz 8 | 9 | # Object files 10 | *.o 11 | *.os 12 | *.ko 13 | *.obj 14 | *.elf 15 | 16 | # Precompiled Headers 17 | *.gch 18 | *.pch 19 | 20 | # Libraries 21 | *.lib 22 | *.a 23 | *.la 24 | *.lo 25 | *.def 26 | *.exp 27 | 28 | # Shared objects (inc. Windows DLLs) 29 | *.dll 30 | *.so 31 | *.so.* 32 | *.dylib 33 | 34 | # Executables 35 | *.exe 36 | *.out 37 | *.app 38 | *.i*86 39 | *.x86_64 40 | *.hex 41 | 42 | # LS 43 | .lexical 44 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "neotest_elixir/iex-unit"] 2 | path = neotest_elixir/iex-unit 3 | url = https://github.com/scottming/iex-unit 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Jhon Pedroza 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # neotest-elixir 2 | 3 | Neotest adapter for Elixir 4 | 5 | ## Installation 6 | 7 | Using packer: 8 | 9 | ```lua 10 | use({ 11 | "nvim-neotest/neotest", 12 | requires = { 13 | ..., 14 | "jfpedroza/neotest-elixir", 15 | } 16 | config = function() 17 | require("neotest").setup({ 18 | ..., 19 | adapters = { 20 | require("neotest-elixir"), 21 | } 22 | }) 23 | end 24 | }) 25 | ``` 26 | 27 | ## Configuration 28 | 29 | You can optionally specify some settings: 30 | 31 | ```lua 32 | require("neotest").setup({ 33 | adapters = { 34 | require("neotest-elixir")({ 35 | -- The Mix task to use to run the tests 36 | -- Can be a function to return a dynamic value. 37 | -- Default: "test" 38 | mix_task = {"my_custom_task"}, 39 | -- Other formatters to pass to the test command as the formatters are overridden 40 | -- Can be a function to return a dynamic value. 41 | -- Default: {"ExUnit.CLIFormatter"} 42 | extra_formatters = {"ExUnit.CLIFormatter", "ExUnitNotifier"}, 43 | -- Extra test block identifiers 44 | -- Can be a function to return a dynamic value. 45 | -- Block identifiers "test", "feature" and "property" are always supported by default. 46 | -- Default: {} 47 | extra_block_identifiers = {"test_with_mock"}, 48 | -- Extra arguments to pass to mix test 49 | -- Can be a function that receives the position, to return a dynamic value 50 | -- Default: {} 51 | args = {"--trace"}, 52 | -- Command wrapper 53 | -- Must be a function that receives the mix command as a table, to return a dynamic value 54 | -- Default: function(cmd) return cmd end 55 | post_process_command = function(cmd) 56 | return vim.iter({{"env", "FOO=bar"}, cmd}):flatten():totable() 57 | end, 58 | -- Delays writes so that results are updated at most every given milliseconds 59 | -- Decreasing this number improves snappiness at the cost of performance 60 | -- Can be a function to return a dynamic value. 61 | -- Default: 1000 62 | write_delay = 1000, 63 | -- The pattern to match test files 64 | -- Default: "_test.exs$" 65 | test_file_pattern = ".test.exs$", 66 | -- Function to determine whether a directory should be ignored 67 | -- By default includes root test directory and umbrella apps' test directories 68 | -- Params: 69 | -- - name (string) - Name of directory 70 | -- - rel_path (string) - Path to directory, relative to root 71 | -- - root (string) - Root directory of project 72 | filter_dir = function(name, rel_path, root) 73 | return rel_path == "test" 74 | or rel_path == "lib" 75 | or vim.startswith(rel_path, 'test/') 76 | or vim.startswith(rel_path, 'lib/') 77 | end, 78 | }), 79 | } 80 | }) 81 | ``` 82 | 83 | `extra_args` are also supported, so you can use them to specify other arguments to `mix test`: 84 | 85 | ```lua 86 | require("neotest").run.run({vim.fn.expand("%"), extra_args = {"--formatter", "ExUnitNotifier", "--timeout", "60"}})) 87 | ``` 88 | 89 | ## Integration with Elixir watchers 90 | 91 | The adapter supports `mix_test_interactive` to watch and run tests. Simply set `mix_task` to `test.interactive`. 92 | 93 | Caveats: When you save a file, there won't be any indicator that the tests are running again. 94 | 95 | ## TODO 96 | 97 | - [ ] Add integration with `mix-test.watch` 98 | 99 | ## Development 100 | 101 | In `tests/sample_proj` there is an Elixir project with to test that the adapter works 102 | -------------------------------------------------------------------------------- /lua/neotest-elixir/base.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | function M.is_test_file(file_path, pattern) 4 | return file_path:match(pattern) 5 | end 6 | 7 | return M 8 | -------------------------------------------------------------------------------- /lua/neotest-elixir/core.lua: -------------------------------------------------------------------------------- 1 | local lib = require("neotest.lib") 2 | local Path = require("plenary.path") 3 | 4 | local M = {} 5 | 6 | function M.mix_root(file_path) 7 | local root = lib.files.match_root_pattern("mix.exs")(file_path) 8 | 9 | -- If the path found is inside an umbrella, return the root of the umbrella 10 | if root ~= nil and root:match("/apps/[%w_]+$") then 11 | local new_root = lib.files.match_root_pattern("mix.exs")(root:gsub("/apps/[%w_]+$", "")) 12 | 13 | return new_root or root 14 | end 15 | 16 | return root 17 | end 18 | 19 | local function relative_to_cwd(path) 20 | local root = M.mix_root(path) 21 | return Path:new(path):make_relative(root) 22 | end 23 | 24 | -- Build the command to send to the IEx shell for running the test 25 | function M.build_iex_test_command(position, output_dir, seed, relative_to) 26 | if not relative_to then 27 | relative_to = relative_to_cwd 28 | end 29 | local relative_path = relative_to(position.path) 30 | 31 | local function get_line_number() 32 | if position.type == "test" then 33 | return position.range[1] + 1 34 | end 35 | end 36 | 37 | local line_number = get_line_number() 38 | if line_number then 39 | return string.format( 40 | "IExUnit.run(%q, line: %s, seed: %s, output_dir: %q)", 41 | relative_path, 42 | line_number, 43 | seed, 44 | output_dir 45 | ) 46 | else 47 | return string.format("IExUnit.run(%q, seed: %s, output_dir: %q)", position.path, seed, output_dir) 48 | end 49 | end 50 | 51 | function M.iex_watch_command(results_path, maybe_compile_error_path, seed) 52 | -- the `&& cat maybe_compile_error_path` just for the case where encountering a compile error 53 | return string.format( 54 | "(tail -n 50 -f %s %s &) | grep -q %s && cat %s", 55 | results_path, 56 | maybe_compile_error_path, 57 | seed, 58 | maybe_compile_error_path 59 | ) 60 | end 61 | 62 | local function build_formatters(extra_formatters) 63 | -- tables need to be copied by value 64 | local default_formatters = { "NeotestElixir.Formatter" } 65 | local formatters = { unpack(default_formatters) } 66 | vim.list_extend(formatters, extra_formatters) 67 | 68 | local result = {} 69 | for _, formatter in ipairs(formatters) do 70 | table.insert(result, "--formatter") 71 | table.insert(result, formatter) 72 | end 73 | 74 | return result 75 | end 76 | 77 | ---@param position neotest.Position 78 | ---@return string[] 79 | local function test_target(position, relative_to) 80 | -- Dependency injection for testing 81 | if not relative_to then 82 | relative_to = relative_to_cwd 83 | end 84 | 85 | local relative_path = relative_to(position.path) 86 | 87 | if position.type == "test" then 88 | local line = position.range[1] + 1 89 | return { relative_path .. ":" .. line } 90 | elseif relative_path == "." then 91 | return {} 92 | else 93 | return { relative_path } 94 | end 95 | end 96 | 97 | local function script_path() 98 | local str = debug.getinfo(2, "S").source:sub(2) 99 | return str:match("(.*/)") 100 | end 101 | 102 | M.plugin_path = Path.new(script_path()):parent():parent() 103 | 104 | -- TODO: dirty version -- make it public only for testing 105 | M.json_encoder_path = (M.plugin_path / "neotest_elixir/json_encoder.ex").filename 106 | M.exunit_formatter_path = (M.plugin_path / "neotest_elixir/formatter.ex").filename 107 | local mix_interactive_runner_path = (M.plugin_path / "neotest_elixir/test_interactive_runner.ex").filename 108 | 109 | local function options_for_task(mix_task) 110 | if mix_task == "test.interactive" then 111 | return { 112 | "-r", 113 | mix_interactive_runner_path, 114 | "-e", 115 | "Application.put_env(:mix_test_interactive, :runner, NeotestElixir.TestInteractiveRunner)", 116 | } 117 | else 118 | return { 119 | "-r", 120 | M.json_encoder_path, 121 | "-r", 122 | M.exunit_formatter_path, 123 | } 124 | end 125 | end 126 | 127 | function M.build_mix_command( 128 | position, 129 | mix_task_func, 130 | extra_formatters_func, 131 | mix_task_args_func, 132 | neotest_args, 133 | relative_to_func 134 | ) 135 | return vim 136 | .iter({ 137 | { 138 | "elixir", 139 | }, 140 | -- deferent tasks have different options 141 | -- for example, `test.interactive` needs to load a custom runner 142 | options_for_task(mix_task_func()), 143 | { 144 | "-S", 145 | "mix", 146 | mix_task_func(), -- `test` is default 147 | }, 148 | -- default is ExUnit.CLIFormatter 149 | build_formatters(extra_formatters_func()), 150 | -- default is {} 151 | -- maybe `test.interactive` has different args with `test` 152 | mix_task_args_func(), 153 | neotest_args.extra_args or {}, 154 | -- test file or directory or testfile:line 155 | test_target(position, relative_to_func), 156 | }) 157 | :flatten() 158 | :totable() 159 | end 160 | 161 | -- public only for testing 162 | function M.iex_start_command(opened_filename) 163 | local filepath = opened_filename or vim.fn.expand("%:p") 164 | local function is_in_umbrella_project() 165 | return string.find(filepath, "/apps/") ~= nil 166 | end 167 | 168 | local function child_app_root_dir() 169 | local umbrella_root = string.match(filepath, "(.*/apps/)"):sub(1, -7) 170 | local child_root = string.match(filepath, "(.*/apps/[%w_]+)") 171 | return Path:new(child_root):make_relative(umbrella_root) 172 | end 173 | 174 | -- generate a starting command for the iex terminal 175 | local runner_path = (M.plugin_path / "neotest_elixir/iex-unit/lib/iex_unit.ex").filename 176 | local start_code = "IExUnit.start()" 177 | local configuration_code = "ExUnit.configure(formatters: [NeotestElixir.Formatter, ExUnit.CLIFormatter])" 178 | local start_command = string.format( 179 | "MIX_ENV=test iex -S mix run -r %q -r %q -r %q -e %q -e %q", 180 | M.json_encoder_path, 181 | M.exunit_formatter_path, 182 | runner_path, 183 | start_code, 184 | configuration_code 185 | ) 186 | 187 | if not is_in_umbrella_project() then 188 | return start_command 189 | end 190 | 191 | local function ends_with(cwd, relatived) 192 | local relatived_len = string.len(relatived) 193 | return string.sub(cwd, -relatived_len) == relatived 194 | end 195 | 196 | local child_root_relatived = child_app_root_dir() 197 | if not ends_with(vim.fn.getcwd(), child_root_relatived) then 198 | return string.format("cd %s && %s", child_root_relatived, start_command) 199 | else 200 | return start_command 201 | end 202 | end 203 | 204 | function M.get_or_create_iex_term(id, direction_func) 205 | local ok, toggleterm = pcall(require, "toggleterm") 206 | if not ok then 207 | vim.notify("Please install `toggleterm.nvim` first", vim.log.levels.ERROR) 208 | end 209 | 210 | local toggleterm_terminal = require("toggleterm.terminal") 211 | local term = toggleterm_terminal.get(id) 212 | local direction = direction_func and direction_func() 213 | 214 | if term == nil then 215 | toggleterm.exec(M.iex_start_command(), id, nil, nil, direction) 216 | term = toggleterm_terminal.get_or_create_term(id) 217 | return term 218 | else 219 | return term 220 | end 221 | end 222 | 223 | function M.generate_seed() 224 | local seed_str, _ = string.gsub(vim.fn.reltimestr(vim.fn.reltime()), "(%d+).(%d+)", "%1%2") 225 | return tonumber(seed_str) 226 | end 227 | 228 | function M.create_and_clear(path) 229 | local x = io.open(path, "w") 230 | if x then 231 | x:write("") 232 | x:close() 233 | end 234 | end 235 | 236 | return M 237 | -------------------------------------------------------------------------------- /lua/neotest-elixir/init.lua: -------------------------------------------------------------------------------- 1 | local ok, async = pcall(require, "nio") 2 | if not ok then 3 | print("use plenary") 4 | async = require("neotest.async") 5 | end 6 | 7 | local Path = require("plenary.path") 8 | local lib = require("neotest.lib") 9 | local base = require("neotest-elixir.base") 10 | local core = require("neotest-elixir.core") 11 | local logger = require("neotest.logging") 12 | 13 | ---@type neotest.Adapter 14 | local ElixirNeotestAdapter = { name = "neotest-elixir" } 15 | 16 | local function get_extra_formatters() 17 | return { "ExUnit.CLIFormatter" } 18 | end 19 | 20 | local function get_mix_task_args() 21 | return {} 22 | end 23 | 24 | local function get_extra_block_identifiers() 25 | return {} 26 | end 27 | local function get_write_delay() 28 | return 1000 29 | end 30 | 31 | local function get_mix_task() 32 | return "test" 33 | end 34 | 35 | local function get_iex_shell_direction() 36 | return "horizontal" 37 | end 38 | 39 | local function get_test_file_pattern() 40 | return "_test.exs$" 41 | end 42 | 43 | local function filter_dir(_, rel_path, _) 44 | return rel_path == "test" 45 | or vim.startswith(rel_path, "test/") 46 | or rel_path == "apps" 47 | or rel_path:match("^apps/[^/]+$") 48 | or rel_path:match("^apps/[^/]+/test") 49 | end 50 | 51 | local function post_process_command(cmd) 52 | return cmd 53 | end 54 | 55 | local function get_relative_path(file_path) 56 | local mix_root_path = core.mix_root(file_path) 57 | local root_elems = vim.split(mix_root_path, Path.path.sep) 58 | local elems = vim.split(file_path, Path.path.sep) 59 | return table.concat({ unpack(elems, (#root_elems + 1), #elems) }, Path.path.sep) 60 | end 61 | 62 | function ElixirNeotestAdapter._generate_id(position, parents) 63 | if position.dynamic then 64 | local relative_path = get_relative_path(position.path) 65 | local line_num = (position.range[1] + 1) 66 | return (relative_path .. ":" .. line_num) 67 | else 68 | return table.concat( 69 | vim 70 | .iter({ 71 | position.path, 72 | vim.tbl_map(function(pos) 73 | return pos.name 74 | end, parents), 75 | position.name, 76 | }) 77 | :flatten() 78 | :totable(), 79 | "::" 80 | ) 81 | end 82 | end 83 | 84 | ElixirNeotestAdapter.root = core.mix_root 85 | 86 | function ElixirNeotestAdapter.filter_dir(name, rel_path, root) 87 | return filter_dir(name, rel_path, root) 88 | end 89 | 90 | function ElixirNeotestAdapter.is_test_file(file_path) 91 | return base.is_test_file(file_path, get_test_file_pattern()) 92 | end 93 | 94 | local function get_match_type(captured_nodes) 95 | if captured_nodes["test.name"] then 96 | return "test" 97 | end 98 | 99 | if captured_nodes["dytest.name"] then 100 | return "dytest" 101 | end 102 | 103 | if captured_nodes["namespace.name"] then 104 | return "namespace" 105 | end 106 | end 107 | 108 | local match_type_map = { 109 | test = "test", 110 | dytest = "test", 111 | namespace = "namespace", 112 | } 113 | 114 | local function remove_heredoc_prefix(name) 115 | local lines = vim.split(name, "\n") 116 | local common_spaces = 1000 117 | for _, line in ipairs(lines) do 118 | local spaces = 0 119 | for i = 1, line:len() do 120 | if line:sub(i, i) == " " then 121 | spaces = spaces + 1 122 | else 123 | break 124 | end 125 | end 126 | 127 | if spaces < common_spaces then 128 | common_spaces = spaces 129 | end 130 | end 131 | 132 | for i, line in ipairs(lines) do 133 | lines[i] = line:sub(common_spaces + 1) 134 | end 135 | 136 | return table.concat(lines, "\n") 137 | end 138 | 139 | local function clean_name(name) 140 | -- Remove quotes 141 | if vim.startswith(name, '"""') then 142 | name = name:gsub('^"""', ""):gsub('"""$', "") 143 | elseif vim.startswith(name, '"') then 144 | name = name:gsub('^"', ""):gsub('"$', "") 145 | end 146 | 147 | -- Replace escaped quotes with literal quotes 148 | name = name:gsub('\\"', '"') 149 | 150 | if vim.startswith(name, "\n ") then 151 | name = remove_heredoc_prefix(name:sub(2)) 152 | end 153 | 154 | -- Replace newlines with spaces 155 | return name:gsub("\n", " "):gsub("\\n", " ") 156 | end 157 | 158 | function ElixirNeotestAdapter._build_position(file_path, source, captured_nodes) 159 | local match_type = get_match_type(captured_nodes) 160 | if match_type then 161 | ---@type string 162 | local name = vim.treesitter.get_node_text(captured_nodes[match_type .. ".name"], source) 163 | local definition = captured_nodes[match_type .. ".definition"] 164 | local dynamic = false 165 | 166 | if match_type == "dytest" then 167 | if vim.startswith(name, "~") then 168 | name = name:sub(4, #name - 1) 169 | end 170 | dynamic = true 171 | end 172 | 173 | if vim.startswith(name, "doctest ") then 174 | dynamic = true 175 | end 176 | 177 | name = clean_name(name) 178 | 179 | return { 180 | type = match_type_map[match_type], 181 | path = file_path, 182 | name = name, 183 | range = { definition:range() }, 184 | dynamic = dynamic, 185 | } 186 | end 187 | end 188 | 189 | ---@async 190 | ---@return neotest.Tree | nil 191 | function ElixirNeotestAdapter.discover_positions(path) 192 | local test_block_id_list = 193 | vim.iter({ { "test", "feature", "property" }, get_extra_block_identifiers() }):flatten():totable() 194 | for index, value in ipairs(test_block_id_list) do 195 | test_block_id_list[index] = '"' .. value .. '"' 196 | end 197 | local test_block_ids = table.concat(test_block_id_list, " ") 198 | local query = [[ 199 | ;; query 200 | ;; Describe blocks 201 | (call 202 | target: (identifier) @_target (#eq? @_target "describe") 203 | (arguments . (string (quoted_content) @namespace.name)) 204 | (do_block) 205 | ) @namespace.definition 206 | 207 | ;; Test blocks (dynamic) 208 | (call 209 | target: (identifier) @_target (#any-of? @_target ]] .. test_block_ids .. [[) 210 | (arguments . [ 211 | (string (interpolation)) ;; String with interpolations 212 | (identifier) ;; Single variable as name 213 | (call target: (identifier) @_target2 (#eq? @_target2 "inspect")) ;; Inspect call as name 214 | (sigil . (sigil_name) @_sigil_name (interpolation)) (#any-of? @_sigil_name "s") ;; Sigil ~s, with interpolations 215 | ] @dytest.name) 216 | (do_block)? 217 | ) @dytest.definition 218 | 219 | ;; Test blocks (static) 220 | (call 221 | target: (identifier) @_target (#any-of? @_target ]] .. test_block_ids .. [[) 222 | (arguments . [ 223 | (string . (quoted_content) @test.name .) ;; Simple string 224 | (string . (quoted_content) [(escape_sequence) (quoted_content)]+ .) @test.name ;; String with escape sequences 225 | (sigil . (sigil_name) @_sigil_name . (quoted_content) @test.name .) (#any-of? @_sigil_name "s" "S") ;; Sigil ~s and ~S, no interpolations 226 | ] 227 | ) 228 | (do_block)? 229 | ) @test.definition 230 | 231 | ;; Doctests 232 | ;; The word doctest is included in the name to make it easier to notice 233 | (call 234 | target: (identifier) @_target (#eq? @_target "doctest") 235 | ) @test.name @test.definition 236 | ]] 237 | 238 | local position_id = 'require("neotest-elixir")._generate_id' 239 | local build_position = 'require("neotest-elixir")._build_position' 240 | return lib.treesitter.parse_positions(path, query, { position_id = position_id, build_position = build_position }) 241 | end 242 | 243 | ---@async 244 | ---@param args neotest.RunArgs 245 | ---@return neotest.RunSpec 246 | function ElixirNeotestAdapter.build_spec(args) 247 | local position = args.tree:data() 248 | 249 | -- create the results directory and empty file 250 | local output_dir = async.fn.tempname() 251 | Path:new(output_dir):mkdir() 252 | local results_path = output_dir .. "/results" 253 | local maybe_compile_error_path = output_dir .. "/compile_error" 254 | logger.debug("result path: " .. results_path) 255 | core.create_and_clear(results_path) 256 | core.create_and_clear(maybe_compile_error_path) 257 | 258 | local post_processing_command 259 | if args.strategy == "iex" then 260 | local MAGIC_IEX_TERM_ID = 42 261 | local term = core.get_or_create_iex_term(MAGIC_IEX_TERM_ID, get_iex_shell_direction) 262 | local seed = core.generate_seed() 263 | local test_command = core.build_iex_test_command(position, output_dir, seed) 264 | term:send(test_command, true) 265 | post_processing_command = core.iex_watch_command(results_path, maybe_compile_error_path, seed) 266 | else 267 | local command = core.build_mix_command(position, get_mix_task, get_extra_formatters, get_mix_task_args, args) 268 | post_processing_command = post_process_command(command) 269 | end 270 | 271 | local stream_data, stop_stream = lib.files.stream_lines(results_path) 272 | local write_delay = tostring(get_write_delay()) 273 | 274 | return { 275 | command = post_processing_command, 276 | context = { 277 | position = position, 278 | results_path = results_path, 279 | stop_stream = stop_stream, 280 | }, 281 | stream = function() 282 | return function() 283 | local lines = stream_data() 284 | local results = {} 285 | for _, line in ipairs(lines) do 286 | local decoded_result = vim.json.decode(line, { luanil = { object = true } }) 287 | local earlier_result = results[decoded_result.id] 288 | if earlier_result == nil or earlier_result.status ~= "failed" then 289 | results[decoded_result.id] = { 290 | status = decoded_result.status, 291 | output = decoded_result.output, 292 | errors = decoded_result.errors, 293 | } 294 | end 295 | end 296 | return results 297 | end 298 | end, 299 | env = { 300 | NEOTEST_OUTPUT_DIR = output_dir, 301 | NEOTEST_WRITE_DELAY = write_delay, 302 | NEOTEST_PLUGIN_PATH = tostring(core.plugin_path), 303 | }, 304 | } 305 | end 306 | 307 | ---@async 308 | ---@param spec neotest.RunSpec 309 | ---@param result neotest.StrategyResult 310 | ---@return neotest.Result[] 311 | function ElixirNeotestAdapter.results(spec, result) 312 | spec.context.stop_stream() 313 | local results = {} 314 | if result.code == 0 or result.code == 2 then 315 | local data = lib.files.read_lines(spec.context.results_path) 316 | 317 | for _, line in ipairs(data) do 318 | local decoded_result = vim.json.decode(line, { luanil = { object = true } }) 319 | local earlier_result = results[decoded_result.id] 320 | if earlier_result == nil or earlier_result.status ~= "failed" then 321 | results[decoded_result.id] = { 322 | status = decoded_result.status, 323 | output = decoded_result.output, 324 | errors = decoded_result.errors, 325 | } 326 | end 327 | end 328 | else 329 | results[spec.context.position.id] = { 330 | status = "failed", 331 | output = result.output, 332 | } 333 | end 334 | 335 | return results 336 | end 337 | 338 | local is_callable = function(obj) 339 | return type(obj) == "function" or (type(obj) == "table" and obj.__call) 340 | end 341 | 342 | local function callable_opt(opt) 343 | if is_callable(opt) then 344 | return opt 345 | elseif opt then 346 | return function() 347 | return opt 348 | end 349 | end 350 | end 351 | 352 | setmetatable(ElixirNeotestAdapter, { 353 | __call = function(_, opts) 354 | if is_callable(opts.post_process_command) then 355 | post_process_command = opts.post_process_command 356 | end 357 | 358 | local mix_task = callable_opt(opts.mix_task) 359 | if mix_task then 360 | get_mix_task = mix_task 361 | end 362 | 363 | local iex_shell_direction = callable_opt(opts.iex_shell_direction) 364 | if iex_shell_direction then 365 | get_iex_shell_direction = iex_shell_direction 366 | end 367 | 368 | local extra_formatters = callable_opt(opts.extra_formatters) 369 | if extra_formatters then 370 | get_extra_formatters = extra_formatters 371 | end 372 | 373 | local extra_block_identifiers = callable_opt(opts.extra_block_identifiers) 374 | if extra_block_identifiers then 375 | get_extra_block_identifiers = extra_block_identifiers 376 | end 377 | 378 | local args = callable_opt(opts.args) 379 | if args then 380 | get_mix_task_args = args 381 | end 382 | 383 | local write_delay = callable_opt(opts.write_delay) 384 | if write_delay then 385 | get_write_delay = write_delay 386 | end 387 | 388 | local test_file_pattern = callable_opt(opts.test_file_pattern) 389 | if test_file_pattern then 390 | get_test_file_pattern = test_file_pattern 391 | end 392 | 393 | if is_callable(opts.filter_dir) then 394 | filter_dir = opts.filter_dir 395 | end 396 | 397 | return ElixirNeotestAdapter 398 | end, 399 | }) 400 | 401 | return ElixirNeotestAdapter 402 | -------------------------------------------------------------------------------- /lua/neotest/client/strategies/iex/init.lua: -------------------------------------------------------------------------------- 1 | local iex = require("neotest.client.strategies.integrated") 2 | return iex 3 | -------------------------------------------------------------------------------- /neotest_elixir/formatter.ex: -------------------------------------------------------------------------------- 1 | defmodule NeotestElixir.Formatter do 2 | @moduledoc """ 3 | A custom ExUnit formatter to provide output that is easier to parse. 4 | """ 5 | use GenServer 6 | 7 | require Logger 8 | 9 | alias NeotestElixir.JsonEncoder 10 | 11 | @impl true 12 | def init(opts) do 13 | output_dir = opts[:output_dir] || System.fetch_env!("NEOTEST_OUTPUT_DIR") 14 | File.mkdir_p!(output_dir) 15 | results_path = Path.join(output_dir, "results") 16 | write_delay = String.to_integer(System.get_env("NEOTEST_WRITE_DELAY") || "100") 17 | 18 | results_io_device = 19 | File.open!(results_path, [:append, {:delayed_write, 64 * 1000, write_delay}, :utf8]) 20 | 21 | config = %{ 22 | seed: opts[:seed], 23 | output_dir: output_dir, 24 | results_path: results_path, 25 | results_io_device: results_io_device, 26 | colors: colors(opts), 27 | test_counter: 0, 28 | failure_counter: 0, 29 | tests: %{} 30 | } 31 | 32 | {:ok, config} 33 | end 34 | 35 | @impl true 36 | def handle_cast({:module_started, %ExUnit.TestModule{} = test_module}, config) do 37 | config = add_test_module(config, test_module) 38 | {:noreply, config} 39 | end 40 | 41 | @impl true 42 | def handle_cast({:test_finished, %ExUnit.Test{} = test}, config) do 43 | try do 44 | config = 45 | config 46 | |> update_test_counter() 47 | |> update_failure_counter(test) 48 | 49 | id = get_test_config(test, config).id 50 | 51 | output = %{ 52 | seed: config[:seed], 53 | id: id, 54 | status: make_status(test), 55 | output: save_test_output(test, config), 56 | errors: make_errors(test) 57 | } 58 | 59 | IO.puts(config.results_io_device, JsonEncoder.encode!(output)) 60 | 61 | {:noreply, config} 62 | catch 63 | kind, reason -> 64 | Logger.error(Exception.format(kind, reason, __STACKTRACE__)) 65 | {:noreply, config} 66 | end 67 | end 68 | 69 | def handle_cast({:suite_finished, _}, config) do 70 | File.close(config.results_io_device) 71 | {:noreply, config} 72 | end 73 | 74 | def handle_cast(_msg, config) do 75 | {:noreply, config} 76 | end 77 | 78 | defp add_test_module(config, %ExUnit.TestModule{} = test_module) do 79 | tests = 80 | test_module.tests 81 | |> Enum.group_by(& &1.tags.line) 82 | |> Stream.flat_map(fn 83 | {_, tests} -> 84 | single_test? = 85 | case tests do 86 | [_] -> true 87 | [_ | _] -> false 88 | end 89 | 90 | Stream.map(tests, fn test -> 91 | test_name = Atom.to_string(test.name) 92 | # Doctests are handled as dynamic even if it's a single test 93 | dynamic? = 94 | test.tags.test_type == :doctest or not single_test? or 95 | String.match?(test_name, ~r/&.*\..*\/\d$/) 96 | 97 | id = make_id(dynamic?, test) 98 | output_file = Path.join(config.output_dir, "test_output_#{:erlang.phash2(id)}") 99 | # The file may exist in some cases (multiple runs with the same output dir) 100 | File.rm(output_file) 101 | test_config = %{id: id, dynamic?: dynamic?, output_file: output_file} 102 | 103 | {{test.module, test.name}, test_config} 104 | end) 105 | end) 106 | |> Map.new() 107 | 108 | update_in(config.tests, &Map.merge(&1, tests)) 109 | end 110 | 111 | defp get_test_config(%ExUnit.Test{} = test, config) do 112 | Map.fetch!(config.tests, {test.module, test.name}) 113 | end 114 | 115 | defp dynamic?(%ExUnit.Test{} = test, config), do: get_test_config(test, config).dynamic? 116 | 117 | defp update_test_counter(config) do 118 | %{config | test_counter: config.test_counter + 1} 119 | end 120 | 121 | defp update_failure_counter(config, %ExUnit.Test{state: {:failed, _}}) do 122 | %{config | failure_counter: config.failure_counter + 1} 123 | end 124 | 125 | defp update_failure_counter(config, %ExUnit.Test{}), do: config 126 | 127 | defp make_id(true = _dynamic?, %ExUnit.Test{tags: tags}) do 128 | "#{Path.relative_to_cwd(tags[:file])}:#{tags[:line]}" 129 | end 130 | 131 | defp make_id(false, %ExUnit.Test{} = test) do 132 | file = test.tags.file 133 | name = remove_prefix(test) |> String.replace("\n", " ") 134 | 135 | if describe = test.tags.describe do 136 | "#{file}::#{describe}::#{name}" 137 | else 138 | "#{file}::#{name}" 139 | end 140 | end 141 | 142 | defp remove_prefix(%ExUnit.Test{} = test) do 143 | name = to_string(test.name) 144 | 145 | prefix = 146 | if test.tags.describe do 147 | "#{test.tags.test_type} #{test.tags.describe} " 148 | else 149 | "#{test.tags.test_type} " 150 | end 151 | 152 | String.replace_prefix(name, prefix, "") 153 | end 154 | 155 | defp make_status(%ExUnit.Test{state: nil}), do: "passed" 156 | defp make_status(%ExUnit.Test{state: {:failed, _}}), do: "failed" 157 | defp make_status(%ExUnit.Test{state: {:skipped, _}}), do: "skipped" 158 | defp make_status(%ExUnit.Test{state: {:excluded, _}}), do: "skipped" 159 | defp make_status(%ExUnit.Test{state: {:invalid, _}}), do: "failed" 160 | 161 | defp save_test_output(%ExUnit.Test{} = test, config) do 162 | output = [make_output(test, config), "\n"] 163 | 164 | if output do 165 | file = get_test_config(test, config).output_file 166 | 167 | if File.exists?(file) do 168 | File.write!(file, ["\n\n", output], [:append]) 169 | else 170 | File.write!(file, output) 171 | end 172 | 173 | file 174 | end 175 | end 176 | 177 | defp make_output(%ExUnit.Test{state: {:failed, failures}} = test, config) do 178 | failures = 179 | ExUnit.Formatter.format_test_failure( 180 | test, 181 | failures, 182 | config.failure_counter, 183 | 80, 184 | &formatter(&1, &2, config) 185 | ) 186 | 187 | [failures, format_captured_logs(test.logs)] 188 | end 189 | 190 | defp make_output(%ExUnit.Test{state: {:skipped, due_to}}, _config) do 191 | "Skipped #{due_to}" 192 | end 193 | 194 | defp make_output(%ExUnit.Test{state: {:excluded, due_to}}, _config) do 195 | "Excluded #{due_to}" 196 | end 197 | 198 | defp make_output(%ExUnit.Test{state: {:invalid, module}}, _config) do 199 | "Test is invalid. `setup_all` for #{inspect(module.name)} failed" 200 | end 201 | 202 | defp make_output(%ExUnit.Test{state: nil} = test, config) do 203 | if dynamic?(test, config) do 204 | "#{test.name} passed in #{format_us(test.time)}ms" 205 | else 206 | "Test passed in #{format_us(test.time)}ms" 207 | end 208 | end 209 | 210 | defp format_captured_logs(""), do: [] 211 | 212 | defp format_captured_logs(output) do 213 | indent = "\n " 214 | output = String.replace(output, "\n", indent) 215 | [" The following output was logged:", indent | output] 216 | end 217 | 218 | defp make_errors(%ExUnit.Test{state: {:failed, failures}} = test) do 219 | Enum.map(failures, fn failure -> 220 | {message, stack} = make_error_message(failure) 221 | %{message: message, line: make_error_line(stack, test)} 222 | end) 223 | end 224 | 225 | defp make_errors(%ExUnit.Test{}), do: [] 226 | 227 | defp make_error_message(failure) do 228 | case failure do 229 | {{:EXIT, _}, {reason, [_ | _] = stack}, _stack} -> 230 | {extract_message(:error, reason), stack} 231 | 232 | {kind, reason, stack} -> 233 | {extract_message(kind, reason), stack} 234 | end 235 | end 236 | 237 | defp extract_message(:error, %ExUnit.AssertionError{message: message}), do: message 238 | 239 | defp extract_message(kind, reason) do 240 | kind 241 | |> Exception.format_banner(reason) 242 | |> String.split("\n", trim: true) 243 | |> hd() 244 | |> String.replace_prefix("** ", "") 245 | end 246 | 247 | defp make_error_line(stack, %ExUnit.Test{} = test) do 248 | if test_call = find_exact_test_stack_match(stack, test) do 249 | line_from_stack_entry(test_call) 250 | else 251 | stack 252 | |> find_anon_fun_test_stack_match(test) 253 | |> line_from_stack_entry() 254 | end 255 | end 256 | 257 | defp find_exact_test_stack_match(stack, test) do 258 | Enum.find(stack, fn {module, function, _, _} -> 259 | module == test.module and function == test.name 260 | end) 261 | end 262 | 263 | defp find_anon_fun_test_stack_match(stack, test) do 264 | fun_prefix = "-#{test.name}/1-" 265 | 266 | Enum.find(stack, fn {module, function, _, _} -> 267 | module == test.module and String.starts_with?(to_string(function), fun_prefix) 268 | end) 269 | end 270 | 271 | defp line_from_stack_entry({_, _, _, location}) do 272 | if line = location[:line] do 273 | line - 1 274 | end 275 | end 276 | 277 | defp line_from_stack_entry(nil), do: nil 278 | 279 | # Format us as ms, from CLIFormatter 280 | defp format_us(us) do 281 | us = div(us, 10) 282 | 283 | if us < 10 do 284 | "0.0#{us}" 285 | else 286 | us = div(us, 10) 287 | "#{div(us, 10)}.#{rem(us, 10)}" 288 | end 289 | end 290 | 291 | # Color styles, copied from CLIFormatter 292 | 293 | defp colorize(escape, string, %{colors: colors}) do 294 | if colors[:enabled] do 295 | [escape, string, :reset] 296 | |> IO.ANSI.format_fragment(true) 297 | |> IO.iodata_to_binary() 298 | else 299 | string 300 | end 301 | end 302 | 303 | defp colorize_doc(escape, doc, %{colors: colors}) do 304 | if colors[:enabled] do 305 | Inspect.Algebra.color(doc, escape, %Inspect.Opts{syntax_colors: colors}) 306 | else 307 | doc 308 | end 309 | end 310 | 311 | defp formatter(:diff_enabled?, _, %{colors: colors}), do: colors[:enabled] 312 | 313 | defp formatter(:error_info, msg, config), do: colorize(:red, msg, config) 314 | 315 | defp formatter(:extra_info, msg, config), do: colorize(:cyan, msg, config) 316 | 317 | defp formatter(:location_info, msg, config), do: colorize([:bright, :black], msg, config) 318 | 319 | defp formatter(:diff_delete, doc, config), do: colorize_doc(:diff_delete, doc, config) 320 | 321 | defp formatter(:diff_delete_whitespace, doc, config), 322 | do: colorize_doc(:diff_delete_whitespace, doc, config) 323 | 324 | defp formatter(:diff_insert, doc, config), do: colorize_doc(:diff_insert, doc, config) 325 | 326 | defp formatter(:diff_insert_whitespace, doc, config), 327 | do: colorize_doc(:diff_insert_whitespace, doc, config) 328 | 329 | defp formatter(:blame_diff, msg, %{colors: colors} = config) do 330 | if colors[:enabled] do 331 | colorize(:red, msg, config) 332 | else 333 | "-" <> msg <> "-" 334 | end 335 | end 336 | 337 | defp formatter(_, msg, _config), do: msg 338 | 339 | @default_colors [ 340 | diff_delete: :red, 341 | diff_delete_whitespace: IO.ANSI.color_background(2, 0, 0), 342 | diff_insert: :green, 343 | diff_insert_whitespace: IO.ANSI.color_background(0, 2, 0) 344 | ] 345 | 346 | defp colors(opts) do 347 | @default_colors 348 | |> Keyword.merge(opts[:colors]) 349 | |> Keyword.put_new(:enabled, IO.ANSI.enabled?()) 350 | end 351 | end 352 | -------------------------------------------------------------------------------- /neotest_elixir/json_encoder.ex: -------------------------------------------------------------------------------- 1 | defmodule NeotestElixir.JsonEncoder do 2 | @moduledoc """ 3 | A custom JSON encoder that doesn't use protocols based on [elixir-json](https://github.com/cblage/elixir-json). 4 | 5 | The encoder is embedded because that way we don't depend on a library being installed 6 | in the project and also we can't really add dependencies ourselves. 7 | """ 8 | 9 | @acii_space 32 10 | 11 | def encode!(input) do 12 | do_encode(input) 13 | end 14 | 15 | defp do_encode(number) when is_number(number), do: to_string(number) 16 | defp do_encode(nil), do: "null" 17 | defp do_encode(true), do: "true" 18 | defp do_encode(false), do: "false" 19 | defp do_encode(atom) when is_atom(atom), do: do_encode(to_string(atom)) 20 | 21 | defp do_encode(string) when is_binary(string) do 22 | [?", Enum.reverse(encode_binary(string, [])), ?"] 23 | end 24 | 25 | defp do_encode(map) when is_map(map) do 26 | content = 27 | Enum.map(map, fn {key, value} -> 28 | [do_encode(key), ": ", do_encode(value)] 29 | end) 30 | 31 | [?{, Enum.intersperse(content, ", "), ?}] 32 | end 33 | 34 | defp do_encode(list) when is_list(list) do 35 | content = Enum.map(list, &do_encode/1) 36 | 37 | [?[, Enum.intersperse(content, ", "), ?]] 38 | end 39 | 40 | defp encode_binary(<<>>, acc) do 41 | acc 42 | end 43 | 44 | defp encode_binary(<>, acc) do 45 | encode_binary(tail, [encode_binary_character(head) | acc]) 46 | end 47 | 48 | defp encode_binary_character(?"), do: [?\\, ?"] 49 | defp encode_binary_character(?\b), do: [?\\, ?b] 50 | defp encode_binary_character(?\f), do: [?\\, ?f] 51 | defp encode_binary_character(?\n), do: [?\\, ?n] 52 | defp encode_binary_character(?\r), do: [?\\, ?r] 53 | defp encode_binary_character(?\t), do: [?\\, ?t] 54 | defp encode_binary_character(?\\), do: [?\\, ?\\] 55 | 56 | defp encode_binary_character(char) when is_number(char) and char < @acii_space do 57 | [?\\, ?u | encode_hexadecimal_unicode_control_character(char)] 58 | end 59 | 60 | defp encode_binary_character(char), do: char 61 | 62 | defp encode_hexadecimal_unicode_control_character(char) when is_number(char) do 63 | char 64 | |> Integer.to_charlist(16) 65 | |> zeropad_hexadecimal_unicode_control_character() 66 | end 67 | 68 | defp zeropad_hexadecimal_unicode_control_character([a, b, c]), do: [?0, a, b, c] 69 | defp zeropad_hexadecimal_unicode_control_character([a, b]), do: [?0, ?0, a, b] 70 | defp zeropad_hexadecimal_unicode_control_character([a]), do: [?0, ?0, ?0, a] 71 | defp zeropad_hexadecimal_unicode_control_character(iolist) when is_list(iolist), do: iolist 72 | end 73 | 74 | # NeotestElixir.JsonEncoder.encode!(%{ 75 | # a: 1, 76 | # b: nil, 77 | # c: true, 78 | # d: false, 79 | # e: :cool, 80 | # f: "some string", 81 | # g: [1, 2.0, "three"], 82 | # h: %{ 83 | # "i" => "nested", 84 | # "j" => "map" 85 | # }, 86 | # l: "some string with a \t and a \n", 87 | # m: "\0\3\a" 88 | # }) 89 | # |> IO.puts() 90 | -------------------------------------------------------------------------------- /neotest_elixir/test_interactive_runner.ex: -------------------------------------------------------------------------------- 1 | defmodule NeotestElixir.TestInteractiveRunner do 2 | @moduledoc """ 3 | Copyright (c) 2021-2022 Randy Coulman 4 | Copyright (c) 2022 Jhon Pedroza 5 | 6 | A copy of https://github.com/randycoulman/mix_test_interactive/blob/main/lib/mix_test_interactive/port_runner.ex 7 | modified to work with Neotest. 8 | """ 9 | 10 | @application :mix_test_interactive 11 | @type runner :: 12 | (String.t(), [String.t()], keyword() -> 13 | {Collectable.t(), exit_status :: non_neg_integer()}) 14 | @type os_type :: {atom(), atom()} 15 | 16 | alias MixTestInteractive.Config 17 | 18 | @doc """ 19 | Run tests based on the current configuration. 20 | """ 21 | @spec run(Config.t(), [String.t()], os_type(), runner()) :: :ok 22 | def run( 23 | config, 24 | args, 25 | os_type \\ :os.type(), 26 | runner \\ &System.cmd/3 27 | ) do 28 | task_command = [config.task | args] 29 | do_commands = [neotest_requires(), task_command] 30 | 31 | case os_type do 32 | {:win32, _} -> 33 | runner.("mix", flatten_do_commands(do_commands), 34 | env: [{"MIX_ENV", "test"}], 35 | into: IO.stream(:stdio, :line) 36 | ) 37 | 38 | _ -> 39 | do_commands = [enable_ansi(task_command) | do_commands] 40 | 41 | Path.join(:code.priv_dir(@application), "zombie_killer") 42 | |> runner.(["mix" | flatten_do_commands(do_commands)], 43 | env: [{"MIX_ENV", "test"}], 44 | into: IO.stream(:stdio, :line) 45 | ) 46 | end 47 | 48 | :ok 49 | end 50 | 51 | defp neotest_requires do 52 | plugin = System.fetch_env!("NEOTEST_PLUGIN_PATH") 53 | json_encoder = "#{plugin}/neotest_elixir/json_encoder.ex" 54 | exunit_formatter = "#{plugin}/neotest_elixir/formatter.ex" 55 | ["run", "-r", json_encoder, "-r", exunit_formatter] 56 | end 57 | 58 | defp enable_ansi(task_command) do 59 | enable_command = "Application.put_env(:elixir, :ansi_enabled, true);" 60 | 61 | if Enum.member?(task_command, "--no-start") do 62 | ["run", "--no-start", "-e", enable_command] 63 | else 64 | ["run", "-e", enable_command] 65 | end 66 | end 67 | 68 | defp flatten_do_commands(do_commands) do 69 | commands = do_commands |> Enum.intersperse([","]) |> Enum.concat() 70 | ["do" | commands] 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /stylua.toml: -------------------------------------------------------------------------------- 1 | indent_type = "Spaces" 2 | indent_width = 2 3 | call_parentheses = "Always" 4 | -------------------------------------------------------------------------------- /tests/sample_proj/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /tests/sample_proj/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | sample_proj-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /tests/sample_proj/.nvim.lua: -------------------------------------------------------------------------------- 1 | if not pcall(require, "neotest") then 2 | return 3 | end 4 | 5 | vim.schedule(function() 6 | require("neotest").setup_project(vim.loop.cwd(), { 7 | adapters = { 8 | require("neotest-elixir")({ mix_task = "test" }), 9 | }, 10 | }) 11 | end) 12 | -------------------------------------------------------------------------------- /tests/sample_proj/README.md: -------------------------------------------------------------------------------- 1 | # SampleProj 2 | 3 | An Elixir project that contains test files with different cases to check that the adapter works. 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `sample_proj` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:sample_proj, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at . 21 | 22 | -------------------------------------------------------------------------------- /tests/sample_proj/lib/sample_proj.ex: -------------------------------------------------------------------------------- 1 | defmodule SampleProj do 2 | @moduledoc """ 3 | Documentation for `SampleProj`. 4 | """ 5 | 6 | @doc """ 7 | Hello world. 8 | 9 | ## Examples 10 | 11 | iex> SampleProj.hello() 12 | :world 13 | 14 | iex> SampleProj.hello() == :world 15 | true 16 | 17 | """ 18 | def hello do 19 | :world 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /tests/sample_proj/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule SampleProj.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :sample_proj, 7 | version: "0.1.0", 8 | elixir: "~> 1.14", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | preferred_cli_env: [ 12 | "test.watch": :test, 13 | "test.interactive": :test 14 | ] 15 | ] 16 | end 17 | 18 | # Run "mix help compile.app" to learn about applications. 19 | def application do 20 | [ 21 | extra_applications: [:logger] 22 | ] 23 | end 24 | 25 | # Run "mix help deps" to learn about dependencies. 26 | defp deps do 27 | [ 28 | # {:dep_from_hexpm, "~> 0.3.0"}, 29 | # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} 30 | {:mix_test_watch, "~> 1.0", only: [:dev, :test], runtime: false}, 31 | {:mix_test_interactive, "~> 1.0", only: [:dev, :test], runtime: false} 32 | ] 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /tests/sample_proj/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 3 | "mix_test_interactive": {:hex, :mix_test_interactive, "1.2.1", "a9727e5172c5e0be19cc47c7e749303e36465afa7b3625c2c7e905fa73958a55", [:mix], [{:file_system, "~> 0.2", [hex: :file_system, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "502d855d98400b9baeb93d5792c56dcf26544951332a6a3dcb05a3d7bfffdbae"}, 4 | "mix_test_watch": {:hex, :mix_test_watch, "1.1.0", "330bb91c8ed271fe408c42d07e0773340a7938d8a0d281d57a14243eae9dc8c3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "52b6b1c476cbb70fd899ca5394506482f12e5f6b0d6acff9df95c7f1e0812ec3"}, 5 | "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, 6 | } 7 | -------------------------------------------------------------------------------- /tests/sample_proj/test/sample_proj/parse_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SampleProj.ParseTest do 2 | use ExUnit.Case, async: true 3 | 4 | doctest SampleProj 5 | 6 | test "a test without describe" do 7 | IO.puts("This is some output, where is it?") 8 | assert SampleProj.hello() == :world 9 | end 10 | 11 | describe "simple tests" do 12 | test "the most basic test" do 13 | assert SampleProj.hello() == :world 14 | end 15 | 16 | test "test with a context", %{} do 17 | assert SampleProj.hello() == :world 18 | end 19 | 20 | test "inline test", do: assert(SampleProj.hello() == :world) 21 | 22 | test("inline test with a context", %{}, do: assert(SampleProj.hello() == :world)) 23 | 24 | test ~s(with the s sigil) do 25 | assert SampleProj.hello() == :world 26 | end 27 | 28 | test ~S(with the S sigil) do 29 | assert SampleProj.hello() == :world 30 | end 31 | 32 | test "without a body" 33 | 34 | test "with a new\nline" do 35 | assert SampleProj.hello() == :world 36 | end 37 | 38 | test "with a \"quoted text\"" do 39 | assert SampleProj.hello() == :world 40 | end 41 | 42 | test "multiline 43 | test" do 44 | assert SampleProj.hello() == :world 45 | end 46 | 47 | test """ 48 | multiline 49 | heredoc 50 | test 51 | """ do 52 | assert SampleProj.hello() == :world 53 | end 54 | end 55 | 56 | describe "dynamic tests" do 57 | for i <- 1..3 do 58 | test "#{i} at the start" do 59 | assert SampleProj.hello() == :world 60 | end 61 | 62 | test "in the #{i} middle" do 63 | assert SampleProj.hello() == :world 64 | end 65 | 66 | test "at the end #{i}" do 67 | assert SampleProj.hello() == :world 68 | end 69 | 70 | test "#{i}" do 71 | assert SampleProj.hello() == :world 72 | end 73 | 74 | test "with context #{i}", %{} do 75 | assert SampleProj.hello() == :world 76 | end 77 | 78 | test "inline #{i}", do: assert(SampleProj.hello() == :world) 79 | 80 | for j <- [:foo, :bar] do 81 | test "#{i} nested #{j} test" do 82 | assert SampleProj.hello() == :world 83 | end 84 | end 85 | 86 | test ~s(with the s sigil #{i}) do 87 | assert SampleProj.hello() == :world 88 | end 89 | 90 | test "with a #{i} new\nline" do 91 | assert SampleProj.hello() == :world 92 | end 93 | 94 | test "multiline #{i} 95 | test" do 96 | assert SampleProj.hello() == :world 97 | end 98 | 99 | test """ 100 | multiline 101 | heredoc #{i} 102 | test 103 | """ do 104 | assert SampleProj.hello() == :world 105 | end 106 | end 107 | 108 | for k <- ["foo", "bar"] do 109 | test k do 110 | assert SampleProj.hello() == :world 111 | end 112 | end 113 | 114 | test inspect(&SampleProj.hello/0) do 115 | assert SampleProj.hello() == :world 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /tests/sample_proj/test/sample_proj_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SampleProjTest do 2 | use ExUnit.Case, async: true 3 | doctest SampleProj 4 | 5 | test "greets the world" do 6 | assert SampleProj.hello() == :world 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /tests/sample_proj/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /tests/unit/core_spec.lua: -------------------------------------------------------------------------------- 1 | local core = require("neotest-elixir.core") 2 | 3 | describe("build_iex_test_command", function() 4 | local relative_to 5 | 6 | before_each(function() 7 | -- always return the input 8 | relative_to = function(path) 9 | return path 10 | end 11 | end) 12 | 13 | it("should return the correct command for a test", function() 14 | local position = { 15 | type = "test", 16 | path = "example_test.exs", 17 | range = { 1, 2 }, 18 | } 19 | local output_dir = "test_output" 20 | local seed = 1234 21 | 22 | local actual = core.build_iex_test_command(position, output_dir, seed, relative_to) 23 | 24 | assert.are.equal('IExUnit.run("example_test.exs", line: 2, seed: 1234, output_dir: "test_output")', actual) 25 | end) 26 | 27 | it("should return the correct command for a file", function() 28 | local position = { 29 | type = "file", 30 | path = "test/neotest_elixir/core_spec.exs", 31 | range = { 1, 2 }, 32 | } 33 | local output_dir = "test_output" 34 | local seed = 1234 35 | 36 | local actual = core.build_iex_test_command(position, output_dir, seed, relative_to) 37 | 38 | assert.are.equal('IExUnit.run("test/neotest_elixir/core_spec.exs", seed: 1234, output_dir: "test_output")', actual) 39 | end) 40 | 41 | it("should return the correct command for the folder", function() 42 | local position = { 43 | type = "folder", 44 | path = "test/neotest_elixir", 45 | range = { 1, 2 }, 46 | } 47 | local output_dir = "test_output" 48 | local seed = 1234 49 | 50 | local actual = core.build_iex_test_command(position, output_dir, seed, relative_to) 51 | 52 | assert.are.equal('IExUnit.run("test/neotest_elixir", seed: 1234, output_dir: "test_output")', actual) 53 | end) 54 | end) 55 | 56 | describe("iex_watch_command", function() 57 | it("should return the correct command", function() 58 | local results_path = "results_path" 59 | local maybe_compile_error_path = "maybe_compile_error_path" 60 | local seed = 1234 61 | 62 | local actual = core.iex_watch_command(results_path, maybe_compile_error_path, seed) 63 | 64 | assert.are.equal( 65 | "(tail -n 50 -f results_path maybe_compile_error_path &) | grep -q 1234 && cat maybe_compile_error_path", 66 | actual 67 | ) 68 | end) 69 | end) 70 | 71 | describe("get_or_create_iex_term", function() 72 | local function starts_with(str, start) 73 | return str:sub(1, #start) == start 74 | end 75 | 76 | it("should create a new iex term if none exists", function() 77 | local actual = core.get_or_create_iex_term(42) 78 | assert.are.equal(42, actual.id) 79 | end) 80 | 81 | it("should cd to the child app if the opened_file in umbrella project", function() 82 | local actual = core.iex_start_command("/root/apps/child_app1/test/child_app_test.exs") 83 | assert.is.True(starts_with(actual, "cd apps/child_app1 && ")) 84 | end) 85 | 86 | it("should not cd to the some place when in a normal app", function() 87 | local actual = core.iex_start_command("/root/my_app/test/my_app_test.exs") 88 | assert.is.False(starts_with(actual, "iex -S mix")) 89 | end) 90 | end) 91 | 92 | describe("build_mix_command", function() 93 | local mix_task_func 94 | local extra_formatter_func 95 | local mix_task_args_func 96 | local relative_to 97 | 98 | before_each(function() 99 | mix_task_func = function() 100 | return "test" 101 | end 102 | extra_formatter_func = function() 103 | return { "ExUnit.CLIFormatter" } 104 | end 105 | mix_task_args_func = function() 106 | return {} 107 | end 108 | relative_to = function(path) 109 | return path 110 | end 111 | end) 112 | 113 | it("should return the correct command for a test", function() 114 | local position = { 115 | type = "test", 116 | path = "example_test.exs", 117 | range = { 1, 2 }, 118 | } 119 | 120 | local actual_tbl = 121 | core.build_mix_command(position, mix_task_func, extra_formatter_func, mix_task_args_func, {}, relative_to) 122 | 123 | local expected = string.format( 124 | "elixir -r %s -r %s -S mix test --formatter NeotestElixir.Formatter --formatter ExUnit.CLIFormatter example_test.exs:2", 125 | core.json_encoder_path, 126 | core.exunit_formatter_path 127 | ) 128 | assert.are.equal(expected, table.concat(actual_tbl, " ")) 129 | end) 130 | 131 | it("should not return line args for a file test", function() 132 | local position = { 133 | type = "file", 134 | path = "example_test.exs", 135 | range = { 1, 2 }, 136 | } 137 | 138 | local actual_tbl = 139 | core.build_mix_command(position, mix_task_func, extra_formatter_func, mix_task_args_func, {}, relative_to) 140 | 141 | local expected = string.format( 142 | "elixir -r %s -r %s -S mix test --formatter NeotestElixir.Formatter --formatter ExUnit.CLIFormatter example_test.exs", 143 | core.json_encoder_path, 144 | core.exunit_formatter_path 145 | ) 146 | assert.are.equal(expected, table.concat(actual_tbl, " ")) 147 | end) 148 | end) 149 | --------------------------------------------------------------------------------