local util = require("test-samurai.util") local M = {} local default_filetypes = { javascript = true, javascriptreact = true, typescript = true, typescriptreact = true, } local default_patterns = { ".test.", ".spec." } local function is_js_test_file(bufnr, filetypes, patterns) local ft = vim.bo[bufnr].filetype if not filetypes[ft] then return false end local path = util.get_buf_path(bufnr) if not path or path == "" then return false end for _, pat in ipairs(patterns) do if path:find(pat, 1, true) then return true end end return false end local function find_block_end(lines, start_idx) local depth = 0 local started = false for i = start_idx, #lines do local line = lines[i] for j = 1, #line do local ch = line:sub(j, j) if ch == "{" then depth = depth + 1 started = true elseif ch == "}" then if started then depth = depth - 1 if depth == 0 then return i - 1 end end end end end return #lines - 1 end local jest_config_files = { "jest.config.js", "jest.config.ts" } local function find_jest_root(file) if not file or file == "" then return util.find_root(file, { "jest.config.js", "jest.config.ts", "vitest.config.ts", "vitest.config.js", "package.json", "node_modules", }) end local dir = vim.fs.dirname(file) local found = vim.fs.find(jest_config_files, { path = dir, upward = true }) if found and #found > 0 then local cfg = found[1] local sep = package.config:sub(1, 1) local marker = sep .. "test" .. sep .. ".bin" .. sep local idx = cfg:find(marker, 1, true) if idx then local root = cfg:sub(1, idx - 1) if root == "" then root = sep end return root end return vim.fs.dirname(cfg) end return util.find_root(file, { "jest.config.js", "jest.config.ts", "vitest.config.ts", "vitest.config.js", "package.json", "node_modules", }) end local function match_test_call(line) local call, name = line:match("^%s*(it)%s*%(%s*['\"`]([^'\"`]+)['\"`]") if call then return call, name end call, name = line:match("^%s*(test)%s*%(%s*['\"`]([^'\"`]+)['\"`]") if call then return call, name end call, name = line:match("^%s*(describe)%s*%(%s*['\"`]([^'\"`]+)['\"`]") if call and name then return call, name end return nil, nil end local function collect_js_structs(lines) local describes = {} local tests = {} for i, line in ipairs(lines) do local dcall, dname = line:match("^%s*(describe)%s*%(%s*['\"`]([^'\"`]+)['\"`]") if dcall and dname then local start0 = i - 1 local end0 = find_block_end(lines, i) table.insert(describes, { kind = dcall, name = dname, start = start0, ["end"] = end0, }) else local tcall, tname = line:match("^%s*(it)%s*%(%s*['\"`]([^'\"`]+)['\"`]") if not tcall then tcall, tname = line:match("^%s*(test)%s*%(%s*['\"`]([^'\"`]+)['\"`]") end if tcall and tname then local start0 = i - 1 local end0 = find_block_end(lines, i) table.insert(tests, { kind = tcall, name = tname, start = start0, ["end"] = end0, }) end end end return describes, tests end local function build_full_name(lines, idx, leaf_name) local parts = { leaf_name } for i = idx - 1, 1, -1 do local line = lines[i] local call, name = line:match("^%s*(describe)%s*%(%s*['\"`]([^'\"`]+)['\"`]") if call and name then table.insert(parts, 1, name) end end return table.concat(parts, " ") end local function find_nearest_test(bufnr, row) local lines = util.get_buf_lines(bufnr) local describes, tests = collect_js_structs(lines) for _, t in ipairs(tests) do if row >= t.start and row <= t["end"] then local full = build_full_name(lines, t.start + 1, t.name) return { kind = t.kind, name = t.name, full_name = full, line = t.start, } end end local best_describe = nil for _, d in ipairs(describes) do if row >= d.start and row <= d["end"] then if not best_describe or d.start >= best_describe.start then best_describe = d end end end if best_describe then local full = build_full_name(lines, best_describe.start + 1, best_describe.name) return { kind = "describe", name = best_describe.name, full_name = full, line = best_describe.start, } end local start = row + 1 if start > #lines then start = #lines elseif start < 1 then start = 1 end for i = start, 1, -1 do local line = lines[i] local call, name = match_test_call(line) if call and name then local full = build_full_name(lines, i, name) return { kind = call, name = name, full_name = full, line = i - 1, } end end return nil end function M.new(opts) local cfg = opts or {} local runner = {} runner.name = cfg.name or "js" runner.framework = cfg.framework or "jest" runner.command = cfg.command or { "npx", runner.framework } runner.all_glob = cfg.all_glob runner.filetypes = {} if cfg.filetypes then for _, ft in ipairs(cfg.filetypes) do runner.filetypes[ft] = true end else runner.filetypes = vim.deepcopy(default_filetypes) end runner.patterns = cfg.patterns or default_patterns function runner.is_test_file(bufnr) return is_js_test_file(bufnr, runner.filetypes, runner.patterns) end function runner.find_nearest(bufnr, row, _col) if not runner.is_test_file(bufnr) then return nil, "not a JS/TS test file" end local hit = find_nearest_test(bufnr, row) if not hit then return nil, "no test call found" end local path = util.get_buf_path(bufnr) local root if runner.framework == "jest" then root = find_jest_root(path) else root = util.find_root(path, { "jest.config.js", "jest.config.ts", "vitest.config.ts", "vitest.config.js", "package.json", "node_modules", }) end return { file = path, cwd = root, framework = runner.framework, test_name = hit.name, full_name = hit.full_name, kind = hit.kind, } end local function build_jest(spec) local cmd = vim.deepcopy(runner.command) table.insert(cmd, spec.file) table.insert(cmd, "-t") table.insert(cmd, spec.test_name) return cmd end local function build_mocha(spec) local cmd = vim.deepcopy(runner.command) local target = spec.full_name or spec.test_name table.insert(cmd, "--fgrep") table.insert(cmd, target) table.insert(cmd, spec.file) return cmd end local function build_vitest(spec) local cmd = vim.deepcopy(runner.command) table.insert(cmd, spec.file) table.insert(cmd, "-t") table.insert(cmd, spec.test_name) return cmd end function runner.build_command(spec) local fw = runner.framework local cmd if fw == "jest" then cmd = build_jest(spec) elseif fw == "mocha" then cmd = build_mocha(spec) elseif fw == "vitest" then cmd = build_vitest(spec) else cmd = vim.deepcopy(runner.command) table.insert(cmd, spec.file) end return { cmd = cmd, cwd = spec.cwd, } end function runner.build_file_command(bufnr) local path = util.get_buf_path(bufnr) if not path or path == "" then return nil end local root if runner.framework == "jest" then root = find_jest_root(path) else root = util.find_root(path, { "jest.config.js", "jest.config.ts", "vitest.config.ts", "vitest.config.js", "package.json", "node_modules", }) end local cmd = vim.deepcopy(runner.command) table.insert(cmd, path) return { cmd = cmd, cwd = root, } end function runner.build_all_command(bufnr) local path = util.get_buf_path(bufnr) local root if path and path ~= "" then if runner.framework == "jest" then root = find_jest_root(path) else root = util.find_root(path, { "jest.config.js", "jest.config.ts", "vitest.config.ts", "vitest.config.js", "package.json", "node_modules", }) end end if not root or root == "" then root = vim.loop.cwd() end local cmd = vim.deepcopy(runner.command) if runner.framework == "mocha" and runner.all_glob then table.insert(cmd, runner.all_glob) end return { cmd = cmd, cwd = root, } end return runner end return M