Compare commits

...

14 Commits

Author SHA1 Message Date
456a157549 mark listing entry when details are visible
All checks were successful
tests / test (push) Successful in 10s
2026-01-20 09:59:21 +01:00
8c598002e4 show automatically the details after keymap navigating to a failed test entry 2026-01-17 16:04:42 +01:00
bd6930adc0 add new keypmap for quick selecting first entry 2026-01-17 15:44:47 +01:00
24bd89a7be save and restore current cursor position 2026-01-17 15:01:33 +01:00
fd3952bedf fix missing summary after TSamLast call
All checks were successful
tests / test (push) Successful in 9s
2026-01-09 16:24:55 +01:00
238d5f9634 show always detail-float on <cr>
All checks were successful
tests / test (push) Successful in 8s
2026-01-08 13:47:45 +01:00
118f84c31e add convenient keymaps for test-command inspection 2026-01-08 13:21:01 +01:00
924584d8b3 add rerun function from within the listing-float
All checks were successful
tests / test (push) Successful in 8s
2026-01-07 20:11:53 +01:00
5d0b4e9dd6 add keymaps for quick filtering the test-listings
All checks were successful
tests / test (push) Successful in 10s
2026-01-07 17:58:53 +01:00
c538a32307 add documentation for common neovim help 2026-01-07 17:34:02 +01:00
e9a7d2029b add quick help within detail-float 2026-01-07 17:22:50 +01:00
4c2d585f2d update AI helper for runner creations 2026-01-07 17:04:01 +01:00
e315a8e8f2 update ai prompt for external runner creation
All checks were successful
tests / test (push) Successful in 8s
2026-01-03 15:20:01 +01:00
1d9b682a58 fix quickfix-list filling for TSamFailedOnly 2026-01-03 15:19:35 +01:00
7 changed files with 2064 additions and 31 deletions

View File

@@ -11,6 +11,9 @@
- **Keine stillen Änderungen**:
- Bestehende Features dürfen nicht unbemerkt geändert oder ersetzt werden.
- Notwendige Anpassungen zur Koexistenz mehrerer Features müssen klar erkennbar sein.
- **Hilfe-Ansicht aktuell halten**:
- Änderungen und/oder Erweiterungen müssen **immer** die `:help`-Dokumentation, die README.md und die `quick-help`-Ansicht (via `?`) automatisch aktualisieren.
- Sprache der Hilfe ist wie die README.md immer **englisch**.
## Projektziel
- Neovim Plugin: **test-samurai**

View File

@@ -59,12 +59,27 @@ If no runner matches the current test file, test-samurai will show:
- `TSamLast` -> `<leader>tl`
- `TSamFailedOnly` -> `<leader>te`
- `TSamShowOutput` -> `<leader>to`
- Help: `:help test-samurai`
Additional keymaps:
- Listing navigation:
- `<leader>fn` -> [F]ind [N]ext failed test in listing (wraps to the first, opens Detail-Float, works in Detail-Float)
- `<leader>fp` -> [F]ind [P]revious failed test in listing (wraps to the last, opens Detail-Float, works in Detail-Float)
- `<leader>ff` -> [F]ind [F]irst list entry (opens Detail-Float, works in Detail-Float)
- `<leader>o` -> jump to the test location
- `<leader>qn` -> close the testing floats and jump to the first quickfix entry
- `<leader>nf` -> jump to the next `[ FAIL ]` entry in the Test-Listing-Float (wraps to the first)
- `<leader>pf` -> jump to the previous `[ FAIL ]` entry in the Test-Listing-Float (wraps to the last)
- Listing filters:
- `<leader>sf` -> filter the listing to `[ FAIL ] - ...` entries
- `<leader>ss` -> filter the listing to `[ SKIP ] - ...` entries
- `<leader>sa` -> clear the listing filter and show all entries
- Listing actions:
- `<leader>tt` -> run the test under the cursor in the listing
- `<leader>cb` -> breaks test-command onto multiple lines (clears search highlight)
- `<leader>cj` -> joins test-command onto single line
- `?` -> show help with TSam commands and standard keymaps in the Detail-Float
Before running any test command, test-samurai runs `:wall` to save all buffers.
## Output UI
@@ -74,11 +89,15 @@ Additional keymaps:
- After `TSamNearest`, `TSamFile`, `TSamAll`, `TSamFailedOnly`, etc., the UI opens in listing mode (only **Test-Listing-Float** visible).
- Press `<cr>` on a `[ FAIL ] ...` line in the listing to open/update the **Detail-Float** as a 20/80 split (left 20% listing, right 80% detail).
- ANSI color translation is only applied in the **Detail-Float**; the **Test-Listing-Float** shows raw text without ANSI translation.
- `<esc><esc>` hides the floating window; `TSamShowOutput` reopens it.
- `<esc><esc>` hides the floating window and restores the cursor position; `TSamShowOutput` reopens it.
- If no output is captured for a test, the **Detail-Float** shows `No output captured`.
- The active listing entry is highlighted while the **Detail-Float** is visible.
- Summary lines (`TOTAL`/`DURATION`) are appended in the listing output, including `TSamLast`.
## Runner architecture
Runners are standalone Lua modules. All runner modules are expected to implement the full interface so every command and keymap works.
All functions are required (including the previously optional ones) and listing output must be streamed.
Required functions:
- `is_test_file`
@@ -87,6 +106,9 @@ Required functions:
- `build_file_command`
- `build_all_command`
- `build_failed_command`
- `parse_results`
- `output_parser` (must stream listing output via `on_line`)
- `parse_test_output`
- `collect_failed_locations`
No runner for your environment exists? No problem: use `runners-agents.md` to guide an AI-assisted runner implementation tailored to your stack.
@@ -100,6 +122,8 @@ No runner for your environment exists? No problem: use `runners-agents.md` to gu
## Development
Runner development guidelines, including required data formats for keymaps, tests (`run_test.sh`), Gitea CI (Neovim AppImage on ARM runners), and framework-agnostic best practices (naming conventions, TSamNearest priority, reporter payloads, failed-only behavior), are documented in `runner-agents.md`.
Tests are written with `plenary.nvim` / `busted`. Mocks and stubs are allowed.
Run tests:

