diff --git a/README.md b/README.md index 8b97d5b..2576414 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,34 @@ or add a specific keymap to run tests with watch mode: vim.api.nvim_set_keymap("n", "tw", "lua require('neotest').run.run({ jestCommand = 'jest --watch ' })", {}) ``` +### Parameterized tests + +If you want to allow to `neotest-jest` to discover parameterized tests you need to enable flag +`jest_test_discovery` in config setup: +```lua +require('neotest').setup({ + ..., + adapters = { + require('neotest-jest')({ + ..., + jest_test_discovery = false, + }), + } +}) +``` +Its also recommended to disable `neotest` `discovery` option like this: +```lua +require("neotest").setup({ + ..., + discovery = { + enabled = false, + }, +}) +``` +because `jest_test_discovery` runs `jest` command on file to determine +what tests are inside the file. If `discovery` would be enabled then `neotest-jest` +would spawn a lot of procesees. + ### Monorepos If you have a monorepo setup, you might have to do a little more configuration, especially if you have different jest configurations per package. diff --git a/lua/neotest-jest/init.lua b/lua/neotest-jest/init.lua index 83864c5..7bfd629 100644 --- a/lua/neotest-jest/init.lua +++ b/lua/neotest-jest/init.lua @@ -3,6 +3,8 @@ local async = require("neotest.async") local lib = require("neotest.lib") local logger = require("neotest.logging") local util = require("neotest-jest.util") +local jest_util = require("neotest-jest.jest-util") +local parameterized_tests = require("neotest-jest.parameterized-tests") ---@class neotest.JestOptions ---@field jestCommand? string|fun(): string @@ -47,7 +49,6 @@ local function rootProjectHasJestDependency() return true end - ---@param path string ---@return boolean local function hasJestDependency(path) @@ -88,6 +89,9 @@ adapter.root = function(path) return lib.files.match_root_pattern("package.json")(path) end +local getJestCommand = jest_util.getJestCommand +local getJestConfig = jest_util.getJestConfig + ---@param file_path? string ---@return boolean function adapter.is_test_file(file_path) @@ -116,6 +120,35 @@ function adapter.filter_dir(name) return name ~= "node_modules" end +local function get_match_type(captured_nodes) + if captured_nodes["test.name"] then + return "test" + end + if captured_nodes["namespace.name"] then + return "namespace" + end +end + +-- Enrich `it.each` tests with metadata about TS node position +function adapter.build_position(file_path, source, captured_nodes) + local match_type = get_match_type(captured_nodes) + if not match_type then + return + end + + ---@type string + local name = vim.treesitter.get_node_text(captured_nodes[match_type .. ".name"], source) + local definition = captured_nodes[match_type .. ".definition"] + + return { + type = match_type, + path = file_path, + name = name, + range = { definition:range() }, + is_parameterized = captured_nodes["each_property"] and true or false, + } +end + ---@async ---@return neotest.Tree | nil function adapter.discover_positions(path) @@ -182,79 +215,45 @@ function adapter.discover_positions(path) function: (call_expression function: (member_expression object: (identifier) @func_name (#any-of? @func_name "it" "test") + property: (property_identifier) @each_property (#eq? @each_property "each") ) ) arguments: (arguments (string (string_fragment) @test.name) [(arrow_function) (function)]) )) @test.definition ]] - return lib.treesitter.parse_positions(path, query, { nested_tests = true }) -end - ----@param path string ----@return string -local function getJestCommand(path) - local gitAncestor = util.find_git_ancestor(path) - - local function findBinary(p) - local rootPath = util.find_node_modules_ancestor(p) - local jestBinary = util.path.join(rootPath, "node_modules", ".bin", "jest") - - if util.path.exists(jestBinary) then - return jestBinary - end - - -- If no binary found and the current directory isn't the parent - -- git ancestor, let's traverse up the tree again - if rootPath ~= gitAncestor then - return findBinary(util.path.dirname(rootPath)) - end - end - - local foundBinary = findBinary(path) - - if foundBinary then - return foundBinary - end - - return "jest" -end - -local jestConfigPattern = util.root_pattern("jest.config.{js,ts}") - ----@param path string ----@return string|nil -local function getJestConfig(path) - local rootPath = jestConfigPattern(path) - - if not rootPath then - return nil - end + local positions = lib.treesitter.parse_positions(path, query, { + nested_tests = false, + build_position = 'require("neotest-jest").build_position', + }) - local jestJs = util.path.join(rootPath, "jest.config.js") - local jestTs = util.path.join(rootPath, "jest.config.ts") + local parameterized_tests_positions = + parameterized_tests.get_parameterized_tests_positions(positions) - if util.path.exists(jestTs) then - return jestTs + if adapter.jest_test_discovery and #parameterized_tests_positions > 0 then + parameterized_tests.enrich_positions_with_parameterized_tests( + positions:data().path, + parameterized_tests_positions + ) end - return jestJs + return positions end local function escapeTestPattern(s) return ( s:gsub("%(", "%\\(") - :gsub("%)", "%\\)") - :gsub("%]", "%\\]") - :gsub("%[", "%\\[") - :gsub("%*", "%\\*") - :gsub("%+", "%\\+") - :gsub("%-", "%\\-") - :gsub("%?", "%\\?") - :gsub("%$", "%\\$") - :gsub("%^", "%\\^") - :gsub("%/", "%\\/") - :gsub("%'", "%\\'") + :gsub("%)", "%\\)") + :gsub("%]", "%\\]") + :gsub("%[", "%\\[") + :gsub("%*", "%\\*") + :gsub("%+", "%\\+") + :gsub("%-", "%\\-") + :gsub("%?", "%\\?") + :gsub("%$", "%\\$") + :gsub("%^", "%\\^") + :gsub("%/", "%\\/") + :gsub("%'", "%\\'") ) end @@ -295,10 +294,10 @@ end local function cleanAnsi(s) return s:gsub("\x1b%[%d+;%d+;%d+;%d+;%d+m", "") - :gsub("\x1b%[%d+;%d+;%d+;%d+m", "") - :gsub("\x1b%[%d+;%d+;%d+m", "") - :gsub("\x1b%[%d+;%d+m", "") - :gsub("\x1b%[%d+m", "") + :gsub("\x1b%[%d+;%d+;%d+;%d+m", "") + :gsub("\x1b%[%d+;%d+;%d+m", "") + :gsub("\x1b%[%d+;%d+m", "") + :gsub("\x1b%[%d+m", "") end local function findErrorPosition(file, errStr) @@ -383,7 +382,11 @@ function adapter.build_spec(args) -- pos.id in form "path/to/file::Describe text::test text" local testName = string.sub(pos.id, string.find(pos.id, "::") + 2) testName, _ = string.gsub(testName, "::", " ") - testNamePattern = "'^" .. escapeTestPattern(testName) + testNamePattern = escapeTestPattern(testName) + testNamePattern = pos.is_parameterized + and parameterized_tests.replaceTestParametersWithRegex(testNamePattern) + or testNamePattern + testNamePattern = "'^" .. testNamePattern if pos.type == "test" then testNamePattern = testNamePattern .. "$'" else @@ -391,7 +394,7 @@ function adapter.build_spec(args) end end - local binary = getJestCommand(pos.path) + local binary = args.jestCommand or getJestCommand(pos.path) local config = getJestConfig(pos.path) or "jest.config.js" local command = vim.split(binary, "%s+") if util.path.exists(config) then @@ -512,6 +515,11 @@ setmetatable(adapter, { return opts.strategy_config end end + + if opts.jest_test_discovery then + adapter.jest_test_discovery = true + end + return adapter end, }) diff --git a/lua/neotest-jest/jest-util.lua b/lua/neotest-jest/jest-util.lua new file mode 100644 index 0000000..0bfac3a --- /dev/null +++ b/lua/neotest-jest/jest-util.lua @@ -0,0 +1,78 @@ +local util = require("neotest-jest.util") + +local M = {} + +function M.is_callable(obj) + return type(obj) == "function" or (type(obj) == "table" and obj.__call) +end + +-- Returns jest binary from `node_modules` if that binary exists and `jest` otherwise. +---@param path string +---@return string +function M.getJestCommand(path) + local gitAncestor = util.find_git_ancestor(path) + + local function findBinary(p) + local rootPath = util.find_node_modules_ancestor(p) + local jestBinary = util.path.join(rootPath, "node_modules", ".bin", "jest") + + if util.path.exists(jestBinary) then + return jestBinary + end + + -- If no binary found and the current directory isn't the parent + -- git ancestor, let's traverse up the tree again + if rootPath ~= gitAncestor then + return findBinary(util.path.dirname(rootPath)) + end + end + + local foundBinary = findBinary(path) + + if foundBinary then + return foundBinary + end + + return "jest" +end + +local jestConfigPattern = util.root_pattern("jest.config.{js,ts}") + +-- Returns jest config file path if it exists. +---@param path string +---@return string|nil +function M.getJestConfig(path) + local rootPath = jestConfigPattern(path) + + if not rootPath then + return nil + end + + local jestJs = util.path.join(rootPath, "jest.config.js") + local jestTs = util.path.join(rootPath, "jest.config.ts") + + if util.path.exists(jestTs) then + return jestTs + end + + return jestJs +end + +-- Returns neotest test id from jest test result. +-- @param testFile string +-- @param assertionResult table +-- @return string +function M.get_test_full_id_from_test_result(testFile, assertionResult) + local keyid = testFile + local name = assertionResult.title + + for _, value in ipairs(assertionResult.ancestorTitles) do + keyid = keyid .. "::" .. value + end + + keyid = keyid .. "::" .. name + + return keyid +end + +return M diff --git a/lua/neotest-jest/parameterized-tests.lua b/lua/neotest-jest/parameterized-tests.lua new file mode 100644 index 0000000..7210a4a --- /dev/null +++ b/lua/neotest-jest/parameterized-tests.lua @@ -0,0 +1,145 @@ +local lib = require("neotest.lib") +local util = require("neotest-jest.util") +local jest_util = require("neotest-jest.jest-util") + +local M = {} + +-- Traverses through whole Tree and returns all parameterized tests positions. +-- All parameterized test positions should have `is_parameterized` property on it. +-- @param positions neotest.Tree +-- @return neotest.Tree[] +function M.get_parameterized_tests_positions(positions) + local parameterized_tests_positions = {} + + for _, value in positions:iter_nodes() do + local data = value:data() + + if data.type == "test" and data.is_parameterized == true then + parameterized_tests_positions[#parameterized_tests_positions + 1] = value + end + end + + return parameterized_tests_positions +end + +-- Synchronously runs `jest` in `file_path` directory skipping all tests and returns `jest` output. +-- Output have all of the test names inside it. It skips all tests by adding +-- extra `--testPathPattern` parameter to jest command with placeholder string that should never exist. +-- @param file_path string - path to file to search for tests +-- @return table - parsed jest test results +local function run_jest_test_discovery(file_path) + local binary = jest_util.getJestCommand(file_path) + local command = vim.split(binary, "%s+") + + vim.list_extend(command, { + "--no-coverage", + "--testLocationInResults", + "--verbose", + "--json", + file_path, + "-t", + "@______________PLACEHOLDER______________@", + }) + + local result = { lib.process.run(command, { stdout = true }) } + + if not result[2] then + return nil + end + + local jest_json_string = result[2].stdout + + if not jest_json_string or #jest_json_string == 0 then + return nil + end + + return vim.json.decode(jest_json_string, { luanil = { object = true } }) +end + +-- Searches through whole `jest` command output and returns array of all tests at given `position`. +-- @param jest_output table +-- @param position number[] +-- @return { keyid: string, name: string }[] +local function get_tests_ids_at_position(jest_output, position) + local test_ids_at_position = {} + for _, testResult in pairs(jest_output.testResults) do + local testFile = testResult.name + + for _, assertionResult in pairs(testResult.assertionResults) do + local location, name = assertionResult.location, assertionResult.title + + if position[1] <= location.line - 1 and position[3] >= location.line - 1 then + local keyid = jest_util.get_test_full_id_from_test_result(testFile, assertionResult) + + test_ids_at_position[#test_ids_at_position + 1] = { keyid = keyid, name = name } + end + end + end + + return test_ids_at_position +end + +-- First runs `jest` in `file_path` to get all of the tests in the file. Then it takes all of +-- the parameterized tests and finds tests that were in the same position as parameterized test +-- and adds new tests (with range=nil) to the parameterized test. +-- @param file_path string +-- @param each_tests_positions neotest.Tree[] +function M.enrich_positions_with_parameterized_tests( + file_path, + parsed_parameterized_tests_positions +) + local jest_test_discovery_output = run_jest_test_discovery(file_path) + + if jest_test_discovery_output == nil then + return + end + + for _, value in pairs(parsed_parameterized_tests_positions) do + local data = value:data() + + local parameterized_test_results_for_position = + get_tests_ids_at_position(jest_test_discovery_output, data.range) + + for _, test_result in ipairs(parameterized_test_results_for_position) do + local new_data = { + id = test_result.keyid, + name = test_result.name, + path = data.path, + } + new_data.range = nil + + local new_pos = value:new(new_data, {}, value._key, {}, {}) + value:add_child(new_data.id, new_pos) + end + end +end + +local JEST_PARAMETER_TYPES = { + "%%p", + "%%s", + "%%d", + "%%i", + "%%f", + "%%j", + "%%o", + "%%#", + "%%%%", +} + +-- Replaces all of the jest parameters (named and unnamed) with `.*` regex pattern. +-- It allows to run all of the parameterized tests in a single run. Idea inpired by Webstorm jest plugin. +-- @param test_name string - test name with escaped characters +-- @returns string +function M.replaceTestParametersWithRegex(test_name) + -- replace named parameters: named characters can be single word (like $parameterName) + -- or field access words (like $parameterName.fieldName) + local result = test_name:gsub("\\$[%a%.]+", ".*") + + for _, parameter in ipairs(JEST_PARAMETER_TYPES) do + result = result:gsub(parameter, ".*") + end + + return result +end + +return M diff --git a/spec/parameterized.test.ts b/spec/parameterized.test.ts new file mode 100644 index 0000000..6a62618 --- /dev/null +++ b/spec/parameterized.test.ts @@ -0,0 +1,34 @@ +describe("describe text", () => { + it.each(["string"])("test with percent %%", () => { + console.log("do test"); + }); + + it.each(["string"])( + "test with all of the parameters %p %s %d %i %f %j %o %# %% %p %s %d %i %f %j %o %# %%", + async () => { + console.log("do test"); + } + ); + + it.each(["string"])("test with $namedParameter", () => { + console.log("do test"); + }); + + it.each(["string"])( + "test with $namedParameter and $anotherNamedParameter", + async () => { + console.log("do test"); + } + ); + + it.each(["string"])("test with $variable.field.otherField", async () => { + console.log("do test"); + }); + + it.each(["string"])( + "test with $variable.field.otherField and (parenthesis)", + async () => { + console.log("do test"); + } + ); +}); diff --git a/tests/init_spec.lua b/tests/init_spec.lua index 5cdd657..c467b51 100644 --- a/tests/init_spec.lua +++ b/tests/init_spec.lua @@ -1,3 +1,4 @@ +local stub = require("luassert.stub") local async = require("nio").tests local plugin = require("neotest-jest")({ jestCommand = "jest", @@ -93,6 +94,19 @@ describe("discover_positions", function() assert.equals(expected_output[1].type, positions[1].type) assert.equals(expected_output[2][1].name, positions[2][1].name) assert.equals(expected_output[2][1].type, positions[2][1].type) + + assert.equals(positions[2][1].is_parameterized, false) + + assert.equals(5, #positions[2]) + for i, value in ipairs(expected_output[2][2]) do + assert.is.truthy(value) + local position = positions[2][i + 1][1] + assert.is.truthy(position) + assert.equals(value.name, position.name) + assert.equals(value.type, position.type) + assert.equals(position.is_parameterized, false) + end + assert.equals(expected_output[3][1].name, positions[3][1].name) assert.equals(expected_output[3][1].type, positions[3][1].type) @@ -104,34 +118,40 @@ describe("discover_positions", function() end) async.it("provides meaningful names for array driven tests", function() + stub(require("neotest.lib").process, "run") local positions = plugin.discover_positions("./spec/array.test.ts"):to_list() local expected_output = { { name = "array.test.ts", type = "file", + is_parameterized = false, }, { { name = "describe text", type = "namespace", + is_parameterized = false, }, { { name = "Array1", type = "test", + is_parameterized = true, }, }, { { name = "Array2", type = "test", + is_parameterized = true, }, }, { { name = "Array3", type = "test", + is_parameterized = true, }, }, { @@ -168,6 +188,7 @@ describe("discover_positions", function() { name = "Array4", type = "test", + is_parameterized = true, }, }, }, @@ -177,6 +198,16 @@ describe("discover_positions", function() assert.equals(expected_output[2][1].name, positions[2][1].name) assert.equals(expected_output[2][1].type, positions[2][1].type) + assert.equals(expected_output[2][1].is_parameterized, positions[2][1].is_parameterized) + for i, value in ipairs(expected_output[2][2]) do + assert.is.truthy(value) + local position = positions[2][i + 1][1] + assert.is.truthy(position) + assert.equals(value.name, position.name) + assert.equals(value.type, position.type) + assert.equals(value.is_parameterized, position.is_parameterized) + end + assert.equals(5, #positions[2]) assert_test_positions_match(expected_output[2][2], positions[2]) @@ -267,4 +298,32 @@ describe("build_spec", function() assert.is.truthy(spec.context.file) assert.is.truthy(spec.context.results_path) end) + + describe("parameterized test names", function() + for _, test_data in ipairs({ + { index = 1, expected_name = "^describe text test with percent .*$" }, + { + index = 2, + expected_name = "^describe text test with all of the parameters .* .* .* .* .* .* .* .* .* .* .* .* .* .* .* .* .* .*$", + }, + { index = 3, expected_name = "^describe text test with .*$" }, + { index = 4, expected_name = "^describe text test with .* and .*$" }, + { index = 5, expected_name = "^describe text test with .*$" }, + { index = 6, expected_name = "^describe text test with .* and \\(parenthesis\\)$" }, + }) do + async.it("builds command with correct test name pattern " .. test_data.index, function() + -- mock neotest process run to not run jest test discovery + stub(require("neotest.lib").process, "run") + + local positions = plugin.discover_positions("./spec/parameterized.test.ts"):to_list() + + local tree = Tree.from_list(positions, function(pos) + return pos.id + end) + + local spec = plugin.build_spec({ tree = tree:children()[1]:children()[test_data.index] }) + assert.contains(spec.command, "--testNamePattern='" .. test_data.expected_name .. "'") + end) + end + end) end)