├── .busted ├── .editorconfig ├── .github └── workflows │ └── tests.yml ├── .luacheckrc ├── LICENSE ├── README.md ├── lua └── neotest-swift-testing │ ├── init.lua │ ├── logging.lua │ ├── parser.lua │ └── util.lua ├── neotest-swift-testing-scm-1.rockspec └── spec ├── Fixtures ├── Package.swift ├── Sources │ └── Fixture.swift ├── Tests │ └── TargetTests │ │ └── TargetTests.swift └── package_description.json ├── parser_spec.lua └── plugin_spec.lua /.busted: -------------------------------------------------------------------------------- 1 | return { 2 | _all = { 3 | coverage = false, 4 | lpath = "lua/?.lua;lua/?/init.lua", 5 | lua = "nlua", 6 | }, 7 | default = { 8 | verbose = true, 9 | }, 10 | tests = { 11 | verbose = true, 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.lua] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Run tests 3 | on: 4 | pull_request: ~ 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | build: 11 | name: Run tests 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | neovim_version: ['nightly'] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Run tests 20 | uses: nvim-neorocks/nvim-busted-action@v1 21 | with: 22 | nvim_version: ${{ matrix.neovim_version }} 23 | -------------------------------------------------------------------------------- /.luacheckrc: -------------------------------------------------------------------------------- 1 | ignore = { 2 | "631", -- max_line_length 3 | } 4 | read_globals = { 5 | "vim", 6 | "describe", 7 | "it", 8 | "assert" 9 | } 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Markus Müller 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # neotest-swift-testing 2 | 3 | This is an adapter for using the [neotest](https://github.com/nvim-neotest/neotest) framework with [Swift Testing](https://github.com/swiftlang/swift-testing). 4 | 5 | The plugin is tested with Xcode 16 only but might work with earlier versions as well. 6 | Thanks to Emmet Murray for his [neotest-swift](https://github.com/ehmurray8/neotest-swift) plugin which I used as a starting point. 7 | I focused on Swift Testing only, legacy XCTest is not supported but might work. 8 | 9 | ## Features 10 | 11 | - [x] - Run Swift Test suites and tests 12 | - [x] - Debug tests cases with neotest dap support 13 | - [ ] - Show parametrized tests in the test list 14 | 15 | ## Neovim DAP configuration for Swift 16 | 17 | Add the following configuration file (e.q. neovim-dap.lua for Lazy) to enable debugging of Swift code with `nvim-dap`: 18 | 19 | ```lua 20 | return { 21 | "mfussenegger/nvim-dap", 22 | optional = true, 23 | dependencies = "williamboman/mason.nvim", 24 | opts = function() 25 | local dap = require("dap") 26 | if not dap.adapters.lldb then 27 | local lldb_dap_path = vim.fn.trim(vim.fn.system("xcrun -f lldb-dap")) 28 | dap.adapters.lldb = { 29 | type = "executable", 30 | command = lldb_dap_path, -- adjust as needed, must be absolute path 31 | name = "lldb", 32 | } 33 | end 34 | 35 | dap.configurations.swift = { 36 | { 37 | name = "Launch file", 38 | type = "lldb", 39 | request = "launch", 40 | program = function() 41 | return vim.fn.input("Path to executable: ", vim.fn.getcwd() .. "/", "file") 42 | end, 43 | cwd = "${workspaceFolder}", 44 | stopOnEntry = false, 45 | }, 46 | } 47 | end, 48 | } 49 | ``` 50 | 51 | Feel free to start a pull request if you have any improvements or bug fixes. 52 | -------------------------------------------------------------------------------- /lua/neotest-swift-testing/init.lua: -------------------------------------------------------------------------------- 1 | local lib = require("neotest.lib") 2 | local async = require("neotest.async") 3 | local xml = require("neotest.lib.xml") 4 | local util = require("neotest-swift-testing.util") 5 | local Path = require("plenary.path") 6 | local logger = require("neotest-swift-testing.logging") 7 | local filetype = require("plenary.filetype") 8 | local files = require("neotest.lib.file") 9 | 10 | local M = { 11 | name = "neotest-swift-testing", 12 | root = function(path) 13 | return files.match_root_pattern("Package.swift")(path) 14 | end, 15 | filter_dir = function(name, rel_path, root) 16 | return vim.list_contains({ "Sources", "build", ".git", ".build", ".git", ".swiftpm" }, name) == false 17 | end, 18 | is_test_file = function(file_path) 19 | if not vim.endswith(file_path, ".swift") then 20 | return false 21 | end 22 | local elems = vim.split(file_path, Path.path.sep) 23 | local file_name = elems[#elems] 24 | return vim.endswith(file_name, "Test.swift") or vim.endswith(file_name, "Tests.swift") 25 | end, 26 | } 27 | 28 | -- Add filetype for swift until it gets added to plenary's built-in filetypes 29 | -- See https://github.com/nvim-lua/plenary.nvim?tab=readme-ov-file#plenaryfiletype for more information 30 | if filetype.detect_from_extension("swift") == "" then 31 | filetype.add_table({ 32 | extension = { ["swift"] = "swift" }, 33 | }) 34 | end 35 | 36 | local treesitter_query = [[ 37 | ;; @Suite struct TestSuite 38 | ((class_declaration 39 | (modifiers 40 | (attribute 41 | (user_type 42 | (type_identifier) @annotation (#eq? @annotation "Suite"))))? 43 | name: (type_identifier) @namespace.name) 44 | ) @namespace.definition 45 | 46 | ((class_declaration 47 | name: (user_type 48 | (type_identifier) @namespace.name))) @namespace.definition 49 | 50 | ;; @Test test func 51 | ((function_declaration 52 | (modifiers 53 | (attribute 54 | (user_type 55 | (type_identifier) @annotation (#eq? @annotation "Test")))) 56 | name: (simple_identifier) @test.name)) @test.definition 57 | 58 | ]] 59 | 60 | ---@async 61 | ---@param cmd string[] 62 | ---@return string|nil 63 | local function shell(cmd) 64 | local code, result = lib.process.run(cmd, { stdout = true, stderr = true }) 65 | if code ~= 0 or result.stderr ~= nil or result.stdout == nil then 66 | logger.error("Failed to run command: " .. vim.inspect(cmd) .. " " .. result.stderr) 67 | return nil 68 | end 69 | return result.stdout 70 | end 71 | 72 | ---@param kind SwiftTesting.TestType 73 | ---@return neotest.PositionType 74 | local function position_type(kind) 75 | if kind == "suite" then 76 | return "namespace" 77 | else 78 | return "test" 79 | end 80 | end 81 | 82 | ---@param record SwiftTesting.TestRecord 83 | ---@return neotest.Position|nil 84 | local function position_from_record(record) 85 | if record.kind == "test" and record.payload ~= nil then 86 | ---@type neotest.Position 87 | local pos = { 88 | id = record.payload.id, 89 | name = record.payload.name, 90 | path = record.payload.sourceLocation._filePath, 91 | type = position_type(record.payload.kind), 92 | range = { 93 | record.payload.sourceLocation.line, 94 | record.payload.sourceLocation.column, 95 | record.payload.sourceLocation.line, 96 | record.payload.sourceLocation.column, 97 | }, 98 | } 99 | return pos 100 | end 101 | return nil 102 | end 103 | 104 | ---@async 105 | ---@param file_path string 106 | ---@return neotest.Tree 107 | M.discover_positions = function(file_path) 108 | -- local output = async.fn.tempname() .. "test-events.jsonl" 109 | -- shell({ "swift", "test", "list", "--enable-swift-testing", "--event-stream-output-path", output }) 110 | -- local lines = files.read_lines(output) 111 | -- 112 | -- ---@type neotest.Position[] 113 | -- local positions = {} 114 | -- for _, line in ipairs(lines) do 115 | -- local parsed = parser.parse(line) 116 | -- if parsed ~= nil and parsed.kind == "test" then 117 | -- local pos = position_from_record(parsed) 118 | -- if pos ~= nil then 119 | -- table.insert(positions, pos) 120 | -- end 121 | -- end 122 | -- end 123 | -- local Tree = require("neotest.types").Tree 124 | -- logger.debug("Discovered positions: " .. vim.inspect(positions)) 125 | -- return Tree.from_list(positions, function(p) 126 | return lib.treesitter.parse_positions(file_path, treesitter_query, { nested_tests = true, require_namespaces = true }) 127 | end 128 | 129 | ---Removes new line characters 130 | ---@param str string 131 | ---@return string 132 | local function remove_nl(str) 133 | local trimmed, _ = string.gsub(str, "\n", "") 134 | return trimmed 135 | end 136 | 137 | ---Returns Xcode devoloper path 138 | ---@async 139 | ---@return string|nil 140 | local function get_dap_cmd() 141 | -- TODO: use swiftly 142 | local result = shell({ "xcode-select", "-p" }) 143 | if not result then 144 | return nil 145 | end 146 | result = shell({ "fd", "swiftpm-testing-helper", remove_nl(result) }) 147 | if not result then 148 | return nil 149 | end 150 | return remove_nl(result) 151 | end 152 | 153 | ---@async 154 | ---@return string[]|nil 155 | local function get_test_executable() 156 | local bin_path = shell({ "swift", "build", "--show-bin-path" }) 157 | if not bin_path then 158 | return nil 159 | end 160 | local json_path = remove_nl(bin_path) .. "/description.json" 161 | if not files.exists(json_path) then 162 | return nil 163 | end 164 | local decoded = vim.json.decode(files.read(json_path)) 165 | return decoded.builtTestProducts[1].binaryPath 166 | end 167 | 168 | ---@async 169 | ---@param test_name string 170 | ---@param dap_args? table 171 | ---@return table|nil 172 | local function get_dap_config(test_name, dap_args) 173 | local program = get_dap_cmd() 174 | if program == nil then 175 | logger.error("Failed to get the spm test helper path") 176 | return nil 177 | end 178 | local executable = get_test_executable() 179 | if not executable then 180 | logger.error("Failed to get the test executable path") 181 | return nil 182 | end 183 | return vim.tbl_extend("force", dap_args or {}, { 184 | name = "Swift Test debugger", 185 | type = "lldb", 186 | request = "launch", 187 | program = program, 188 | args = { 189 | "--test-bundle-path", 190 | executable, 191 | "--testing-library", 192 | "swift-testing", 193 | "--enable-swift-test", 194 | "--filter", 195 | test_name, 196 | }, 197 | cwd = "${workspaceFolder}", 198 | stopOnEntry = false, 199 | }) 200 | end 201 | 202 | ---@async 203 | ---@return integer 204 | local function ensure_test_bundle_is_build() 205 | local code, result = lib.process.run({ 206 | "swift", 207 | "build", 208 | "--build-tests", 209 | "--enable-swift-testing", 210 | "-c", 211 | "debug", 212 | }) 213 | if code ~= 0 then 214 | logger.debug("Failed to build test bundle: " .. result.stderr) 215 | end 216 | return code 217 | end 218 | 219 | ---Finds the test target for a given file in the package directory 220 | ---@async 221 | ---@param package_directory string 222 | ---@param file_name string 223 | ---@return string|nil The test target name or nil if not found 224 | local function find_test_target(package_directory, file_name) 225 | local result = shell({ "swift", "package", "--package-path", package_directory, "describe", "--type", "json" }) 226 | if result == nil then 227 | logger.error("Failed to run swift package describe.") 228 | return nil 229 | end 230 | 231 | local decoded = vim.json.decode(result) 232 | if not decoded then 233 | logger.error("Failed to decode swift package describe output.") 234 | return nil 235 | end 236 | 237 | for _, target in ipairs(decoded.targets or {}) do 238 | if target.type == "test" and target.sources and vim.list_contains(target.sources, file_name) then 239 | return target.name 240 | end 241 | end 242 | return nil 243 | end 244 | 245 | ---@async 246 | ---@param args neotest.RunArgs 247 | ---@return neotest.RunSpec|neotest.RunSpec[]|nil 248 | function M.build_spec(args) 249 | if not args.tree then 250 | logger.error("Unexpectedly did not receive a neotest.Tree.") 251 | return nil 252 | end 253 | local position = args.tree:data() 254 | local junit_folder = async.fn.tempname() 255 | local cwd = assert(M.root(position.path), "could not locate root directory of " .. position.path) 256 | 257 | if args.strategy == "dap" then 258 | -- id pattern /Users/name/project/Tests/ProjectTests/fileName.swift::className::testName 259 | local file_name, class_name, test_name = position.id:match(".*/(.-%.swift)::(.-)::(.*)") 260 | 261 | if file_name == nil or class_name == nil or test_name == nil then 262 | logger.error("Could not extract file, class name and test name from position.id: " .. position.id) 263 | return 264 | end 265 | 266 | local target = find_test_target(cwd, file_name) 267 | if not target then 268 | logger.error("Swift test target not found.") 269 | return 270 | end 271 | 272 | local full_test_name = target .. "." .. class_name .. "/" .. test_name .. "()" 273 | if ensure_test_bundle_is_build() ~= 0 then 274 | logger.error("Failed to build test bundle.") 275 | return nil 276 | end 277 | local path = shell({ "xcrun", "--show-sdk-platform-path" }) or "" 278 | return { 279 | cwd = cwd, 280 | context = { is_dap_active = true, position_id = position.id }, 281 | strategy = get_dap_config(full_test_name), 282 | env = { ["DYLD_FRAMEWORK_PATH"] = remove_nl(path) .. "/Developer/Library/Frameworks" }, 283 | } 284 | end 285 | 286 | local command = { 287 | "swift", 288 | "test", 289 | "--enable-swift-testing", 290 | "-c", 291 | "debug", 292 | "--xunit-output", 293 | junit_folder .. "junit.xml", 294 | "-q", 295 | } 296 | local filters = {} 297 | if position.type == "file" then 298 | table.insert(filters, "/" .. position.name) 299 | elseif position.type == "namespace" then 300 | table.insert(filters, "." .. position.name .. "$") 301 | elseif position.type == "test" then 302 | local namespace, test = string.match(position.id, ".*::(.-)::(.-)$") 303 | if namespace ~= nil and test ~= nil then 304 | table.insert(filters, namespace .. "." .. test) 305 | end 306 | elseif position.type == "dir" and position.path ~= cwd then 307 | table.insert(filters, position.name) 308 | end 309 | 310 | if #filters > 0 then 311 | table.insert(command, "--filter") 312 | for _, filter in ipairs(filters) do 313 | table.insert(command, filter) 314 | end 315 | end 316 | 317 | return { 318 | command = command, 319 | context = { 320 | results_path = junit_folder .. "junit-swift-testing.xml", 321 | }, 322 | cwd = cwd, 323 | } 324 | end 325 | 326 | ---Parse the output of swift test to get the line number and error message 327 | ---@async 328 | ---@param output string[] The output of the swift test command 329 | ---@param position neotest.Position The position of the test 330 | ---@param test_name string The name of the test 331 | ---@return integer?, string? The line number and error message. nil if not found 332 | local function parse_errors(output, position, test_name) 333 | local pattern = "Test (%w+)%(%) recorded an issue at ([%w-_]+%.swift):(%d+):%d+: (.+)" 334 | local pattern_with_arguments = 335 | "Test (%w+)%b() recorded an issue with 1 argument value → (.+) at ([%w-_]+%.swift):(%d+):%d+: (.+)" 336 | for _, line in ipairs(output) do 337 | local method, file, line_number, message = line:match(pattern) 338 | if method and file and line_number and message then 339 | if test_name == method and vim.endswith(position.path, file) then 340 | return tonumber(line_number) - 1 or nil, message 341 | end 342 | end 343 | method, _, file, line_number, message = line:match(pattern_with_arguments) 344 | if method and file and line_number and message then 345 | if test_name == method and vim.endswith(position.path, file) then 346 | return tonumber(line_number) - 1 or nil, message 347 | end 348 | end 349 | end 350 | return nil, nil 351 | end 352 | 353 | ---@async 354 | ---@param spec neotest.RunSpec 355 | ---@param result neotest.StrategyResult 356 | ---@param tree neotest.Tree 357 | ---@return table 358 | function M.results(spec, result, tree) 359 | local test_results = {} 360 | local nodes = {} 361 | 362 | if spec.context.errors ~= nil and #spec.context.errors > 0 then 363 | -- mark as failed if a non-test error occurred. 364 | test_results[spec.context.position_id] = { 365 | status = "failed", 366 | errors = spec.context.errors, 367 | } 368 | return test_results 369 | elseif spec.context and spec.context.is_dap_active and spec.context.position_id then 370 | -- return early if test result processing is not desired. 371 | test_results[spec.context.position_id] = { 372 | status = "skipped", 373 | } 374 | return test_results 375 | end 376 | 377 | local position = tree:data() 378 | local list = tree:to_list() 379 | local tests = util.collect_tests(list) 380 | if position.type == "test" then 381 | table.insert(nodes, position) 382 | end 383 | 384 | for _, node in ipairs(tests) do 385 | table.insert(nodes, node) 386 | end 387 | local raw_output = files.read_lines(result.output) 388 | 389 | if files.exists(spec.context.results_path) then 390 | local root = xml.parse(files.read(spec.context.results_path)) 391 | 392 | local testsuites 393 | if root.testsuites.testsuite == nil then 394 | testsuites = {} 395 | elseif #root.testsuites.testsuite == 0 then 396 | testsuites = { root.testsuites.testsuite } 397 | else 398 | testsuites = root.testsuites.testsuite 399 | end 400 | for _, testsuite in pairs(testsuites) do 401 | local testcases 402 | 403 | if testsuite.testcase == nil then 404 | testcases = {} 405 | elseif #testsuite.testcase == 0 then 406 | testcases = { testsuite.testcase } 407 | else 408 | testcases = testsuite.testcase 409 | end 410 | 411 | for _, testcase in ipairs(testcases) do 412 | local test_position = util.find_position(nodes, testcase._attr.classname, testcase._attr.name, spec.cwd) 413 | if test_position ~= nil then 414 | if testcase.failure then 415 | local line_number, error_message = 416 | parse_errors(raw_output, test_position, util.get_prefix(testcase._attr.name, "(")) 417 | test_results[test_position.id] = { 418 | status = "failed", 419 | } 420 | if line_number and error_message then 421 | test_results[test_position.id].errors = { 422 | { line = line_number, message = error_message }, 423 | } 424 | end 425 | else 426 | test_results[test_position.id] = { 427 | status = "passed", 428 | } 429 | end 430 | else 431 | logger.debug("Position not found: " .. testcase._attr.classname .. "/" .. testcase._attr.name) 432 | end 433 | end 434 | end 435 | else 436 | if spec.context.position_id ~= nil then 437 | test_results[spec.context.position_id] = { 438 | status = "failed", 439 | output = result.output, 440 | short = table.concat(raw_output, "\n"), 441 | } 442 | end 443 | end 444 | return test_results 445 | end 446 | 447 | setmetatable(M, { 448 | __call = function(_, opts) 449 | if opts.log_level then 450 | logger:set_level(opts.log_level) 451 | end 452 | return M 453 | end, 454 | }) 455 | 456 | return M 457 | -------------------------------------------------------------------------------- /lua/neotest-swift-testing/logging.lua: -------------------------------------------------------------------------------- 1 | -- https://github.com/fredrikaverpil/neotest-golang/blob/main/lua/neotest-golang/logging.lua 2 | 3 | local Logger = {} 4 | local prefix = "[neotest-swift-testing]" 5 | 6 | local logger 7 | 8 | local log_date_format = "%FT%H:%M:%SZ%z" 9 | ---@param opts table 10 | ---@return neotest.Logger 11 | function Logger.new(opts) 12 | opts = opts or {} 13 | if logger then 14 | return logger 15 | end 16 | logger = {} 17 | setmetatable(logger, { __index = Logger }) 18 | logger = logger 19 | 20 | logger._level = opts.log_level or vim.log.levels.DEBUG 21 | 22 | for level, levelnr in pairs(vim.log.levels) do 23 | logger[level:lower()] = function(...) 24 | local argc = select("#", ...) 25 | if levelnr < logger._level then 26 | return false 27 | end 28 | if argc == 0 then 29 | return true 30 | end 31 | local info = debug.getinfo(2, "Sl") 32 | local fileinfo = string.format("%s:%s", info.short_src, info.currentline) 33 | local parts = { 34 | table.concat({ prefix, level, os.date(log_date_format), fileinfo }, "|"), 35 | } 36 | if _G._NEOTEST_IS_CHILD then 37 | table.insert(parts, "CHILD |") 38 | end 39 | for i = 1, argc do 40 | local arg = select(i, ...) 41 | if arg == nil then 42 | table.insert(parts, "") 43 | elseif type(arg) == "string" then 44 | table.insert(parts, arg) 45 | elseif type(arg) == "table" and arg.__tostring then 46 | table.insert(parts, arg.__tostring(arg)) 47 | else 48 | table.insert(parts, vim.inspect(arg)) 49 | end 50 | end 51 | -- TODO: Add a log file 52 | vim.print(table.concat(parts, " ")) 53 | end 54 | end 55 | return logger 56 | end 57 | 58 | function Logger:set_level(level) 59 | self._level = assert( 60 | type(level) == "number" and level or vim.log.levels[tostring(level):upper()], 61 | string.format("Log level must be one of (trace, debug, info, warn, error), got: %q", level) 62 | ) 63 | end 64 | 65 | return Logger.new({}) 66 | -------------------------------------------------------------------------------- /lua/neotest-swift-testing/parser.lua: -------------------------------------------------------------------------------- 1 | local logger = require("neotest-swift-testing.logging") 2 | local M = {} 3 | 4 | ---@class SwiftTesting.SourceLocation 5 | ---@field _filePath string 6 | ---@field fileID string 7 | ---@field line number 8 | ---@field column number 9 | 10 | ---@alias SwiftTesting.Instant "absolute" | "since1970" 11 | 12 | ---@alias SwiftTesting.RecordType "test" | "event" 13 | 14 | ---@alias SwiftTesting.TestType "suite" | "function" 15 | 16 | ---@class SwiftTesting.Payload 17 | ---@field id string 18 | ---@field kind SwiftTesting.TestType 19 | ---@field sourceLocation SwiftTesting.SourceLocation 20 | ---@field name string 21 | ---@field diplayName? string 22 | ---@field isParameterized? boolean 23 | 24 | ---@class SwiftTesting.TestRecord 25 | ---@field version number 26 | ---@field kind SwiftTesting.RecordType 27 | ---@field payload SwiftTesting.Payload 28 | 29 | ---@alias SwiftTesting.EventKind "runStarted" | "testStarted" | "testCaseStarted" | "issueRecorded" | "testCaseEnded" | "testEnded" | "testSkipped" | "runEnded" | "valueAttached" 30 | 31 | ---@class SwiftTesting.MessageSymbol "default" | "skip" | "pass" | "passWithKnownIssue" | "fail" | "difference" | "warning" | "details" 32 | 33 | ---@class SwiftTesting.Message 34 | ---@field symbol SwiftTesting.MessageSymbol 35 | ---@field text string 36 | 37 | ---@class SwiftTesting.Issue 38 | ---@field isKnown boolean 39 | ---@field sourceLocation? SwiftTesting.SourceLocation 40 | 41 | ---@class SwiftTesting.Attachment 42 | ---@field path string 43 | 44 | ---@class SwiftTesting.Event 45 | ---@field kind SwiftTesting.EventKind 46 | ---@field instant SwiftTesting.Instant 47 | ---@field messages SwiftTesting.Message[] 48 | ---@field issue? SwiftTesting.Issue 49 | ---@field attachment? SwiftTesting.Attachment 50 | ---@field testID? string 51 | 52 | ---@param line string 53 | ---@return SwiftTesting.TestRecord|SwiftTesting.Event|nil 54 | function M.parse(line) 55 | local status, result = pcall(function() 56 | return vim.json.decode(line) 57 | end) 58 | 59 | if not status then 60 | logger.error(string.format("Failed to parse JSON on line %s", result)) 61 | return nil 62 | end 63 | 64 | return result 65 | end 66 | 67 | setmetatable(M, { 68 | __call = function(_, opts) 69 | if opts.log_level then 70 | logger:set_level(opts.log_level) 71 | end 72 | return M 73 | end, 74 | }) 75 | 76 | return M 77 | -------------------------------------------------------------------------------- /lua/neotest-swift-testing/util.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local separator = "::" 3 | 4 | ---Replace the first occurrence of a character in a string. 5 | ---@param str string The string. 6 | ---@param char string The character to replace. 7 | ---@param replacement string The replacement character. 8 | M.replace_first_occurrence = function(str, char, replacement) 9 | return string.gsub(str, char, replacement, 1) 10 | end 11 | 12 | M.table_contains = function(table, value) 13 | for _, v in pairs(table) do 14 | if v == value then 15 | return true 16 | end 17 | end 18 | return false 19 | end 20 | 21 | M.trim_up_to_prefix = function(str, char) 22 | local pattern = "^[^" .. char .. "]*" .. char 23 | return string.gsub(str, pattern, "") 24 | end 25 | 26 | ---Get the prefix of a string. 27 | ---@param str string 28 | ---@param char string 29 | ---@return string 30 | M.get_prefix = function(str, char) 31 | local prefix = string.match(str, "^[^" .. char .. "]*") 32 | return prefix 33 | end 34 | 35 | ---@param list neotest.Position[] 36 | ---@param prefix string 37 | ---@param suffix string 38 | ---@return neotest.Position? 39 | local function find_element_by_id(list, prefix, suffix) 40 | for _, item in ipairs(list) do 41 | local a = vim.startswith(item.id, prefix) 42 | local b = vim.endswith(item.id, suffix) 43 | 44 | if item.type == "test" and a and b then 45 | return item 46 | end 47 | end 48 | return nil 49 | end 50 | 51 | M.collect_tests = function(nested_table) 52 | local flattened_table = {} 53 | 54 | local function recurse(subtable) 55 | for _, item in ipairs(subtable) do 56 | if type(item) == "table" then 57 | if item.type == "test" then 58 | table.insert(flattened_table, item) 59 | else 60 | recurse(item) 61 | end 62 | end 63 | end 64 | end 65 | 66 | recurse(nested_table) 67 | return flattened_table 68 | end 69 | 70 | ---@param list neotest.Position[] 71 | ---@param class_name string 72 | ---@param test_name string 73 | ---@param cwd string 74 | ---@return neotest.Position? 75 | M.find_position = function(list, class_name, test_name, cwd) 76 | local module, class = class_name:match("([^%.]+)%.([^%.]+)") 77 | if not module or not class then 78 | return nil 79 | end 80 | local prefix = cwd .. "/Tests/" .. module 81 | local suffix = separator .. class .. separator .. M.get_prefix(test_name, "(") 82 | 83 | return find_element_by_id(list, prefix, suffix) 84 | end 85 | 86 | return M 87 | -------------------------------------------------------------------------------- /neotest-swift-testing-scm-1.rockspec: -------------------------------------------------------------------------------- 1 | rockspec_format = "3.0" 2 | package = "neotest-swift-testing" 3 | version = "scm-1" 4 | 5 | description = { 6 | summary = "A plugin for running Swift Test with neotest in Neovim.", 7 | homepage = "https://codeberg.org/mmllr/neotest-swift-testing", 8 | license = "MIT", 9 | labels = { "neovim", "swift", "test", "neotest" }, 10 | } 11 | 12 | source = { 13 | url = "git+https://github.com/mmllr/neotest-swift-testing", 14 | } 15 | 16 | dependencies = { 17 | "lua >= 5.1", 18 | "nvim-nio", 19 | "neotest", 20 | "plenary.nvim", 21 | } 22 | 23 | test_dependencies = { 24 | "nlua", 25 | "busted", 26 | "luassert", 27 | } 28 | 29 | test = { 30 | type = "busted", 31 | } 32 | 33 | build = { 34 | type = "builtin", 35 | copy_directories = { 36 | -- Add runtimepath directories, like 37 | -- 'plugin', 'ftplugin', 'doc' 38 | -- here. DO NOT add 'lua' or 'lib'. 39 | }, 40 | } 41 | -------------------------------------------------------------------------------- /spec/Fixtures/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.9 2 | -------------------------------------------------------------------------------- /spec/Fixtures/Sources/Fixture.swift: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmllr/neotest-swift-testing/a63fd7906af3d14d4488c6240ffd6c9768b90882/spec/Fixtures/Sources/Fixture.swift -------------------------------------------------------------------------------- /spec/Fixtures/Tests/TargetTests/TargetTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | 3 | struct TargetTests { 4 | @Test 5 | func aTest() {} 6 | } 7 | -------------------------------------------------------------------------------- /spec/Fixtures/package_description.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": [], 3 | "manifest_display_name": "MyPackage", 4 | "name": "MyPackage", 5 | "path": "/PathToPackage", 6 | "platforms": [], 7 | "products": [ 8 | { 9 | "name": "MyPackage", 10 | "targets": ["MyPackage"], 11 | "type": { 12 | "library": ["automatic"] 13 | } 14 | } 15 | ], 16 | "targets": [ 17 | { 18 | "c99name": "MyPackageTests", 19 | "module_type": "SwiftTarget", 20 | "name": "MyPackageTests", 21 | "path": "Tests/MyPackageTests", 22 | "sources": ["MyPackageTests.swift"], 23 | "target_dependencies": ["MyPackage"], 24 | "type": "test" 25 | }, 26 | { 27 | "c99name": "MyPackage", 28 | "module_type": "SwiftTarget", 29 | "name": "MyPackage", 30 | "path": "Sources/MyPackage", 31 | "product_memberships": ["MyPackage"], 32 | "sources": ["MyPackage.swift"], 33 | "type": "library" 34 | } 35 | ], 36 | "tools_version": "6.1" 37 | } 38 | -------------------------------------------------------------------------------- /spec/parser_spec.lua: -------------------------------------------------------------------------------- 1 | describe("JSON Lines parser", function() 2 | local sut = require("neotest-swift-testing/parser")({ log_level = vim.log.levels.OFF }) 3 | it("Parses empty lines", function() 4 | local result = sut.parse("") 5 | assert.is_nil(result) 6 | end) 7 | 8 | describe("Discovering", function() 9 | it("can parse test suites", function() end) 10 | local input = [[ 11 | {"kind":"test","payload":{"id":"GPXKitTests.ArrayExtensionsTests","kind":"suite","name":"ArrayExtensionsTests","sourceLocation":{"_filePath":"\/Users\/user\/folder\/GPXKit\/Tests\/GPXKitTests\/CollectionExtensionsTests.swift","column":2,"fileID":"GPXKitTests\/CollectionExtensionsTests.swift","line":11}},"version":0} 12 | ]] 13 | 14 | local result = sut.parse(input) 15 | 16 | ---@type SwiftTesting.TestRecord 17 | local actual = { 18 | kind = "test", 19 | payload = { 20 | id = "GPXKitTests.ArrayExtensionsTests", 21 | kind = "suite", 22 | name = "ArrayExtensionsTests", 23 | sourceLocation = { 24 | _filePath = "/Users/user/folder/GPXKit/Tests/GPXKitTests/CollectionExtensionsTests.swift", 25 | column = 2, 26 | fileID = "GPXKitTests/CollectionExtensionsTests.swift", 27 | line = 11, 28 | }, 29 | }, 30 | version = 0, 31 | } 32 | assert.are.same(actual, result) 33 | end) 34 | 35 | it("can parse test cases", function() 36 | local input = [[ 37 | {"kind":"test","payload":{"id":"GPXKitTests.GPXParserTests\/testTrackPointsDateWithFraction()\/GPXParserTests.swift:235:6","isParameterized":false,"kind":"function","name":"testTrackPointsDateWithFraction()","sourceLocation":{"_filePath":"\/Users\/user\/folder\/GPXKit\/Tests\/GPXKitTests\/GPXParserTests.swift","column":6,"fileID":"GPXKitTests\/GPXParserTests.swift","line":235}},"version":0} 38 | ]] 39 | 40 | local result = sut.parse(input) 41 | local actual = { 42 | kind = "test", 43 | version = 0, 44 | payload = { 45 | id = "GPXKitTests.GPXParserTests/testTrackPointsDateWithFraction()/GPXParserTests.swift:235:6", 46 | isParameterized = false, 47 | kind = "function", 48 | name = "testTrackPointsDateWithFraction()", 49 | sourceLocation = { 50 | _filePath = "/Users/user/folder/GPXKit/Tests/GPXKitTests/GPXParserTests.swift", 51 | column = 6, 52 | fileID = "GPXKitTests/GPXParserTests.swift", 53 | line = 235, 54 | }, 55 | }, 56 | } 57 | assert.are.same(actual, result) 58 | end) 59 | end) 60 | -------------------------------------------------------------------------------- /spec/plugin_spec.lua: -------------------------------------------------------------------------------- 1 | local Tree = require("neotest.types").Tree 2 | local lib = require("neotest.lib") 3 | local async = require("neotest.async") 4 | 5 | local function load_file(filename) 6 | local cwd = vim.fn.getcwd() 7 | local path = cwd .. "/" .. filename 8 | local file = io.open(path, "r") 9 | if not file then 10 | error("Could not open file: " .. path) 11 | end 12 | local content = file:read("*a") 13 | file:close() 14 | return content 15 | end 16 | 17 | describe("Swift testing adapter", function() 18 | local it = async.tests.it 19 | 20 | ---@param id string 21 | ---@param type neotest.PositionType 22 | ---@param name string 23 | ---@param path? string 24 | ---@return neotest.Tree 25 | local function given_tree(id, type, name, path) 26 | ---@type neotest.Position 27 | local pos = { 28 | id = id, 29 | type = type, 30 | name = name, 31 | path = path or "/neotest/client", 32 | range = { 0, 0, 0, 0 }, 33 | } 34 | ---@type neotest.Tree 35 | local tree = Tree.from_list({ pos }, function(p) 36 | return p.id 37 | end) 38 | return tree 39 | end 40 | 41 | ---@param code? integer 42 | ---@param output? string 43 | ---@return neotest.StrategyResult 44 | local function given_strategy_result(code, output) 45 | return { 46 | code = code or 0, 47 | output = output or "", 48 | } 49 | end 50 | 51 | ---@type table 52 | local files = {} 53 | ---@type table 54 | local files_exists = {} 55 | 56 | local function stub_files() 57 | local orig = lib.files.read 58 | 59 | lib.files.read = function(path) 60 | if files[path] ~= nil then 61 | return files[path] 62 | end 63 | return orig(path) 64 | end 65 | local orig_exists = lib.files.exists 66 | lib.files.exists = function(path) 67 | if files_exists[path] ~= nil then 68 | return files_exists[path] 69 | end 70 | return orig_exists(path) 71 | end 72 | end 73 | 74 | ---@type neotest.Adapter 75 | local sut 76 | setup(function() 77 | sut = require("neotest-swift-testing")({ log_level = vim.log.levels.OFF }) 78 | end) 79 | 80 | teardown(function() 81 | sut = nil 82 | end) 83 | 84 | ---@type table 85 | local stubbed_commands 86 | 87 | before_each(function() 88 | stubbed_commands = {} 89 | lib.process.run = function(cmd, opts) 90 | local key = table.concat(cmd, " ") 91 | assert.is_not_nil(stubbed_commands[key], "Expected to find\n" .. key .. "\nin stubbed commands") 92 | local p = stubbed_commands[key] 93 | if p then 94 | stubbed_commands[key] = nil 95 | return p.code, { stdout = p.result } 96 | end 97 | return -1, nil 98 | end 99 | 100 | ---@diagnostic disable-next-line: duplicate-set-field 101 | async.fn.tempname = function() 102 | return "/temporary/path/" 103 | end 104 | files = {} 105 | files_exists = {} 106 | end) 107 | 108 | after_each(function() 109 | assert.are.same({}, stubbed_commands, "Expected all stubbed commands to be invoked. Uninvoked commands:\n" .. vim.inspect(stubbed_commands)) 110 | files = {} 111 | files_exists = {} 112 | end) 113 | 114 | ---Stubs the result for a command. 115 | ---@param cmd string 116 | ---@param result string 117 | ---@param code? integer 118 | local function given(cmd, result, code) 119 | stubbed_commands[cmd] = { result = result, code = code or 0 } 120 | end 121 | 122 | ---Stubs content for lib.files.read 123 | ---@param path string 124 | ---@param content? string 125 | local function given_file(path, content) 126 | files[path] = content 127 | files_exists[path] = content ~= nil 128 | end 129 | 130 | it("Has a name", function() 131 | assert.is_equal("neotest-swift-testing", sut.name) 132 | end) 133 | 134 | it("Has a valid root function", function() 135 | local path = vim.fn.getcwd() .. "/spec/fixtures/Sources" 136 | local expected = vim.fn.getcwd() .. "/spec/fixtures" 137 | local actual = sut.root(path) 138 | 139 | assert.is_equal(expected, actual) 140 | end) 141 | 142 | it("Filters invalid directories", function() 143 | local root = vim.fn.getcwd() .. "/spec/fixtures/Sources" 144 | 145 | local invalid = { "Sources", "build", ".git", ".build", ".git", ".swiftpm" } 146 | 147 | for _, dir in ipairs(invalid) do 148 | local actual = sut.filter_dir(dir, "spec/fixtures/Sources", root) 149 | assert.is_false(actual) 150 | end 151 | end) 152 | 153 | it("Does not filters test directories", function() 154 | assert.is_true(sut.filter_dir("Tests", "spec/fixtures/", vim.fn.getcwd())) 155 | assert.is_false(sut.filter_dir("Sources", "spec/fixtures/", vim.fn.getcwd())) 156 | end) 157 | 158 | it("Accepts test files", function() 159 | for _, name in ipairs({ "Test.swift", "Tests/Test.swift", "FeatureTests.swift" }) do 160 | assert.is_true(sut.is_test_file(name), "expected " .. name .. " to be a test file") 161 | end 162 | end) 163 | 164 | it("Filters non test files", function() 165 | for _, name in ipairs({ 166 | "Source.swift", 167 | "Feature.swift", 168 | "main.swift", 169 | "main.c", 170 | "header.h", 171 | "objc.m", 172 | "Package.swift", 173 | "Makefile", 174 | }) do 175 | assert.is_false(sut.is_test_file(name), "expected " .. name .. " to be a test file") 176 | end 177 | end) 178 | 179 | describe("Build spec", function() 180 | before_each(function() 181 | sut.root = function(p) 182 | return "/project/root" 183 | end 184 | end) 185 | 186 | describe("Integrated strategy", function() 187 | ---@param filter string 188 | ---@return string[] 189 | local function expected_command(filter) 190 | return { 191 | "swift", 192 | "test", 193 | "--enable-swift-testing", 194 | "-c", 195 | "debug", 196 | "--xunit-output", 197 | "/temporary/path/junit.xml", 198 | "-q", 199 | "--filter", 200 | filter, 201 | } 202 | end 203 | it("Directory filter", function() 204 | ---@type neotest.RunArgs 205 | local args = { 206 | tree = given_tree( 207 | "/Users/name/project/Tests/ProjectTests/MyPackageTests.swift::className::testName", 208 | "dir", 209 | "folderName" 210 | ), 211 | strategy = "integrated", 212 | } 213 | local result = sut.build_spec(args) 214 | 215 | assert.are.same({ 216 | command = expected_command("folderName"), 217 | cwd = "/project/root", 218 | context = { 219 | results_path = "/temporary/path/junit-swift-testing.xml", 220 | }, 221 | }, result) 222 | end) 223 | 224 | it("Test filter", function() 225 | ---@type neotest.RunArgs 226 | local args = { 227 | tree = given_tree( 228 | "/Users/name/project/Tests/ProjectTests/MyPackageTests.swift::className::testName", 229 | "test", 230 | "testName()" 231 | ), 232 | strategy = "integrated", 233 | } 234 | local result = sut.build_spec(args) 235 | 236 | assert.are.same({ 237 | command = expected_command("className.testName"), 238 | cwd = "/project/root", 239 | context = { 240 | results_path = "/temporary/path/junit-swift-testing.xml", 241 | }, 242 | }, result) 243 | end) 244 | 245 | it("Namespace filter", function() 246 | ---@type neotest.RunArgs 247 | local args = { 248 | tree = given_tree( 249 | "/Users/name/project/Tests/ProjectTests/MyPackageTests.swift::className::testName", 250 | "namespace", 251 | "TestSuite" 252 | ), 253 | strategy = "integrated", 254 | } 255 | local result = sut.build_spec(args) 256 | 257 | assert.are.same({ 258 | command = expected_command(".TestSuite$"), 259 | cwd = "/project/root", 260 | context = { 261 | results_path = "/temporary/path/junit-swift-testing.xml", 262 | }, 263 | }, result) 264 | end) 265 | 266 | it("File filter", function() 267 | ---@type neotest.RunArgs 268 | local args = { 269 | tree = given_tree( 270 | "/Users/name/project/Tests/ProjectTests/MyPackageTests.swift::className::testName", 271 | "file", 272 | "filename" 273 | ), 274 | strategy = "integrated", 275 | } 276 | local result = sut.build_spec(args) 277 | 278 | assert.are.same({ 279 | command = expected_command("/filename"), 280 | cwd = "/project/root", 281 | context = { 282 | results_path = "/temporary/path/junit-swift-testing.xml", 283 | }, 284 | }, result) 285 | end) 286 | end) 287 | 288 | describe("DAP support", function() 289 | it("build spec when strategy is dap", function() 290 | given( 291 | "swift package --package-path /project/root describe --type json", 292 | load_file("spec/Fixtures/package_description.json") 293 | ) 294 | given("swift build --build-tests --enable-swift-testing -c debug", "") 295 | given("xcrun --show-sdk-platform-path", "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform") 296 | given("xcode-select -p", "/Applications/Xcode.App/Contents/Developer") 297 | given("fd swiftpm-testing-helper /Applications/Xcode.App/Contents/Developer", "/path/to/swiftpm-testing-helper") 298 | given("swift build --show-bin-path", "/Users/name/project/.build/arm-apple-macosx/debug") 299 | ---@type neotest.RunArgs 300 | local args = { 301 | tree = given_tree( 302 | "/project/Tests/ProjectTests/MyPackageTests.swift::className::testName", 303 | "dir", 304 | "folderName" 305 | ), 306 | strategy = "dap", 307 | } 308 | 309 | local result = sut.build_spec(args) 310 | 311 | assert.are.same({ 312 | context = { 313 | is_dap_active = true, 314 | position_id = "/project/Tests/ProjectTests/MyPackageTests.swift::className::testName", 315 | }, 316 | cwd = "/project/root", 317 | env = { 318 | DYLD_FRAMEWORK_PATH = "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks", 319 | }, 320 | }, result) 321 | end) 322 | end) 323 | end) 324 | 325 | describe("Results method", function() 326 | ---@type neotest.Tree 327 | local tree 328 | ---@type neotest.StrategyResult 329 | local strategy_result 330 | before_each(function() 331 | tree = given_tree("/project/Tests/ProjectTests/MyPackageTests.swift::className::testName", "test", "testName()") 332 | strategy_result = given_strategy_result(0, "/outputpath/log") 333 | end) 334 | it("Failed build", function() 335 | ---@type neotest.RunSpec 336 | local spec = { 337 | command = { "swift", "test" }, 338 | context = { 339 | position_id = "/project/Tests/ProjectTests/MyPackageTests.swift::className::testName", 340 | ---@type neotest.Error[] 341 | errors = { 342 | { 343 | message = "Build error", 344 | line = 42, 345 | }, 346 | }, 347 | }, 348 | } 349 | 350 | assert.are.same({ 351 | ["/project/Tests/ProjectTests/MyPackageTests.swift::className::testName"] = { 352 | status = "failed", 353 | errors = { 354 | { 355 | message = "Build error", 356 | line = 42, 357 | }, 358 | }, 359 | }, 360 | }, sut.results(spec, strategy_result, tree)) 361 | end) 362 | 363 | it("Skips dap results", function() 364 | ---@type neotest.RunSpec 365 | local spec = { 366 | command = { "swift", "test" }, 367 | context = { 368 | position_id = "/project/Tests/ProjectTests/MyPackageTests.swift::className::testName", 369 | is_dap_active = true, 370 | }, 371 | } 372 | 373 | assert.are.same({ 374 | ["/project/Tests/ProjectTests/MyPackageTests.swift::className::testName"] = { 375 | status = "skipped", 376 | }, 377 | }, sut.results(spec, strategy_result, tree)) 378 | end) 379 | 380 | it("Fails when result_path is not found", function() 381 | given_file("/temporary/path/junit-swift-testing.xml", nil) 382 | given_file("/outputpath/log", "Output errors") 383 | stub_files() 384 | local spec = { 385 | command = { "swift", "test" }, 386 | context = { 387 | results_path = "/temporary/path/junit-swift-testing.xml", 388 | position_id = "/project/Tests/ProjectTests/MyPackageTests.swift::className::testName", 389 | }, 390 | } 391 | 392 | assert.are.same({ 393 | ["/project/Tests/ProjectTests/MyPackageTests.swift::className::testName"] = { 394 | status = "failed", 395 | output = "/outputpath/log", 396 | short = "Output errors", 397 | }, 398 | }, sut.results(spec, strategy_result, tree)) 399 | end) 400 | 401 | it("Successful test run", function() 402 | local results = [[ 403 | 404 | 405 | 406 | 407 | 408 | 409 | ]] 410 | given_file("/temporary/path/junit-swift-testing.xml", results) 411 | given_file("/outputpath/log", "") 412 | stub_files() 413 | local spec = { 414 | cwd = "/project", 415 | command = { "swift", "test" }, 416 | context = { 417 | results_path = "/temporary/path/junit-swift-testing.xml", 418 | position_id = "/project/Tests/ProjectTests/MyPackageTests.swift::className::testName", 419 | }, 420 | } 421 | assert.are.same({ 422 | ["/project/Tests/ProjectTests/MyPackageTests.swift::className::testName"] = { 423 | status = "passed", 424 | }, 425 | }, sut.results(spec, given_strategy_result(0, "/outputpath/log"), tree)) 426 | end) 427 | end) 428 | 429 | describe("Test discovery", function() 430 | it("discovers tests", function() 431 | local path = vim.fn.getcwd() .. "/spec/fixtures/Tests/TargetTests/TargetTests.swift" 432 | local output = "/temporary/path/test-events.jsonl" 433 | local entry = [[ 434 | {"kind":"test","payload":{"id":"MyPackageTests.example()\/MyPackageTests.swift:4:2","isParameterized":false,"kind":"function","name":"example()","sourceLocation":{"_filePath":"\/Users\/user\/MyPackage\/Tests\/MyPackageTests\/MyPackageTests.swift","column":2,"fileID":"MyPackageTests\/MyPackageTests.swift","line":4}},"version":0} 435 | ]] 436 | -- given("swift test list --enable-swift-testing --event-stream-output-path " .. output, "", 0) 437 | -- given_file(output, entry) 438 | 439 | -- assert.are.same({}, sut.discover_positions(path)) 440 | end) 441 | end) 442 | end) 443 | --------------------------------------------------------------------------------