create runner with ChatGPT-Codex by using the AGENTS.md
Some checks failed
tests / test (push) Failing after 4s

This commit is contained in:
2026-01-03 14:53:56 +01:00
commit b70d26256c
8 changed files with 1459 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
name: tests
on:
push:
branches:
- "**"
jobs:
test:
runs-on: ubuntu-latest
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

239
AGENTS.md Normal file
View File

@@ -0,0 +1,239 @@
# runner-agents.md — test-samurai Runner-API
Ziel: Diese Datei beschreibt die öffentliche Runner-API, die ein neuer Runner
implementieren muss, damit alle Commands vollständig unterstützt werden.
## Modulform (Pflicht)
- Der Runner ist ein Lua-Modul, das eine Table mit Funktionen zurückgibt.
- Beispiel:
- `local runner = {}`
- `return runner`
- Der Modulpfad in `runner_modules` muss exakt zum Dateipfad unter `lua/` passen.
- 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- und Keymap-Unterstützung)
- `is_test_file(bufnr) -> boolean`
- Wird für die Runner-Auswahl genutzt.
- `find_nearest(bufnr, row, col) -> spec|nil, err?`
- Für `TSamNearest`.
- `spec.file` muss gesetzt sein.
- Bei Fehler/kein Treffer: `nil, "reason"` zurückgeben.
- `build_command(spec) -> command_spec`
- Für `TSamNearest`.
- `build_file_command(bufnr) -> command_spec`
- Für `TSamFile`.
- `build_all_command(bufnr) -> command_spec`
- 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)
Beide Varianten müssen vorhanden sein:
- `parse_results(output) -> results`
- `results` muss enthalten:
- `passes` (Array von Namen)
- `failures` (Array von Namen)
- `skips` (Array von Namen)
- Optional:
- `display = { passes = {}, failures = {}, skips = {} }`
- `failures_all` (für Streaming-Parser, um alle bisherigen Failures zu liefern)
- Wenn `display` fehlt, werden `passes/failures/skips` direkt im Listing angezeigt.
- 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
- Wenn Testnamen das Format `Parent/Subtest` oder verschachtelt `Parent/Sub/Subtest` haben
und der Parent ebenfalls in den Ergebnissen vorhanden ist, gruppiert das Listing:
- Parent kommt vor seinen direkten Kindern.
- Mehrstufige Subtests werden hierarchisch gruppiert (Parent -> Kind -> Enkel).
- Die Reihenfolge der Kinder folgt der Eingangsreihenfolge des Runners.
- Ohne Parent-Eintrag bleibt die normale Reihenfolge erhalten.
## Detail-Output (TSamShowOutput / <cr> im Listing)
- `parse_test_output(output) -> table`
- Rückgabeform:
- `{ [test_name] = { "line1", "line2", ... } }`
- `test_name` muss mit `results.*` korrespondieren (gleiches Namensschema).
## Quickfix-Unterstützung (Failures)
- `collect_failed_locations(failures, command, scope_kind) -> items`
- `items`: Array von Quickfix-Items
- `{ filename = "...", lnum = <number>, col = <number>, text = "..." }`
## Erwartete Datenformen
- `command_spec`:
- `{ cmd = { "binary", "arg1", ... }, cwd = "..." }`
- `cmd` darf nicht leer sein.
- `cwd` ist optional; wenn nicht gesetzt, nutzt der Core das aktuelle CWD.
- `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
- `name` (String)
- Wird in Fehlermeldungen und Logs angezeigt.
- `framework` (String)
- Wird zur Framework-Auswahl (z. B. JS) genutzt.
## Prompt-Beispiel
"Erstelle mir anhand der `runner-agents.md` einen neuen Runner für Rust."
## Minimaler Runner-Skeleton (Template)
```lua
local runner = {
name = "my-runner",
framework = "my-framework",
}
function runner.is_test_file(bufnr)
return false
end
function runner.find_nearest(bufnr, row, col)
return nil, "no test call found"
end
function runner.build_command(spec)
return { cmd = { "echo", "not-implemented" }, cwd = spec.cwd }
end
function runner.build_file_command(bufnr)
return { cmd = { "echo", "not-implemented" } }
end
function runner.build_all_command(bufnr)
return { cmd = { "echo", "not-implemented" } }
end
function runner.build_failed_command(last_command, failures, scope_kind)
return { cmd = { "echo", "not-implemented" }, cwd = last_command and last_command.cwd or nil }
end
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
function runner.collect_failed_locations(failures, command, scope_kind)
return {}
end
return runner
```
## Checkliste für neue Runner
- is_test_file implementiert
- find_nearest implementiert (setzt `spec.file`)
- build_command implementiert
- build_file_command implementiert
- build_all_command implementiert
- build_failed_command 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
- 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` 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
name: tests
on:
push:
branches:
- "**"
jobs:
test:
runs-on: ubuntu-latest
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
```

61
README.md Normal file
View File

@@ -0,0 +1,61 @@
# test-samurai-jest-runner.nvim
Jest.js runner for `test-samurai.nvim`.
Main plugin: https://gitea.mschirmer.com/m13r/test-samurai.nvim
## Features
- Detects Jest test files (`*.test.*`, `*.spec.*`).
- 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`.
- Uses `--testLocationInResults` for Quickfix and `<leader>o` support.
## Installation (lazy.nvim)
```lua
{
"m13r/test-samurai.nvim",
dependencies = {
"m13r/test-samurai-jest-runner.nvim",
},
config = function()
require("test-samurai").setup({
runners = {
require("test-samurai-jest-runner"),
},
})
end,
}
```
## Local Development (lazy.nvim)
```lua
{
"m13r/test-samurai.nvim",
dependencies = {
{
"test-samurai-jest-runner.nvim",
dir = "/absolute/path/to/test-samurai-jest-runner.nvim",
},
},
config = function()
require("test-samurai").setup({
runners = {
require("test-samurai-jest-runner"),
},
})
end,
}
```
## Usage
Use the standard `test-samurai.nvim` commands (e.g. `TSamNearest`, `TSamFile`, `TSamAll`, `TSamFailedOnly`).
## Notes
- The reporter lives at `reporter/test_samurai_jest_reporter.js` and is loaded via `--reporters`.
- Test names are reported as `Describe/It` for grouping in the listing.