9
doc/tags Normal file
View File

@@ -0,0 +1,9 @@
test-samurai test-samurai.txt /*test-samurai*
test-samurai-commands test-samurai.txt /*test-samurai-commands*
test-samurai-default-keys test-samurai.txt /*test-samurai-default-keys*
test-samurai-quickhelp test-samurai.txt /*test-samurai-quickhelp*
test-samurai-req test-samurai.txt /*test-samurai-req*
test-samurai-runners test-samurai.txt /*test-samurai-runners*
test-samurai-setup test-samurai.txt /*test-samurai-setup*
test-samurai-ui test-samurai.txt /*test-samurai-ui*
test-samurai.txt test-samurai.txt /*test-samurai.txt*

102
doc/test-samurai.txt Normal file
View File

@@ -0,0 +1,102 @@
*test-samurai.txt* Run tests with a unified UX
INTRODUCTION *test-samurai*
Test Samurai provides a unified UX to run tests across languages
and frameworks, backed by an extensible runner architecture.
REQUIREMENTS *test-samurai-req*
Neovim 0.11.4 or later is required.
SETUP *test-samurai-setup*
This plugin ships without built-in runners. Configure runner modules
in your setup so test commands and keymaps work properly:
>
require("test-samurai").setup({
runner_modules = {
"my-runners.go",
"my-runners.js",
},
})
<
COMMANDS *test-samurai-commands*
:TSamNearest Run the nearest test.
:TSamFile Run all tests in the current file.
:TSamAll Run all tests in the project.
:TSamLast Re-run the last test command.
:TSamFailedOnly Re-run only previously failed tests.
:TSamShowOutput Reopen the Testing-Float output window.
DEFAULT KEYMAPS *test-samurai-default-keys*
TSamNearest <leader>tn
TSamFile <leader>tf
TSamAll <leader>ta
TSamLast <leader>tl
TSamFailedOnly <leader>te
TSamShowOutput <leader>to
QUICK-HELP & FLOATS *test-samurai-quickhelp*
In the Testing-Float, press ? to open the quick-help in the Detail-Float.
Additional keymaps:
Listing navigation:
<leader>fn [F]ind [N]ext failed test in listing (opens Detail-Float; works in Detail-Float)
<leader>fp [F]ind [P]revious failed test in listing (opens Detail-Float; works in Detail-Float)
<leader>ff [F]ind [F]irst list entry (opens Detail-Float; works in Detail-Float)
<leader>o Jump to test location
<leader>qn Close floats + jump to the first quickfix entry
Listing filters:
<leader>sf Filter listing to [ FAIL ] only
<leader>ss Filter listing to [ SKIP ] only
<leader>sa Show all listing entries (clear filter)
Listing actions:
<leader>tt Run the test under the cursor in the listing
<leader>cb breaks test-command onto multiple lines (clears search highlight)
<leader>cj joins test-command onto single line
Testing-Float:
<leader>z Toggle Detail-Float full width
<C-l> Focus Detail-Float (press l again for full)
<C-h> Focus Test-Listing-Float
<esc><esc> Close Testing-Float and restore cursor
<C-c> Close Detail-Float (when focused)
Notes:
Active listing entry is highlighted while the Detail-Float is visible.
Buffers are saved via :wall before every test run.
OUTPUT UI *test-samurai-ui*
Testing-Float: container floating window for output.
Test-Listing-Float: left subwindow listing test results.
Detail-Float: right subwindow showing detailed output for a test.
After running a test command, the UI opens in listing mode (only the
Test-Listing-Float is visible). Press <cr> on a [ FAIL ] entry to open
the Detail-Float with a 20/80 split. ANSI colors are translated only
inside the Detail-Float. If no output is captured for a test, the
Detail-Float shows "No output captured".
Summary lines (TOTAL/DURATION) are rendered in the listing output,
including after :TSamLast.
RUNNER ARCHITECTURE *test-samurai-runners*
Runners are standalone Lua modules that implement the full interface.
See README.md for the required functions and runner guidelines.
==============================================================================
vim:ft=help

View File

@@ -23,6 +23,12 @@ local state = {
detail_win = nil,
detail_opening = false,
detail_full = false,
trigger_win = nil,
trigger_buf = nil,
trigger_cursor = nil,
listing_unfiltered_lines = nil,
listing_filtered_kind = nil,
detail_line = nil,
hardtime_refcount = 0,
hardtime_was_enabled = false,
autocmds_set = false,
@@ -31,11 +37,16 @@ local state = {
local summary_ns = vim.api.nvim_create_namespace("TestSamuraiSummary")
local result_ns = vim.api.nvim_create_namespace("TestSamuraiResult")
local detail_ns = vim.api.nvim_create_namespace("TestSamuraiDetailAnsi")
local help_ns = vim.api.nvim_create_namespace("TestSamuraiHelp")
local listing_sel_ns = vim.api.nvim_create_namespace("TestSamuraiListingSelection")
local apply_border_kind
local close_container
local restore_listing_full
local close_detail_float
local jump_to_first_quickfix
local apply_summary_highlights
local apply_result_highlights
local run_command
local function disable_container_maps(buf)
local opts = { buffer = buf, nowait = true, silent = true }
@@ -43,6 +54,170 @@ local function disable_container_maps(buf)
vim.keymap.set("n", "<C-k>", "<Nop>", opts)
end
local function help_lines()
return {
"Test-Samurai Help",
"",
"TSam commands:",
" TSamNearest <leader>tn",
" TSamFile <leader>tf",
" TSamAll <leader>ta",
" TSamLast <leader>tl",
" TSamFailedOnly <leader>te",
" TSamShowOutput <leader>to",
"",
"Listing navigation:",
" <leader>fn [F]ind [N]ext failed test in listing (opens Detail-Float; works in Detail-Float)",
" <leader>fp [F]ind [P]revious failed test in listing (opens Detail-Float; works in Detail-Float)",
" <leader>ff [F]ind [F]irst list entry (opens Detail-Float; works in Detail-Float)",
" <leader>o Jump to test location",
" <leader>qn Close floats + jump to first quickfix entry",
"",
"Listing filters:",
" <leader>sf Filter listing to [ FAIL ] only",
" <leader>ss Filter listing to [ SKIP ] only",
" <leader>sa Show all listing entries (clear filter)",
"",
"Listing actions:",
" <leader>tt Run the test under the cursor",
" <leader>cb breaks test-command onto multiple lines (clears search highlight)",
" <leader>cj joins test-command onto single line",
"",
"Testing-Float (Listing):",
" <cr> Open Detail-Float for selected test",
" <esc><esc> Close Testing-Float and restore cursor",
" <C-l> Focus Detail-Float (press l again for full)",
" <C-h> Focus Test-Listing-Float",
" <leader>z Toggle Detail-Float full width",
" ? Show this help",
"",
"Testing-Float (Detail):",
" <esc><esc> Close Testing-Float and restore cursor",
" <C-h> Focus Test-Listing-Float",
" <C-w>h Focus Test-Listing-Float",
" <C-l> Focus Detail-Float",
" <leader>z Toggle Detail-Float full width",
" <C-c> Close Detail-Float",
" ? Show this help",
"",
"Notes:",
" No output captured -> shows placeholder text",
" Active listing entry is highlighted while Detail-Float is visible",
" Buffers are saved via :wall before every test run",
}
end
local function split_listing_sections(lines)
local summary_start = nil
for i, line in ipairs(lines or {}) do
if line:match("^TOTAL%s+%d+") then
summary_start = i - 1
if summary_start < 2 then
summary_start = 2
end
break
end
end
local header = {}
local body = {}
local summary = {}
if lines and #lines > 0 then
header = { lines[1] }
end
for i, line in ipairs(lines or {}) do
if i == 1 then
elseif summary_start and i >= summary_start then
table.insert(summary, line)
else
table.insert(body, line)
end
end
return header, body, summary
end
local function rebuild_result_line_map(lines)
state.last_result_line_map = {}
for idx, line in ipairs(lines or {}) do
local status = line:match("^%[%s*(%u+)%s*%]%s*%-")
if status == "PASS" or status == "FAIL" or status == "SKIP" then
local name = line:match("^%[%s*[%u]+%s*%]%s*%-%s*(.+)$")
if name and name ~= "" then
state.last_result_line_map[idx] = name
end
end
end
end
local function apply_listing_lines(buf, lines)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
vim.api.nvim_buf_clear_namespace(buf, result_ns, 0, -1)
vim.api.nvim_buf_clear_namespace(buf, summary_ns, 0, -1)
vim.api.nvim_buf_clear_namespace(buf, listing_sel_ns, 0, -1)
apply_result_highlights(buf, 0, lines)
apply_summary_highlights(buf, 0, lines)
rebuild_result_line_map(lines)
end
local function apply_listing_substitution(command)
local buf = vim.api.nvim_get_current_buf()
if not (buf and vim.api.nvim_buf_is_valid(buf)) then
return
end
vim.api.nvim_buf_call(buf, function()
vim.cmd(command)
end)
end
local function apply_listing_filter(kind)
if not (state.last_buf and vim.api.nvim_buf_is_valid(state.last_buf)) then
return
end
if kind == "all" then
if not state.listing_filtered_kind or not state.listing_unfiltered_lines then
return
end
apply_listing_lines(state.last_buf, state.listing_unfiltered_lines)
state.listing_filtered_kind = nil
return
end
if state.listing_filtered_kind == kind then
return
end
local base = state.listing_unfiltered_lines
if not base then
base = vim.api.nvim_buf_get_lines(state.last_buf, 0, -1, false)
state.listing_unfiltered_lines = vim.deepcopy(base)
end
local header, body, summary = split_listing_sections(base)
local filtered = {}
for _, line in ipairs(body) do
if kind == "fail" and line:match("^%[ FAIL %] %-") then
table.insert(filtered, line)
elseif kind == "skip" and line:match("^%[ SKIP %] %-") then
table.insert(filtered, line)
end
end
if #filtered == 0 then
return
end
local combined = {}
if #header > 0 then
table.insert(combined, header[1])
table.insert(combined, "")
end
for _, line in ipairs(filtered) do
table.insert(combined, line)
end
for _, line in ipairs(summary) do
table.insert(combined, line)
end
apply_listing_lines(state.last_buf, combined)
state.listing_filtered_kind = kind
end
local function get_hardtime()
local ok, hardtime = pcall(require, "hardtime")
if not ok or type(hardtime) ~= "table" then
@@ -107,11 +282,11 @@ local function jump_listing_fail(direction)
local win = vim.api.nvim_get_current_win()
local buf = vim.api.nvim_get_current_buf()
if not (buf and vim.api.nvim_buf_is_valid(buf)) then
return
return nil
end
local total = vim.api.nvim_buf_line_count(buf)
if total == 0 then
return
return nil
end
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
local cursor = vim.api.nvim_win_get_cursor(win)
@@ -163,6 +338,76 @@ local function jump_listing_fail(direction)
if target then
vim.api.nvim_win_set_cursor(win, { target, 0 })
end
return target
end
local function jump_to_first_listing_entry()
local win = vim.api.nvim_get_current_win()
local buf = vim.api.nvim_get_current_buf()
if not (buf and vim.api.nvim_buf_is_valid(buf)) then
return nil
end
local total = vim.api.nvim_buf_line_count(buf)
if total == 0 then
return nil
end
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
for i = 1, total do
if lines[i] and lines[i]:match("^%[ %u+ %] %- ") then
vim.api.nvim_win_set_cursor(win, { i, 0 })
return i
end
end
return nil
end
local function jump_listing_and_open(kind)
local current_win = vim.api.nvim_get_current_win()
local listing_win = state.last_win
if listing_win and vim.api.nvim_win_is_valid(listing_win) then
vim.api.nvim_set_current_win(listing_win)
else
listing_win = current_win
end
local target = nil
if kind == "next" then
target = jump_listing_fail("next")
elseif kind == "prev" then
target = jump_listing_fail("prev")
elseif kind == "first" then
target = jump_to_first_listing_entry()
end
if target then
M.open_test_output_at_cursor()
return
end
if current_win and vim.api.nvim_win_is_valid(current_win) then
vim.api.nvim_set_current_win(current_win)
end
end
local function clear_listing_selection()
if state.last_buf and vim.api.nvim_buf_is_valid(state.last_buf) then
vim.api.nvim_buf_clear_namespace(state.last_buf, listing_sel_ns, 0, -1)
end
state.detail_line = nil
end
local function apply_listing_selection(line)
if not (state.last_buf and vim.api.nvim_buf_is_valid(state.last_buf)) then
return
end
vim.api.nvim_buf_clear_namespace(state.last_buf, listing_sel_ns, 0, -1)
if not line then
state.detail_line = nil
return
end
local total = vim.api.nvim_buf_line_count(state.last_buf)
if line < 1 or line > total then
return
end
vim.api.nvim_buf_add_highlight(state.last_buf, listing_sel_ns, "TestSamuraiListingActive", line - 1, 0, -1)
state.detail_line = line
end
local function find_normal_window()
@@ -175,6 +420,54 @@ local function find_normal_window()
return nil
end
local function capture_trigger_location()
local win = vim.api.nvim_get_current_win()
local cfg = vim.api.nvim_win_get_config(win)
if cfg.relative ~= "" then
win = find_normal_window()
end
if not (win and vim.api.nvim_win_is_valid(win)) then
return
end
local buf = vim.api.nvim_win_get_buf(win)
if not (buf and vim.api.nvim_buf_is_valid(buf)) then
return
end
local cursor = vim.api.nvim_win_get_cursor(win)
state.trigger_win = win
state.trigger_buf = buf
state.trigger_cursor = { cursor[1], cursor[2] }
end
local function restore_trigger_location()
local buf = state.trigger_buf
local cursor = state.trigger_cursor
if not (buf and vim.api.nvim_buf_is_valid(buf)) then
return
end
local target_win = nil
if state.trigger_win and vim.api.nvim_win_is_valid(state.trigger_win) then
local cfg = vim.api.nvim_win_get_config(state.trigger_win)
if cfg.relative == "" then
target_win = state.trigger_win
end
end
if not target_win then
target_win = find_normal_window()
end
if not (target_win and vim.api.nvim_win_is_valid(target_win)) then
return
end
vim.api.nvim_set_current_win(target_win)
vim.api.nvim_win_set_buf(target_win, buf)
if cursor then
pcall(vim.api.nvim_win_set_cursor, target_win, cursor)
end
state.trigger_win = nil
state.trigger_buf = nil
state.trigger_cursor = nil
end
local function jump_to_listing_test()
local cursor = vim.api.nvim_win_get_cursor(0)
local line = cursor[1]
@@ -269,6 +562,7 @@ local function setup_summary_highlights()
pcall(vim.api.nvim_set_hl, 0, "TestSamuraiResultSkip", { fg = skip_fg })
pcall(vim.api.nvim_set_hl, 0, "TestSamuraiBorderPass", { fg = pass_fg, bold = true })
pcall(vim.api.nvim_set_hl, 0, "TestSamuraiBorderFail", { fg = fail_fg, bold = true })
pcall(vim.api.nvim_set_hl, 0, "TestSamuraiListingActive", { link = "Visual" })
end
local function load_runners()
@@ -302,6 +596,7 @@ local function ensure_output_autocmds()
if state.detail_win and closed == state.detail_win then
state.detail_win = nil
restore_listing_full()
clear_listing_selection()
hardtime_restore()
return
end
@@ -312,6 +607,7 @@ local function ensure_output_autocmds()
pcall(vim.api.nvim_win_close, state.detail_win, true)
state.detail_win = nil
end
clear_listing_selection()
return
end
end,
@@ -479,6 +775,12 @@ close_container = function()
pcall(vim.api.nvim_win_close, state.last_win, true)
state.last_win = nil
end
clear_listing_selection()
end
local function close_container_and_restore()
close_container()
restore_trigger_location()
end
jump_to_first_quickfix = function()
@@ -493,6 +795,7 @@ close_detail_float = function()
if state.detail_win and vim.api.nvim_win_is_valid(state.detail_win) then
pcall(vim.api.nvim_win_close, state.detail_win, true)
end
clear_listing_selection()
end
restore_listing_full = function()
@@ -566,6 +869,7 @@ local function apply_split_layout(left_ratio)
end
local function create_output_win(initial_lines)
capture_trigger_location()
if state.detail_win and vim.api.nvim_win_is_valid(state.detail_win) then
pcall(vim.api.nvim_win_close, state.detail_win, true)
state.detail_win = nil
@@ -600,7 +904,7 @@ local function create_output_win(initial_lines)
hardtime_disable()
vim.keymap.set("n", "<esc><esc>", function()
close_container()
close_container_and_restore()
end, { buffer = buf, nowait = true, silent = true })
vim.keymap.set("n", "<cr>", function()
M.open_test_output_at_cursor()
@@ -614,11 +918,20 @@ local function create_output_win(initial_lines)
vim.keymap.set("n", "<leader>z", function()
M.toggle_detail_full()
end, { buffer = buf, nowait = true, silent = true })
vim.keymap.set("n", "<leader>nf", function()
jump_listing_fail("next")
vim.keymap.set("n", "<leader>fn", function()
jump_listing_and_open("next")
end, { buffer = buf, nowait = true, silent = true })
vim.keymap.set("n", "<leader>pf", function()
jump_listing_fail("prev")
vim.keymap.set("n", "<leader>fp", function()
jump_listing_and_open("prev")
end, { buffer = buf, nowait = true, silent = true })
vim.keymap.set("n", "<leader>ff", function()
jump_listing_and_open("first")
end, { buffer = buf, nowait = true, silent = true, desc = "[F]ind [F]irst list entry" })
vim.keymap.set("n", "<leader>cb", function()
M.listing_break_on_dashes()
end, { buffer = buf, nowait = true, silent = true })
vim.keymap.set("n", "<leader>cj", function()
M.listing_join_backslashes()
end, { buffer = buf, nowait = true, silent = true })
vim.keymap.set("n", "<leader>o", function()
jump_to_listing_test()
@@ -626,6 +939,21 @@ local function create_output_win(initial_lines)
vim.keymap.set("n", "<leader>qn", function()
jump_to_first_quickfix()
end, { buffer = buf, nowait = true, silent = true })
vim.keymap.set("n", "<leader>sf", function()
M.filter_listing_failures()
end, { buffer = buf, nowait = true, silent = true })
vim.keymap.set("n", "<leader>ss", function()
M.filter_listing_skips()
end, { buffer = buf, nowait = true, silent = true })
vim.keymap.set("n", "<leader>sa", function()
M.filter_listing_all()
end, { buffer = buf, nowait = true, silent = true })
vim.keymap.set("n", "<leader>tt", function()
M.run_test_at_cursor()
end, { buffer = buf, nowait = true, silent = true })
vim.keymap.set("n", "?", function()
M.show_help()
end, { buffer = buf, nowait = true, silent = true })
disable_container_maps(buf)
state.last_win = listing
@@ -649,6 +977,7 @@ local function reopen_output_win()
state.detail_win = nil
end
capture_trigger_location()
local width, height, row, col = float_geometry()
state.last_float = { width = width, height = height, row = row, col = col }
@@ -664,7 +993,7 @@ local function reopen_output_win()
hardtime_disable()
vim.keymap.set("n", "<esc><esc>", function()
close_container()
close_container_and_restore()
end, { buffer = state.last_buf, nowait = true, silent = true })
vim.keymap.set("n", "<cr>", function()
M.open_test_output_at_cursor()
@@ -678,11 +1007,20 @@ local function reopen_output_win()
vim.keymap.set("n", "<leader>z", function()
M.toggle_detail_full()
end, { buffer = state.last_buf, nowait = true, silent = true })
vim.keymap.set("n", "<leader>nf", function()
jump_listing_fail("next")
vim.keymap.set("n", "<leader>fn", function()
jump_listing_and_open("next")
end, { buffer = state.last_buf, nowait = true, silent = true })
vim.keymap.set("n", "<leader>pf", function()
jump_listing_fail("prev")
vim.keymap.set("n", "<leader>fp", function()
jump_listing_and_open("prev")
end, { buffer = state.last_buf, nowait = true, silent = true })
vim.keymap.set("n", "<leader>ff", function()
jump_listing_and_open("first")
end, { buffer = state.last_buf, nowait = true, silent = true, desc = "[F]ind [F]irst list entry" })
vim.keymap.set("n", "<leader>cb", function()
M.listing_break_on_dashes()
end, { buffer = state.last_buf, nowait = true, silent = true })
vim.keymap.set("n", "<leader>cj", function()
M.listing_join_backslashes()
end, { buffer = state.last_buf, nowait = true, silent = true })
vim.keymap.set("n", "<leader>o", function()
jump_to_listing_test()
@@ -690,6 +1028,21 @@ local function reopen_output_win()
vim.keymap.set("n", "<leader>qn", function()
jump_to_first_quickfix()
end, { buffer = state.last_buf, nowait = true, silent = true })
vim.keymap.set("n", "<leader>sf", function()
M.filter_listing_failures()
end, { buffer = state.last_buf, nowait = true, silent = true })
vim.keymap.set("n", "<leader>ss", function()
M.filter_listing_skips()
end, { buffer = state.last_buf, nowait = true, silent = true })
vim.keymap.set("n", "<leader>sa", function()
M.filter_listing_all()
end, { buffer = state.last_buf, nowait = true, silent = true })
vim.keymap.set("n", "<leader>tt", function()
M.run_test_at_cursor()
end, { buffer = state.last_buf, nowait = true, silent = true })
vim.keymap.set("n", "?", function()
M.show_help()
end, { buffer = state.last_buf, nowait = true, silent = true })
disable_container_maps(state.last_buf)
state.last_win = win
@@ -952,6 +1305,18 @@ local function apply_detail_highlights(buf, highlights)
end
end
local function apply_help_highlights(buf, lines)
if not (buf and vim.api.nvim_buf_is_valid(buf)) then
return
end
vim.api.nvim_buf_clear_namespace(buf, help_ns, 0, -1)
for lnum, line in ipairs(lines or {}) do
if line:match(":%s*$") then
vim.api.nvim_buf_add_highlight(buf, help_ns, "TestSamuraiSummaryPass", lnum - 1, 0, -1)
end
end
end
local function parse_go_output_from_raw(output)
local out = {}
if not output or output == "" then
@@ -985,7 +1350,7 @@ local function ensure_detail_buf(lines)
vim.api.nvim_buf_set_option(buf, "filetype", "test-samurai-output")
state.detail_buf = buf
vim.keymap.set("n", "<esc><esc>", function()
close_container()
close_container_and_restore()
end, { buffer = buf, nowait = true, silent = true })
vim.keymap.set("n", "<C-w>h", function()
M.focus_listing()
@@ -999,16 +1364,29 @@ local function ensure_detail_buf(lines)
vim.keymap.set("n", "<leader>z", function()
M.toggle_detail_full()
end, { buffer = buf, nowait = true, silent = true })
vim.keymap.set("n", "<leader>fn", function()
jump_listing_and_open("next")
end, { buffer = buf, nowait = true, silent = true })
vim.keymap.set("n", "<leader>fp", function()
jump_listing_and_open("prev")
end, { buffer = buf, nowait = true, silent = true })
vim.keymap.set("n", "<leader>ff", function()
jump_listing_and_open("first")
end, { buffer = buf, nowait = true, silent = true, desc = "[F]ind [F]irst list entry" })
vim.keymap.set("n", "<C-c>", function()
close_detail_float()
end, { buffer = buf, nowait = true, silent = true })
vim.keymap.set("n", "<leader>qn", function()
jump_to_first_quickfix()
end, { buffer = buf, nowait = true, silent = true })
vim.keymap.set("n", "?", function()
M.show_help()
end, { buffer = buf, nowait = true, silent = true })
disable_container_maps(buf)
end
local clean_lines, highlights = parse_ansi_lines(normalize_output_lines(lines))
vim.api.nvim_buf_set_lines(buf, 0, -1, false, clean_lines)
vim.api.nvim_buf_clear_namespace(buf, help_ns, 0, -1)
apply_detail_highlights(buf, highlights)
return buf
end
@@ -1126,11 +1504,109 @@ function M.open_test_output_at_cursor()
end
end
if type(output) ~= "table" or #output == 0 then
vim.notify("[test-samurai] No output captured for " .. test_name, vim.log.levels.WARN)
open_detail_split({ "", "No output captured" }, "default")
apply_listing_selection(line)
return
end
local border_kind = status and status:lower() or nil
open_detail_split(output, border_kind)
apply_listing_selection(line)
end
function M.show_help()
if not (state.last_win and vim.api.nvim_win_is_valid(state.last_win)) then
vim.notify("[test-samurai] No test output window", vim.log.levels.WARN)
return
end
local lines = help_lines()
open_detail_split(lines, "default")
clear_listing_selection()
apply_help_highlights(state.detail_buf, lines)
end
function M.filter_listing_failures()
apply_listing_filter("fail")
end
function M.filter_listing_skips()
apply_listing_filter("skip")
end
function M.filter_listing_all()
apply_listing_filter("all")
end
function M.listing_break_on_dashes()
apply_listing_substitution([[%s/--/\\\r\t--/g]])
vim.cmd("noh")
end
function M.listing_join_backslashes()
apply_listing_substitution([[%s/\\\n\t//g]])
end
function M.run_test_at_cursor()
local cursor = vim.api.nvim_win_get_cursor(0)
local line = cursor[1]
local text = vim.api.nvim_get_current_line()
local status = text:match("^%[%s*(%u+)%s*%]%s*%-")
if status ~= "PASS" and status ~= "FAIL" and status ~= "SKIP" then
return
end
local test_name = state.last_result_line_map[line]
if not test_name then
test_name = text:match("^%[%s*[%u]+%s*%]%s*%-%s*(.+)$")
end
if not test_name or test_name == "" then
return
end
local runner = state.last_scope_runner or state.last_runner
if not runner or type(runner.build_command) ~= "function" then
vim.notify("[test-samurai] Runner missing methods", vim.log.levels.ERROR)
return
end
local command_src = state.last_scope_command or state.last_command
if not command_src then
vim.notify("[test-samurai] No previous test command", vim.log.levels.WARN)
return
end
local spec = {
file = command_src.file,
cwd = command_src.cwd,
test_name = test_name,
full_name = test_name,
}
if runner._last_mocha_titles and type(runner._last_mocha_titles) == "table" then
spec.mocha_full_title = runner._last_mocha_titles[test_name]
end
if not spec.mocha_full_title and test_name:find("/", 1, true) then
spec.mocha_full_title = test_name:gsub("/", " ")
end
if not spec.file or spec.file == "" then
vim.notify("[test-samurai] Missing test file for rerun", vim.log.levels.WARN)
return
end
local ok_cmd, command = pcall(runner.build_command, spec)
if not ok_cmd or not command or type(command.cmd) ~= "table" or #command.cmd == 0 then
vim.notify("[test-samurai] Runner failed to build command", vim.log.levels.ERROR)
return
end
command.file = spec.file
local parser = runner.output_parser
if type(parser) == "function" then
parser = parser()
end
run_command(command, {
track_scope = true,
runner = runner,
scope_kind = "nearest",
output_parser = parser or runner.parse_results,
})
end
function M.focus_listing()
@@ -1525,7 +2001,7 @@ local function highlight_label_word(buf, ns, lnum, line, label, hl_group)
vim.api.nvim_buf_add_highlight(buf, ns, hl_group, lnum, label_start - 1, label_start - 1 + #label)
end
local function apply_summary_highlights(buf, start_line, lines)
apply_summary_highlights = function(buf, start_line, lines)
if not (buf and vim.api.nvim_buf_is_valid(buf)) then
return
end
@@ -1547,7 +2023,7 @@ local function apply_summary_highlights(buf, start_line, lines)
end
end
local function apply_result_highlights(buf, start_line, lines)
apply_result_highlights = function(buf, start_line, lines)
if not (buf and vim.api.nvim_buf_is_valid(buf)) then
return
end
@@ -1585,11 +2061,14 @@ local function collect_failure_names_from_listing()
return out
end
local function run_command(command, opts)
run_command = function(command, opts)
local options = opts or {}
vim.cmd("wall")
state.last_test_outputs = {}
state.last_result_line_map = {}
state.last_raw_output = nil
state.listing_unfiltered_lines = nil
state.listing_filtered_kind = nil
local failures = {}
local failures_seen = {}
if command and type(command.cmd) == "table" and #command.cmd > 0 then
@@ -1635,7 +2114,10 @@ local function run_command(command, opts)
skips = {},
}
local had_parsed_output = false
local summary_enabled = options.scope_kind == "file" or options.scope_kind == "all" or options.scope_kind == "nearest"
local summary_enabled = options.scope_kind == "file"
or options.scope_kind == "all"
or options.scope_kind == "nearest"
or options.scope_kind == "last"
local summary = make_summary_tracker(summary_enabled)
local result_counts = make_summary_tracker(true)
state.last_border_kind = "default"
@@ -1804,7 +2286,7 @@ local function run_command(command, opts)
append_lines(buf, summary_lines)
apply_summary_highlights(buf, start_line, summary_lines)
end
append_lines(buf, { "", "[exit code] " .. tostring(code) })
append_lines(buf, { "" })
else
if not has_output then
local cur = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
@@ -1823,13 +2305,14 @@ local function run_command(command, opts)
append_lines(buf, summary_lines)
apply_summary_highlights(buf, start_line, summary_lines)
end
append_lines(buf, { "", "[exit code] " .. tostring(code) })
append_lines(buf, { "" })
end
if options.track_scope then
state.last_scope_exit_code = code
end
local items = {}
local failures_for_qf = failures
local fallback_failures = options.qf_fallback_failures
if options.track_scope and type(state.last_scope_failures) == "table" then
local merged = {}
local seen = {}
@@ -1865,8 +2348,20 @@ local function run_command(command, opts)
end
failures_for_qf = merged
end
if (not failures_for_qf or #failures_for_qf == 0) and type(fallback_failures) == "table" then
local merged = {}
local seen = {}
for _, name in ipairs(fallback_failures) do
if name and not seen[name] then
seen[name] = true
table.insert(merged, name)
end
end
failures_for_qf = merged
end
if #failures_for_qf > 0 and runner and type(runner.collect_failed_locations) == "function" then
local ok_collect, collected = pcall(runner.collect_failed_locations, failures_for_qf, command, options.scope_kind)
local scope_kind = options.qf_scope_kind or options.scope_kind
local ok_collect, collected = pcall(runner.collect_failed_locations, failures_for_qf, command, scope_kind)
if ok_collect and type(collected) == "table" then
items = collected
end
@@ -1896,6 +2391,7 @@ function M.run_last()
end
run_command(command, {
runner = runner,
scope_kind = state.last_scope_kind or "last",
output_parser = parser or (runner and runner.parse_results),
})
end
@@ -2066,10 +2562,17 @@ function M.run_failed_only()
end
run_command(command, {
save_last = false,
runner = runner,
output_parser = parser or (runner and runner.parse_results),
qf_fallback_failures = state.last_scope_failures,
qf_scope_kind = state.last_scope_kind,
})
end
function M.close_output_and_restore()
close_container_and_restore()
end
function M.show_output()
if not (state.last_buf and vim.api.nvim_buf_is_valid(state.last_buf)) then
vim.notify("[test-samurai] No previous output", vim.log.levels.WARN)

View File

@@ -13,7 +13,7 @@ implementieren muss, damit alle Commands vollständig unterstützt werden.
- Beispiel: `lua/test-samurai-go-runner/init.lua` -> `require("test-samurai-go-runner")`.
- `lua/init.lua` wäre `require("init")` und ist kollisionsanfällig; vermeiden.
## Pflichtfunktionen (volle Command-Unterstützung)
## Pflichtfunktionen (volle Command- und Keymap-Unterstützung)
- `is_test_file(bufnr) -> boolean`
- Wird für die Runner-Auswahl genutzt.
@@ -29,10 +29,18 @@ implementieren muss, damit alle Commands vollständig unterstützt werden.
- Für `TSamAll`.
- `build_failed_command(last_command, failures, scope_kind) -> command_spec`
- Für `TSamFailedOnly`.
- `parse_results(output) -> results`
- Fallback-Parser für vollständigen Output.
- `output_parser() -> { on_line, on_complete }`
- Muss Streaming unterstützen (Listing wird live befüllt).
- `parse_test_output(output) -> table`
- Detail-Ausgabe pro Test (Detail-Float und `<cr>` im Listing).
- `collect_failed_locations(failures, command, scope_kind) -> items`
- Quickfix-Unterstützung (Keymap `<leader>qn`).
## Output-Parsing (Listing + Summary)
Genau eine der folgenden Varianten muss vorhanden sein:
Beide Varianten müssen vorhanden sein:
- `parse_results(output) -> results`
- `results` muss enthalten:
@@ -46,6 +54,9 @@ Genau eine der folgenden Varianten muss vorhanden sein:
- oder `output_parser() -> { on_line, on_complete }`
- `on_line(line, state)` kann `results` liefern (siehe oben).
- `on_complete(output, state)` kann `results` liefern (siehe oben).
- `on_line` muss Ergebnisse liefern, damit das Listing-Float immer gestreamt wird.
Hinweis: `parse_results` darf intern `output_parser().on_complete` nutzen, aber beide Funktionen müssen existieren.
## Listing-Gruppierung fuer Parent/Subtests
@@ -78,6 +89,25 @@ Genau eine der folgenden Varianten muss vorhanden sein:
- `spec` (von `find_nearest`):
- Muss mindestens `file` enthalten, z. B.:
- `{ file = "...", cwd = "...", test_name = "...", full_name = "...", kind = "..." }`
- `results` (für Listing-Float + Keymaps):
- `{ passes = { "Name1", ... }, failures = { "Name2", ... }, skips = { "Name3", ... } }`
- Optional: `display = { passes = { "DisplayName1", ... }, failures = { "DisplayName2", ... }, skips = { "DisplayName3", ... } }`
- `failures` steuert `[ FAIL ]`-Zeilen im Listing und wird von `<leader>nf`/`<leader>pf` genutzt.
- `items` (für Quickfix):
- `{ { filename = "...", lnum = 1, col = 1, text = "..." }, ... }`
- Wird von `<leader>qn` verwendet.
## Keymaps (Datenlieferung)
- `<leader>nf` / `<leader>pf`
- benötigt `[ FAIL ]`-Einträge im Listing.
- Runner muss `results.failures` (und optional `display.failures`) liefern.
- `<leader>qn`
- springt in die Quickfix-Liste.
- Runner muss `collect_failed_locations` implementieren und gültige `items` liefern.
- `<cr>` im Listing
- öffnet Detail-Float.
- Runner muss `parse_test_output` liefern und Testnamen konsistent zu `results.*` halten.
## Optional empfohlene Metadaten
@@ -126,6 +156,17 @@ function runner.parse_results(output)
return { passes = {}, failures = {}, skips = {}, display = { passes = {}, failures = {}, skips = {} } }
end
function runner.output_parser()
return {
on_line = function(_line, _state)
return nil
end,
on_complete = function(_output, _state)
return runner.parse_results(_output)
end,
}
end
function runner.parse_test_output(output)
return {}
end
@@ -145,21 +186,33 @@ return runner
- build_file_command implementiert
- build_all_command implementiert
- build_failed_command implementiert
- parse_results oder output_parser implementiert
- parse_results implementiert
- output_parser implementiert (Streaming)
- parse_test_output implementiert
- collect_failed_locations implementiert
- command_spec `{ cmd, cwd }` korrekt zurückgegeben
## Projekt- und Prozessanforderungen
- Eine englischsprachige `README.md` ist zu erstellen.
- Sie muss bei jedem weiteren Prompt aktualisiert werden, wenn die Aenderungen es erfordern.
- Rolle: **TDD-first Entwickler**.
- Jede neue Funktion, jedes neue Kommando und jede Verhaltensänderung muss durch Tests abgesichert sein.
- Nach jeder Code-Änderung Tests via `bash run_test.sh` ausführen und bei Fehlern korrigieren, bis alles grün ist.
- **Nicht raten**:
- Bei unklaren oder mehrdeutigen Anforderungen Arbeit stoppen und Klarstellung verlangen.
- TODO/NOTE im Code ist zulässig, stilles Raten nicht.
- **Keine stillen Änderungen**:
- Bestehende Features dürfen nicht unbemerkt geändert oder ersetzt werden.
- Notwendige Anpassungen zur Koexistenz mehrerer Features müssen klar erkennbar sein.
- Antworten immer auf Deutsch.
- Eine englischsprachige `README.md` ist zu erstellen und wird bei Änderungen automatisch aktualisiert.
- TDD-Vorgaben (aus `AGENTS.md`) uebernehmen:
- Neue Funktionen/Commands/Verhaltensaenderungen muessen getestet werden.
- Tests nach jeder Code-Aenderung ausfuehren.
- Im Runner erstellter Quellcode ist ebenfalls zu testen.
- Eine eigene `run_test.sh` darf im Runner-Repo angelegt werden.
- Eine eigene `run_test.sh` wird im Runner-Repo angelegt.
- Eine Gitea-Action ist zu erstellen, die bei jedem Push die Tests ausfuehrt.
- Neovim wird per AppImage installiert (kein `apt`).
- Runner laeuft auf `gitea-act-runner` mit Raspberry Pi 5 (ARM).
- Beispiel (anpassbarer Workflow):
```yaml
@@ -176,6 +229,19 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Neovim (AppImage)
run: |
curl -L -o nvim.appimage https://github.com/neovim/neovim/releases/download/v0.11.4/nvim.appimage
chmod +x nvim.appimage
sudo mv nvim.appimage /usr/local/bin/nvim
- 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.

File diff suppressed because it is too large Load Diff