diff --git a/AGENTS.md b/AGENTS.md
index 287504a..0d206d7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -237,3 +237,11 @@ jobs:
- name: Run tests
run: bash run_test.sh
```
+
+## Zusaetzliche Runner-Guidelines (framework-agnostisch)
+
+- **Testnamen-Konvention:** Runner sollen eine konsistente, dokumentierte Full-Name-Bildung verwenden (z. B. `Parent/Subtest`), inklusive Mehrfach-Nesting. Diese Konvention muss in `results.*`, `parse_test_output` und `collect_failed_locations` uebereinstimmen.
+- **TSamNearest-Prioritaet:** Falls moeglich, gelten folgende Regeln: Test-Block > Describe/Context-Block > File-Command. Das Verhalten muss getestet werden (Cursor im Test, zwischen Tests, ausserhalb von Describe/Context).
+- **Reporter-Payload-Schema:** Wenn ein Custom-Reporter verwendet wird, soll dessen JSON-Payload dokumentiert und stabil sein (z. B. `{ name, status, file, location, output }`), damit Parser/Quickfix/Detail-Output konsistent bleiben.
+- **Failed-Only-Logik:** Failed-Only muss auf den letzten Fehlermeldungen basieren und nur die fehlerhaften Tests erneut ausfuehren. Die Pattern-Strategie (z. B. Titel-only vs. Full-Name) muss getestet werden.
+- **CI-Installations-Snippet:** Die Neovim-Installation in CI soll als „authoritative snippet“ behandelt werden und in Runner-Repos 1:1 uebernommen werden.
diff --git a/README.md b/README.md
index 5981fcc..81d27cc 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,7 @@ Main plugin: https://gitea.mschirmer.com/m13r/test-samurai.nvim
## Features
- Detects Jest test files (`*.test.*`, `*.spec.*`).
+- Activates only when the nearest `package.json` lists `jest` in dependencies or devDependencies.
- Finds nearest `test`/`it` within `describe` blocks.
- Builds `npx jest` commands for nearest, file, all, and failed-only runs.
- Streams results via a custom Jest reporter using `onTestCaseResult`.
diff --git a/lua/test-samurai-jest-runner/init.lua b/lua/test-samurai-jest-runner/init.lua
index a7e8a00..5aec668 100644
--- a/lua/test-samurai-jest-runner/init.lua
+++ b/lua/test-samurai-jest-runner/init.lua
@@ -38,6 +38,47 @@ local function find_root(path, markers)
return vim.fs.dirname(found[1])
end
+local function find_nearest_package_root(path)
+ if not path or path == "" then
+ return nil
+ end
+ local dir = vim.fs.dirname(path)
+ local prev = nil
+ while dir and dir ~= prev do
+ local candidate = dir .. "/package.json"
+ if vim.fn.filereadable(candidate) == 1 then
+ return dir
+ end
+ prev = dir
+ dir = vim.fs.dirname(dir)
+ end
+ return nil
+end
+
+local function read_package_json(root)
+ if not root then
+ return nil
+ end
+ local ok, lines = pcall(vim.fn.readfile, root .. "/package.json")
+ if not ok then
+ return nil
+ end
+ local ok_decode, data = pcall(vim.json.decode, table.concat(lines, "\n"))
+ if not ok_decode then
+ return nil
+ end
+ return data
+end
+
+local function has_jest_dependency(pkg)
+ if not pkg or type(pkg) ~= "table" then
+ return false
+ end
+ local deps = pkg.dependencies or {}
+ local dev_deps = pkg.devDependencies or {}
+ return deps.jest ~= nil or dev_deps.jest ~= nil
+end
+
local function count_char(line, ch)
local count = 0
for i = 1, #line do
@@ -346,7 +387,12 @@ end
function runner.is_test_file(bufnr)
local path = get_buf_path(bufnr)
- return test_file_path(path)
+ if not test_file_path(path) then
+ return false
+ end
+ local root = find_nearest_package_root(path)
+ local pkg = read_package_json(root)
+ return has_jest_dependency(pkg)
end
function runner.find_nearest(bufnr, row, _col)
@@ -354,7 +400,7 @@ function runner.find_nearest(bufnr, row, _col)
if not path or path == "" then
return nil, "no file name"
end
- if not test_file_path(path) then
+ if not runner.is_test_file(bufnr) then
return nil, "not a jest test file"
end
local lines = get_buf_lines(bufnr)
diff --git a/tests/test_jest_runner_spec.lua b/tests/test_jest_runner_spec.lua
index a401022..7882943 100644
--- a/tests/test_jest_runner_spec.lua
+++ b/tests/test_jest_runner_spec.lua
@@ -1,5 +1,37 @@
local runner = require("test-samurai-jest-runner")
+local function write_file(path, content)
+ local dir = vim.fn.fnamemodify(path, ":h")
+ vim.fn.mkdir(dir, "p")
+ vim.fn.writefile(vim.split(content, "\n"), path)
+end
+
+local function with_project(package_json, fn)
+ local root = vim.fn.tempname()
+ vim.fn.mkdir(root, "p")
+ root = vim.loop.fs_realpath(root) or root
+ if package_json then
+ write_file(root .. "/package.json", package_json)
+ end
+ fn(root)
+end
+
+local JEST_PACKAGE = [[
+{
+ "devDependencies": {
+ "jest": "^29.0.0"
+ }
+}
+]]
+
+local NO_JEST_PACKAGE = [[
+{
+ "devDependencies": {
+ "mocha": "^10.0.0"
+ }
+}
+]]
+
local function get_reporter_path()
local source = debug.getinfo(runner.build_command, "S").source
if source:sub(1, 1) == "@" then
@@ -10,157 +42,147 @@ local function get_reporter_path()
end
describe("test-samurai-jest-runner", function()
- it("detects Jest test files by suffix", function()
- local bufnr1 = vim.api.nvim_create_buf(false, true)
- vim.api.nvim_buf_set_name(bufnr1, "/tmp/example.test.js")
- assert.is_true(runner.is_test_file(bufnr1))
+ it("detects Jest test files by suffix and package.json", function()
+ with_project(JEST_PACKAGE, function(root)
+ local bufnr1 = vim.api.nvim_create_buf(false, true)
+ vim.api.nvim_buf_set_name(bufnr1, root .. "/example.test.js")
+ assert.is_true(runner.is_test_file(bufnr1))
- local bufnr2 = vim.api.nvim_create_buf(false, true)
- vim.api.nvim_buf_set_name(bufnr2, "/tmp/example.spec.tsx")
- assert.is_true(runner.is_test_file(bufnr2))
+ local bufnr2 = vim.api.nvim_create_buf(false, true)
+ vim.api.nvim_buf_set_name(bufnr2, root .. "/example.spec.tsx")
+ assert.is_true(runner.is_test_file(bufnr2))
- local bufnr3 = vim.api.nvim_create_buf(false, true)
- vim.api.nvim_buf_set_name(bufnr3, "/tmp/example.js")
- assert.is_false(runner.is_test_file(bufnr3))
+ local bufnr3 = vim.api.nvim_create_buf(false, true)
+ vim.api.nvim_buf_set_name(bufnr3, root .. "/example.js")
+ assert.is_false(runner.is_test_file(bufnr3))
+ end)
+ end)
+
+ it("rejects test files when jest dependency is missing", function()
+ with_project(NO_JEST_PACKAGE, function(root)
+ local bufnr = vim.api.nvim_create_buf(false, true)
+ vim.api.nvim_buf_set_name(bufnr, root .. "/example.test.js")
+ assert.is_false(runner.is_test_file(bufnr))
+ end)
end)
it("finds nearest test with describe hierarchy", function()
- local bufnr = vim.api.nvim_create_buf(false, true)
- vim.api.nvim_buf_set_name(bufnr, "/tmp/math.test.js")
- local lines = {
- "describe('Math', () => {",
- " test('adds', () => {",
- " expect(1 + 1).toBe(2)",
- " })",
- "})",
- }
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
+ with_project(JEST_PACKAGE, function(root)
+ local bufnr = vim.api.nvim_create_buf(false, true)
+ vim.api.nvim_buf_set_name(bufnr, root .. "/math.test.js")
+ local lines = {
+ "describe('Math', () => {",
+ " test('adds', () => {",
+ " expect(1 + 1).toBe(2)",
+ " })",
+ "})",
+ }
+ vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
- local orig_fs_find = vim.fs.find
- vim.fs.find = function(_, _)
- return { "/tmp/package.json" }
- end
+ local spec, err = runner.find_nearest(bufnr, 2, 0)
- local spec, err = runner.find_nearest(bufnr, 2, 0)
-
- vim.fs.find = orig_fs_find
-
- assert.is_nil(err)
- assert.equals("adds", spec.test_name)
- assert.equals("Math/adds", spec.full_name)
- assert.equals("Math adds", spec.jest_name)
- assert.are.same({ "Math", "adds" }, spec.jest_parts)
- assert.is_true(spec.file:match("math%.test%.js$") ~= nil)
- assert.equals("/tmp", spec.cwd)
+ assert.is_nil(err)
+ assert.equals("adds", spec.test_name)
+ assert.equals("Math/adds", spec.full_name)
+ assert.equals("Math adds", spec.jest_name)
+ assert.are.same({ "Math", "adds" }, spec.jest_parts)
+ assert.is_true(spec.file:match("math%.test%.js$") ~= nil)
+ assert.equals(root, spec.cwd)
+ end)
end)
it("handles multiline describe and it declarations", function()
- local bufnr = vim.api.nvim_create_buf(false, true)
- vim.api.nvim_buf_set_name(bufnr, "/tmp/multiline.test.js")
- local lines = {
- "describe(",
- " '',",
- " () => {",
- " describe(",
- " 'renders properly...',",
- " () => {",
- " it(",
- " 'the teaser links',",
- " async () => {",
- " expect(true).toBe(true)",
- " }",
- " )",
- " }",
- " )",
- " }",
- ")",
- }
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
+ with_project(JEST_PACKAGE, function(root)
+ local bufnr = vim.api.nvim_create_buf(false, true)
+ vim.api.nvim_buf_set_name(bufnr, root .. "/multiline.test.js")
+ local lines = {
+ "describe(",
+ " '',",
+ " () => {",
+ " describe(",
+ " 'renders properly...',",
+ " () => {",
+ " it(",
+ " 'the teaser links',",
+ " async () => {",
+ " expect(true).toBe(true)",
+ " }",
+ " )",
+ " }",
+ " )",
+ " }",
+ ")",
+ }
+ vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
- local orig_fs_find = vim.fs.find
- vim.fs.find = function(_, _)
- return { "/tmp/package.json" }
- end
+ local spec, err = runner.find_nearest(bufnr, 9, 0)
- local spec, err = runner.find_nearest(bufnr, 9, 0)
-
- vim.fs.find = orig_fs_find
-
- assert.is_nil(err)
- assert.equals("the teaser links", spec.test_name)
- assert.equals("/renders properly.../the teaser links", spec.full_name)
- assert.are.same({ "", "renders properly...", "the teaser links" }, spec.jest_parts)
+ assert.is_nil(err)
+ assert.equals("the teaser links", spec.test_name)
+ assert.equals("/renders properly.../the teaser links", spec.full_name)
+ assert.are.same({ "", "renders properly...", "the teaser links" }, spec.jest_parts)
+ end)
end)
it("uses describe block when cursor is between tests", function()
- local bufnr = vim.api.nvim_create_buf(false, true)
- vim.api.nvim_buf_set_name(bufnr, "/tmp/between.test.js")
- local lines = {
- "describe('', () => {",
- " describe('renders properly...', () => {",
- " it('the logo', async () => {",
- " expect(true).toBe(true)",
- " })",
- " ",
- " it('the teaser links', async () => {",
- " expect(true).toBe(true)",
- " })",
- " })",
- "})",
- }
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
+ with_project(JEST_PACKAGE, function(root)
+ local bufnr = vim.api.nvim_create_buf(false, true)
+ vim.api.nvim_buf_set_name(bufnr, root .. "/between.test.js")
+ local lines = {
+ "describe('', () => {",
+ " describe('renders properly...', () => {",
+ " it('the logo', async () => {",
+ " expect(true).toBe(true)",
+ " })",
+ " ",
+ " it('the teaser links', async () => {",
+ " expect(true).toBe(true)",
+ " })",
+ " })",
+ "})",
+ }
+ vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
- local orig_fs_find = vim.fs.find
- vim.fs.find = function(_, _)
- return { "/tmp/package.json" }
- end
+ local spec, err = runner.find_nearest(bufnr, 5, 0)
- local spec, err = runner.find_nearest(bufnr, 5, 0)
-
- vim.fs.find = orig_fs_find
-
- assert.is_nil(err)
- assert.equals("renders properly...", spec.test_name)
- assert.equals("/renders properly...", spec.full_name)
- assert.are.same({ "", "renders properly..." }, spec.jest_parts)
- assert.equals("describe", spec.kind)
+ assert.is_nil(err)
+ assert.equals("renders properly...", spec.test_name)
+ assert.equals("/renders properly...", spec.full_name)
+ assert.are.same({ "", "renders properly..." }, spec.jest_parts)
+ assert.equals("describe", spec.kind)
+ end)
end)
it("falls back to file command when outside any describe", function()
- local bufnr = vim.api.nvim_create_buf(false, true)
- vim.api.nvim_buf_set_name(bufnr, "/tmp/outside.test.js")
- local lines = {
- "const value = 1",
- "function helper() { return value }",
- }
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
+ with_project(JEST_PACKAGE, function(root)
+ local bufnr = vim.api.nvim_create_buf(false, true)
+ vim.api.nvim_buf_set_name(bufnr, root .. "/outside.test.js")
+ local lines = {
+ "const value = 1",
+ "function helper() { return value }",
+ }
+ vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
- local orig_fs_find = vim.fs.find
- vim.fs.find = function(_, _)
- return { "/tmp/package.json" }
- end
+ local spec, err = runner.find_nearest(bufnr, 1, 0)
+ assert.is_nil(err)
+ assert.equals("file", spec.kind)
- local spec, err = runner.find_nearest(bufnr, 1, 0)
- assert.is_nil(err)
- assert.equals("file", spec.kind)
+ local cmd_spec = runner.build_command(spec)
- local cmd_spec = runner.build_command(spec)
-
- vim.fs.find = orig_fs_find
-
- assert.are.same(
- {
- "npx",
- "jest",
- "--testLocationInResults",
- "--reporters",
- get_reporter_path(),
- "--runTestsByPath",
- cmd_spec.cmd[#cmd_spec.cmd],
- },
- cmd_spec.cmd
- )
- assert.is_true(cmd_spec.cmd[#cmd_spec.cmd]:match("outside%.test%.js$") ~= nil)
+ assert.are.same(
+ {
+ "npx",
+ "jest",
+ "--testLocationInResults",
+ "--reporters",
+ get_reporter_path(),
+ "--runTestsByPath",
+ cmd_spec.cmd[#cmd_spec.cmd],
+ },
+ cmd_spec.cmd
+ )
+ assert.is_true(cmd_spec.cmd[#cmd_spec.cmd]:match("outside%.test%.js$") ~= nil)
+ end)
end)
it("build_command uses npx jest with reporter and pattern", function()
@@ -218,57 +240,47 @@ describe("test-samurai-jest-runner", function()
end)
it("build_file_command scopes to file", function()
- local bufnr = vim.api.nvim_create_buf(false, true)
- vim.api.nvim_buf_set_name(bufnr, "/tmp/project/foo.test.ts")
+ with_project(JEST_PACKAGE, function(root)
+ local bufnr = vim.api.nvim_create_buf(false, true)
+ vim.api.nvim_buf_set_name(bufnr, root .. "/foo.test.ts")
- local orig_fs_find = vim.fs.find
- vim.fs.find = function(_, _)
- return { "/tmp/project/package.json" }
- end
+ local cmd_spec = runner.build_file_command(bufnr)
- local cmd_spec = runner.build_file_command(bufnr)
-
- vim.fs.find = orig_fs_find
-
- assert.are.same(
- {
- "npx",
- "jest",
- "--testLocationInResults",
- "--reporters",
- get_reporter_path(),
- "--runTestsByPath",
- "/tmp/project/foo.test.ts",
- },
- cmd_spec.cmd
- )
- assert.equals("/tmp/project", cmd_spec.cwd)
+ assert.are.same(
+ {
+ "npx",
+ "jest",
+ "--testLocationInResults",
+ "--reporters",
+ get_reporter_path(),
+ "--runTestsByPath",
+ root .. "/foo.test.ts",
+ },
+ cmd_spec.cmd
+ )
+ assert.equals(root, cmd_spec.cwd)
+ end)
end)
it("build_all_command runs project tests", function()
- local bufnr = vim.api.nvim_create_buf(false, true)
- vim.api.nvim_buf_set_name(bufnr, "/tmp/project/bar.test.ts")
+ with_project(JEST_PACKAGE, function(root)
+ local bufnr = vim.api.nvim_create_buf(false, true)
+ vim.api.nvim_buf_set_name(bufnr, root .. "/bar.test.ts")
- local orig_fs_find = vim.fs.find
- vim.fs.find = function(_, _)
- return { "/tmp/project/package.json" }
- end
+ local cmd_spec = runner.build_all_command(bufnr)
- local cmd_spec = runner.build_all_command(bufnr)
-
- vim.fs.find = orig_fs_find
-
- assert.are.same(
- {
- "npx",
- "jest",
- "--testLocationInResults",
- "--reporters",
- get_reporter_path(),
- },
- cmd_spec.cmd
- )
- assert.equals("/tmp/project", cmd_spec.cwd)
+ assert.are.same(
+ {
+ "npx",
+ "jest",
+ "--testLocationInResults",
+ "--reporters",
+ get_reporter_path(),
+ },
+ cmd_spec.cmd
+ )
+ assert.equals(root, cmd_spec.cwd)
+ end)
end)
it("build_failed_command narrows to failed tests", function()