View File

@@ -0,0 +1,660 @@
local runner = {
name = "jest",
framework = "javascript",
}
local RESULT_PREFIX = "TSAMURAI_RESULT "
local STATUS_MAP = {
passed = "passes",
failed = "failures",
skipped = "skips",
pending = "skips",
todo = "skips",
}
runner._last_locations = {}
runner._last_jest_names = {}
local function get_buf_path(bufnr)
return vim.api.nvim_buf_get_name(bufnr)
end
local function get_buf_lines(bufnr)
return vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
end
local function find_root(path, markers)
if not path or path == "" then
return nil
end
local dir = vim.fs.dirname(path)
if not dir or dir == "" then
return nil
end
local found = vim.fs.find(markers, { path = dir, upward = true })
if not found or not found[1] then
return nil
end
return vim.fs.dirname(found[1])
end
local function count_char(line, ch)
local count = 0
for i = 1, #line do
if line:sub(i, i) == ch then
count = count + 1
end
end
return count
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
end
end
end
end
end
return start_idx
end
local function match_call_name(lines, idx, keywords)
local line = lines[idx] or ""
for _, key in ipairs(keywords) do
local pattern = "%f[%w_]" .. key .. "[%w_%.]*%s*%(%s*['\"]([^'\"]+)['\"]"
local name = line:match(pattern)
if name and name ~= "" then
return name
end
local has_call = line:match("%f[%w_]" .. key .. "[%w_%.]*%s*%(")
if has_call then
local max_idx = math.min(#lines, idx + 3)
for j = idx + 1, max_idx do
local next_line = lines[j] or ""
local next_name = next_line:match("['\"]([^'\"]+)['\"]")
if next_name and next_name ~= "" then
return next_name
end
if next_line:find("%)") then
break
end
end
end
end
return nil
end
local function escape_regex(text)
text = text or ""
return (text:gsub("([\\.^$|()%%[%]{}*+?%-])", "\\\\%1"))
end
local function build_jest_pattern(parts)
local escaped = {}
for _, part in ipairs(parts) do
table.insert(escaped, escape_regex(part))
end
return "^.*" .. escape_regex(parts[#parts] or "") .. "$"
end
local function build_jest_prefix_pattern(parts)
local tokens = {}
for _, part in ipairs(parts or {}) do
for token in part:gmatch("%w+") do
table.insert(tokens, token)
end
end
if #tokens == 0 then
return escape_regex(parts[#parts] or "") .. ".*"
end
return table.concat(tokens, ".*") .. ".*"
end
local function to_jest_full_name(name)
if not name or name == "" then
return name
end
if not name:find("/", 1, true) then
return name
end
local parts = vim.split(name, "/", { plain = true, trimempty = true })
return table.concat(parts, " ")
end
local function find_tests(lines)
local tests = {}
local describes = {}
for i, _line in ipairs(lines) do
local describe_name = match_call_name(lines, i, { "describe", "context" })
if describe_name then
local start_idx = i
local end_idx = find_block_end(lines, start_idx)
table.insert(describes, {
name = describe_name,
start = start_idx - 1,
["end"] = end_idx - 1,
})
end
local test_name = match_call_name(lines, i, { "test", "it" })
if test_name then
local start_idx = i
local end_idx = find_block_end(lines, start_idx)
table.insert(tests, {
name = test_name,
start = start_idx - 1,
["end"] = end_idx - 1,
})
end
end
local function describe_chain(at_start)
local parents = {}
for _, describe in ipairs(describes) do
if at_start >= describe.start and at_start <= describe["end"] then
table.insert(parents, describe)
end
end
table.sort(parents, function(a, b)
if a.start == b.start then
return a["end"] < b["end"]
end
return a.start < b.start
end)
return parents
end
for _, test in ipairs(tests) do
local parents = describe_chain(test.start)
local parts = {}
for _, parent in ipairs(parents) do
table.insert(parts, parent.name)
end
table.insert(parts, test.name)
test.full_name = table.concat(parts, "/")
test.jest_name = table.concat(parts, " ")
test.jest_parts = parts
end
for _, describe in ipairs(describes) do
local parents = describe_chain(describe.start)
local parts = {}
for _, parent in ipairs(parents) do
table.insert(parts, parent.name)
end
describe.full_name = table.concat(parts, "/")
describe.jest_name = table.concat(parts, " ")
describe.jest_parts = parts
end
return tests, describes
end
local function test_file_path(path)
if not path or path == "" then
return false
end
return path:match("%.test%.[jt]sx?$") ~= nil
or path:match("%.spec%.[jt]sx?$") ~= nil
or path:match("%.test%.mjs$") ~= nil
or path:match("%.spec%.mjs$") ~= nil
or path:match("%.test%.cjs$") ~= nil
or path:match("%.spec%.cjs$") ~= nil
end
local function collect_unique(list)
local out = {}
local seen = {}
for _, item in ipairs(list) do
if item and item ~= "" and not seen[item] then
seen[item] = true
table.insert(out, item)
end
end
return out
end
local function order_by_root(names)
local roots = {}
local seen_root = {}
local buckets = {}
for _, name in ipairs(names) do
local root = name:match("^[^/]+") or name
if not seen_root[root] then
seen_root[root] = true
table.insert(roots, root)
end
buckets[root] = buckets[root] or { main = nil, subs = {} }
if name == root then
buckets[root].main = name
else
table.insert(buckets[root].subs, name)
end
end
local ordered = {}
for _, root in ipairs(roots) do
local bucket = buckets[root]
if bucket.main then
table.insert(ordered, bucket.main)
end
for _, sub in ipairs(bucket.subs) do
table.insert(ordered, sub)
end
end
return ordered
end
local function order_with_display(names, display_map)
local ordered = order_by_root(names)
local display = {}
for _, name in ipairs(ordered) do
display[#display + 1] = display_map[name] or name
end
return ordered, display
end
local function split_output_lines(text)
if not text or text == "" then
return {}
end
local lines = vim.split(text, "\n", { plain = true })
if #lines > 0 and lines[#lines] == "" then
table.remove(lines, #lines)
end
return lines
end
local function reporter_path()
local source = debug.getinfo(1, "S").source
if source:sub(1, 1) == "@" then
source = source:sub(2)
end
local dir = vim.fs.dirname(source)
return vim.fs.normalize(dir .. "/../../reporter/test_samurai_jest_reporter.js")
end
local function base_cmd()
return {
"npx",
"jest",
"--testLocationInResults",
"--reporters",
reporter_path(),
}
end
local function parse_result_line(line)
if not line or line == "" then
return nil
end
if line:sub(1, #RESULT_PREFIX) ~= RESULT_PREFIX then
return nil
end
local payload = line:sub(#RESULT_PREFIX + 1)
local ok, data = pcall(vim.json.decode, payload)
if not ok or type(data) ~= "table" then
return nil
end
return data
end
local function update_location_cache(name, data)
if not name or name == "" then
return
end
local location = data.location
if type(location) ~= "table" or not data.file then
return
end
runner._last_locations[name] = {
filename = data.file,
lnum = location.line or 1,
col = location.column or 1,
text = name,
}
if data.jestName and data.jestName ~= "" then
runner._last_jest_names[name] = data.jestName
end
end
function runner.is_test_file(bufnr)
local path = get_buf_path(bufnr)
return test_file_path(path)
end
function runner.find_nearest(bufnr, row, _col)
local path = get_buf_path(bufnr)
if not path or path == "" then
return nil, "no file name"
end
if not test_file_path(path) then
return nil, "not a jest test file"
end
local lines = get_buf_lines(bufnr)
local tests, describes = find_tests(lines)
local nearest = nil
for _, test in ipairs(tests) do
if row >= test.start and row <= test["end"] then
nearest = test
end
end
if not nearest then
local describe_match = nil
for _, describe in ipairs(describes) do
if row >= describe.start and row <= describe["end"] then
if not describe_match or describe.start >= describe_match.start then
describe_match = describe
end
end
end
if describe_match then
local cwd = find_root(path, {
"package.json",
"jest.config.js",
"jest.config.ts",
"jest.config.cjs",
"jest.config.mjs",
"jest.config.json",
})
return {
file = path,
cwd = cwd,
test_name = describe_match.name,
full_name = describe_match.full_name,
jest_name = describe_match.jest_name,
jest_parts = describe_match.jest_parts,
kind = "describe",
}
end
local cwd = find_root(path, {
"package.json",
"jest.config.js",
"jest.config.ts",
"jest.config.cjs",
"jest.config.mjs",
"jest.config.json",
})
return {
file = path,
cwd = cwd,
kind = "file",
}
end
local cwd = find_root(path, {
"package.json",
"jest.config.js",
"jest.config.ts",
"jest.config.cjs",
"jest.config.mjs",
"jest.config.json",
})
return {
file = path,
cwd = cwd,
test_name = nearest.name,
full_name = nearest.full_name,
jest_name = nearest.jest_name,
jest_parts = nearest.jest_parts,
kind = "test",
}
end
function runner.build_command(spec)
local file = spec.file
if not file or file == "" then
return { cmd = base_cmd(), cwd = spec.cwd }
end
if spec.kind == "file" then
local cmd = base_cmd()
table.insert(cmd, "--runTestsByPath")
table.insert(cmd, file)
return { cmd = cmd, cwd = spec.cwd }
end
local cmd = base_cmd()
table.insert(cmd, "--runTestsByPath")
table.insert(cmd, file)
local ok, pattern = pcall(function()
if type(spec.jest_parts) == "table" and #spec.jest_parts > 0 then
if spec.kind == "describe" then
return build_jest_prefix_pattern(spec.jest_parts)
end
return build_jest_pattern(spec.jest_parts)
end
local name = spec.jest_name or spec.test_name
if name and name ~= "" then
return "^" .. escape_regex(name) .. "$"
end
return nil
end)
if not ok then
pattern = nil
end
if pattern then
table.insert(cmd, "--testNamePattern")
table.insert(cmd, pattern)
end
return { cmd = cmd, cwd = spec.cwd }
end
function runner.build_file_command(bufnr)
local path = get_buf_path(bufnr)
local cwd = find_root(path, {
"package.json",
"jest.config.js",
"jest.config.ts",
"jest.config.cjs",
"jest.config.mjs",
"jest.config.json",
})
local cmd = base_cmd()
table.insert(cmd, "--runTestsByPath")
table.insert(cmd, path)
return { cmd = cmd, cwd = cwd }
end
function runner.build_all_command(bufnr)
local path = get_buf_path(bufnr)
local cwd = find_root(path, {
"package.json",
"jest.config.js",
"jest.config.ts",
"jest.config.cjs",
"jest.config.mjs",
"jest.config.json",
})
local cmd = base_cmd()
return { cmd = cmd, cwd = cwd }
end
function runner.build_failed_command(last_command, failures, _scope_kind)
if not failures or #failures == 0 then
if last_command and last_command.cmd then
return { cmd = last_command.cmd, cwd = last_command.cwd }
end
return { cmd = base_cmd() }
end
local pattern_parts = {}
for _, name in ipairs(failures or {}) do
if name and name ~= "" then
local jest_name = runner._last_jest_names[name] or to_jest_full_name(name)
table.insert(pattern_parts, "^" .. escape_regex(jest_name) .. "$")
end
end
local pattern = "(" .. table.concat(pattern_parts, "|") .. ")"
local cmd = {}
local skip_next = false
for _, arg in ipairs(last_command and last_command.cmd or {}) do
if skip_next then
skip_next = false
elseif arg == "--testNamePattern" then
skip_next = true
else
table.insert(cmd, arg)
end
end
if #cmd == 0 then
cmd = base_cmd()
end
table.insert(cmd, "--testNamePattern")
table.insert(cmd, pattern)
return {
cmd = cmd,
cwd = last_command and last_command.cwd or nil,
}
end
function runner.parse_results(output)
runner._last_locations = {}
runner._last_jest_names = {}
if not output or output == "" then
return { passes = {}, failures = {}, skips = {}, display = { passes = {}, failures = {}, skips = {} } }
end
local passes = {}
local failures = {}
local skips = {}
local display = { passes = {}, failures = {}, skips = {} }
local pass_display = {}
local fail_display = {}
local skip_display = {}
local seen = {
passes = {},
failures = {},
skips = {},
}
for line in output:gmatch("[^\n]+") do
local data = parse_result_line(line)
if data and data.name and data.status then
local kind = STATUS_MAP[data.status]
if kind and not seen[kind][data.name] then
seen[kind][data.name] = true
table.insert(kind == "passes" and passes or kind == "failures" and failures or skips, data.name)
if kind == "passes" then
pass_display[data.name] = data.display or data.name
elseif kind == "failures" then
fail_display[data.name] = data.display or data.name
else
skip_display[data.name] = data.display or data.name
end
update_location_cache(data.name, data)
end
end
end
passes = collect_unique(passes)
failures = collect_unique(failures)
skips = collect_unique(skips)
passes, display.passes = order_with_display(passes, pass_display)
failures, display.failures = order_with_display(failures, fail_display)
skips, display.skips = order_with_display(skips, skip_display)
return { passes = passes, failures = failures, skips = skips, display = display }
end
function runner.output_parser()
runner._last_locations = {}
runner._last_jest_names = {}
return {
on_line = function(line, state)
local data = parse_result_line(line)
if not data or not data.name or not data.status then
return nil
end
local kind = STATUS_MAP[data.status]
if not kind then
return nil
end
state.jest = state.jest or { failures_all = {}, failures_seen = {} }
local results = {
passes = {},
failures = {},
skips = {},
display = { passes = {}, failures = {}, skips = {} },
}
if kind == "passes" then
results.passes = { data.name }
results.display.passes = { data.display or data.name }
elseif kind == "failures" then
results.failures = { data.name }
results.display.failures = { data.display or data.name }
if not state.jest.failures_seen[data.name] then
state.jest.failures_seen[data.name] = true
table.insert(state.jest.failures_all, data.name)
end
results.failures_all = vim.deepcopy(state.jest.failures_all)
else
results.skips = { data.name }
results.display.skips = { data.display or data.name }
end
update_location_cache(data.name, data)
return results
end,
on_complete = function(output, state)
return nil
end,
}
end
function runner.parse_test_output(output)
local out = {}
if not output or output == "" then
return out
end
for line in output:gmatch("[^\n]+") do
local data = parse_result_line(line)
if data and data.name and data.output then
out[data.name] = out[data.name] or {}
if type(data.output) == "string" then
for _, item in ipairs(split_output_lines(data.output)) do
table.insert(out[data.name], item)
end
elseif type(data.output) == "table" then
for _, item in ipairs(data.output) do
if item and item ~= "" then
table.insert(out[data.name], item)
end
end
end
end
end
return out
end
function runner.collect_failed_locations(failures, _command, _scope_kind)
if type(failures) ~= "table" or #failures == 0 then
return {}
end
local items = {}
local seen = {}
for _, name in ipairs(failures) do
local loc = runner._last_locations[name]
if loc then
local key = string.format("%s:%d:%d:%s", loc.filename or "", loc.lnum or 0, loc.col or 0, name)
if not seen[key] then
seen[key] = true
table.insert(items, loc)
end
end
end
return items
end
return runner

View File

@@ -0,0 +1,52 @@
class TestSamuraiJestReporter {
constructor(_globalConfig, _options) {}
onTestCaseResult(test, testCaseResult) {
const name = this.buildListingName(testCaseResult);
const jestName = this.buildJestName(testCaseResult);
const payload = {
name,
jestName,
status: testCaseResult.status,
file: test && test.path ? test.path : null,
location: testCaseResult.location || null,
output: this.buildOutput(testCaseResult),
};
process.stdout.write(`TSAMURAI_RESULT ${JSON.stringify(payload)}\n`);
}
buildListingName(testCaseResult) {
const ancestors = Array.isArray(testCaseResult.ancestorTitles)
? testCaseResult.ancestorTitles
: [];
const title = testCaseResult.title ? String(testCaseResult.title) : "";
return [...ancestors, title].filter(Boolean).join("/");
}
buildJestName(testCaseResult) {
const ancestors = Array.isArray(testCaseResult.ancestorTitles)
? testCaseResult.ancestorTitles
: [];
const title = testCaseResult.title ? String(testCaseResult.title) : "";
return [...ancestors, title].filter(Boolean).join(" ");
}
buildOutput(testCaseResult) {
const lines = [];
const failures = Array.isArray(testCaseResult.failureMessages)
? testCaseResult.failureMessages
: [];
failures.forEach((message) => {
if (typeof message === "string" && message.length > 0) {
message.split("\n").forEach((line) => {
if (line !== "") {
lines.push(line);
}
});
}
});
return lines;
}
}
module.exports = TestSamuraiJestReporter;

3
run_test.sh Normal file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
nvim --headless -u tests/minimal_init.lua -c "PlenaryBustedDirectory tests" -c qa

15
tests/minimal_init.lua Normal file
View File

@@ -0,0 +1,15 @@
vim.cmd("set rtp^=.")
local data_path = vim.fn.stdpath("data")
local plenary_paths = {
data_path .. "/site/pack/packer/start/plenary.nvim",
data_path .. "/lazy/plenary.nvim",
}
for _, path in ipairs(plenary_paths) do
if vim.fn.isdirectory(path) == 1 then
vim.cmd("set rtp^=" .. path)
end
end
vim.cmd("runtime! plugin/plenary.vim")

View File

@@ -0,0 +1,409 @@
local runner = require("test-samurai-jest-runner")
local function get_reporter_path()
local source = debug.getinfo(runner.build_command, "S").source
if source:sub(1, 1) == "@" then
source = source:sub(2)
end
local dir = vim.fs.dirname(source)
return vim.fs.normalize(dir .. "/../../reporter/test_samurai_jest_reporter.js")
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))
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 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))
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)
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)
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)
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(",
" '<Header/>',",
" () => {",
" 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)
vim.fs.find = orig_fs_find
assert.is_nil(err)
assert.equals("the teaser links", spec.test_name)
assert.equals("<Header/>/renders properly.../the teaser links", spec.full_name)
assert.are.same({ "<Header/>", "renders properly...", "the teaser links" }, spec.jest_parts)
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('<Header/>', () => {",
" 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)
vim.fs.find = orig_fs_find
assert.is_nil(err)
assert.equals("renders properly...", spec.test_name)
assert.equals("<Header/>/renders properly...", spec.full_name)
assert.are.same({ "<Header/>", "renders properly..." }, spec.jest_parts)
assert.equals("describe", spec.kind)
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)
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 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)
end)
it("build_command uses npx jest with reporter and pattern", function()
local spec = {
file = "/tmp/math.test.js",
cwd = "/tmp",
full_name = "Math/adds",
jest_parts = { "Math", "adds" },
}
local cmd_spec = runner.build_command(spec)
assert.are.same(
{
"npx",
"jest",
"--testLocationInResults",
"--reporters",
get_reporter_path(),
"--runTestsByPath",
"/tmp/math.test.js",
"--testNamePattern",
"^.*adds$",
},
cmd_spec.cmd
)
assert.equals("/tmp", cmd_spec.cwd)
end)
it("build_command uses prefix pattern for describe blocks", function()
local spec = {
file = "/tmp/math.test.js",
cwd = "/tmp",
kind = "describe",
jest_parts = { "<Header/>", "renders properly..." },
}
local cmd_spec = runner.build_command(spec)
local pattern = cmd_spec.cmd[#cmd_spec.cmd]
assert.are.same(
{
"npx",
"jest",
"--testLocationInResults",
"--reporters",
get_reporter_path(),
"--runTestsByPath",
"/tmp/math.test.js",
"--testNamePattern",
pattern,
},
cmd_spec.cmd
)
assert.is_true(pattern:find("Header", 1, true) ~= nil)
assert.is_true(pattern:find("renders", 1, true) ~= nil)
assert.is_true(pattern:find("properly", 1, true) ~= nil)
assert.is_true(pattern:sub(-2) == ".*")
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")
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)
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)
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")
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)
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)
end)
it("build_failed_command narrows to failed tests", function()
local last_command = {
cmd = {
"npx",
"jest",
"--testLocationInResults",
"--reporters",
get_reporter_path(),
"--runTestsByPath",
"/tmp/math.test.js",
"--testNamePattern",
"^Old$",
},
cwd = "/tmp",
}
local failures = { "Math/adds", "edge (1+1)" }
runner.parse_results(table.concat({
"TSAMURAI_RESULT " .. vim.json.encode({
name = "Math/adds",
jestName = "Math adds",
status = "failed",
}),
"TSAMURAI_RESULT " .. vim.json.encode({
name = "edge (1+1)",
jestName = "edge (1+1)",
status = "failed",
}),
}, "\n"))
local cmd_spec = runner.build_failed_command(last_command, failures, "file")
local pattern = cmd_spec.cmd[#cmd_spec.cmd]
assert.are.same(
{
"npx",
"jest",
"--testLocationInResults",
"--reporters",
get_reporter_path(),
"--runTestsByPath",
"/tmp/math.test.js",
"--testNamePattern",
pattern,
},
cmd_spec.cmd
)
assert.is_true(pattern:match("%^Math adds%$") ~= nil)
assert.is_true(pattern:match("edge") ~= nil)
assert.is_true(pattern:find("\\(1", 1, true) ~= nil)
assert.is_true(pattern:find("\\+1", 1, true) ~= nil)
assert.equals("/tmp", cmd_spec.cwd)
end)
it("parse_results collects statuses and locations", function()
local output = table.concat({
"TSAMURAI_RESULT " .. vim.json.encode({
name = "Math/adds",
status = "passed",
file = "/tmp/math.test.js",
location = { line = 3, column = 4 },
}),
"TSAMURAI_RESULT " .. vim.json.encode({
name = "Math/subtracts",
status = "failed",
file = "/tmp/math.test.js",
location = { line = 10, column = 2 },
}),
"TSAMURAI_RESULT " .. vim.json.encode({
name = "Math/skipped",
status = "skipped",
file = "/tmp/math.test.js",
location = { line = 20, column = 2 },
}),
}, "\n")
local results = runner.parse_results(output)
assert.are.same({ "Math/adds" }, results.passes)
assert.are.same({ "Math/subtracts" }, results.failures)
assert.are.same({ "Math/skipped" }, results.skips)
assert.are.same({ "Math/adds" }, results.display.passes)
assert.are.same({ "Math/subtracts" }, results.display.failures)
assert.are.same({ "Math/skipped" }, results.display.skips)
end)
it("output_parser streams per test case", function()
local parser = runner.output_parser()
local state = {}
local line = "TSAMURAI_RESULT " .. vim.json.encode({
name = "Math/adds",
status = "failed",
file = "/tmp/math.test.js",
location = { line = 3, column = 4 },
})
local results = parser.on_line(line, state)
assert.are.same({ "Math/adds" }, results.failures)
assert.are.same({ "Math/adds" }, results.display.failures)
assert.are.same({ "Math/adds" }, results.failures_all)
assert.is_nil(parser.on_complete("", state))
end)
it("parse_test_output groups output per test", function()
local output = table.concat({
"TSAMURAI_RESULT " .. vim.json.encode({
name = "Math/adds",
status = "failed",
output = { "line1", "line2" },
}),
"TSAMURAI_RESULT " .. vim.json.encode({
name = "Math/adds",
status = "failed",
output = { "line3" },
}),
}, "\n")
local results = runner.parse_test_output(output)
assert.are.same({ "line1", "line2", "line3" }, results["Math/adds"])
end)
it("collect_failed_locations uses cached locations", function()
local output = "TSAMURAI_RESULT " .. vim.json.encode({
name = "Math/adds",
status = "failed",
file = "/tmp/math.test.js",
location = { line = 8, column = 2 },
})
runner.parse_results(output)
local items = runner.collect_failed_locations({ "Math/adds" }, nil, "file")
assert.equals(1, #items)
assert.equals("/tmp/math.test.js", items[1].filename)
assert.equals(8, items[1].lnum)
assert.equals(2, items[1].col)
end)
end)