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()