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

This commit is contained in:
2026-01-04 13:24:41 +01:00
commit 0c0f01efbe
6 changed files with 1087 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

247
AGENTS.md Normal file
View File

@@ -0,0 +1,247 @@
# 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
```
## 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.

61
README.md Normal file
View File

@@ -0,0 +1,61 @@
# test-samurai-mocha-runner
Mocha.js runner for the test-samurai Neovim plugin.
## Features
- Detects Mocha test files by checking `package.json` dependencies.
- Supports nearest, file, all, and failed-only commands.
- Streams results via Mocha's `json-stream` reporter.
- Provides quickfix locations and per-test output.
- Uses `--grep` with escaped patterns to match titles safely, even when running through `npm test`.
- Executes tests via `npx mocha` for direct Mocha invocation.
- `TSamAll` runs `test/**/*.{test,spec}.{t,j}s` to discover tests reliably.
## Full Name Convention
The runner builds stable full names using the active suite stack joined by `/`,
followed by the test title:
```
Suite/Subsuite/Test
```
This convention is used consistently in `results.*`, `parse_test_output`, and
`collect_failed_locations`. Failed-only runs translate `/` back to spaces for
Mocha's `--grep` matching. Avoid `/` in your titles if you rely on that mapping.
## Reporter Payload
The runner expects Mocha's built-in `json-stream` reporter, which emits one JSON
object per line. It consumes the following fields when present:
```
{
"event": "suite" | "suite end" | "pass" | "fail" | "pending",
"title": "Test or suite title",
"fullTitle": "Mocha full title",
"file": "/path/to/test.js",
"err": { "message": "string", "stack": "string" }
}
```
## Usage
Add the module to your test-samurai configuration:
```lua
require("test-samurai").setup({
runner_modules = {
"test-samurai-mocha-runner",
},
})
```
## Development
Run tests:
```bash
bash run_test.sh
```

View File

@@ -0,0 +1,503 @@
local runner = {
name = "test-samurai-mocha-runner",
framework = "javascript",
}
local function read_file(path)
local ok, lines = pcall(vim.fn.readfile, path)
if not ok then
return nil
end
return table.concat(lines, "\n")
end
local function find_project_root(start_path)
if not start_path or start_path == "" then
return nil
end
local dir = vim.fn.fnamemodify(start_path, ":h")
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.fn.fnamemodify(dir, ":h")
end
return nil
end
local function load_package_json(root)
if not root then
return nil
end
local content = read_file(root .. "/package.json")
if not content then
return nil
end
local ok, data = pcall(vim.json.decode, content)
if not ok then
return nil
end
return data
end
local function has_mocha_dependency(pkg)
if not pkg then
return false
end
local deps = pkg.dependencies or {}
local dev_deps = pkg.devDependencies or {}
return deps.mocha ~= nil or dev_deps.mocha ~= nil
end
local function count_char(line, char)
local _, count = line:gsub(char, "")
return count
end
local function extract_title(line, names)
for _, name in ipairs(names) do
local pattern = name .. "%s*%(%s*(['\"])(.-)%1"
local _, title = line:match(pattern)
if title and title ~= "" then
return name, title
end
end
return nil, nil
end
local function build_full_name(suites, title)
if #suites == 0 then
return title
end
return table.concat(suites, "/") .. "/" .. title
end
local function build_mocha_title(suites, title)
if #suites == 0 then
return title
end
return table.concat(suites, " ") .. " " .. title
end
local function parse_buffer_scope(lines, row)
local depth = 0
local stack = {}
local suite_names = { "describe", "context" }
local test_names = { "it", "test" }
for i = 1, row do
local line = lines[i] or ""
local kind, title = extract_title(line, suite_names)
if title then
local opens = count_char(line, "{")
local closes = count_char(line, "}")
local new_depth = depth + opens - closes
table.insert(stack, { kind = "suite", title = title, depth = new_depth, line = i })
depth = new_depth
else
kind, title = extract_title(line, test_names)
local opens = count_char(line, "{")
local closes = count_char(line, "}")
local new_depth = depth + opens - closes
if title then
table.insert(stack, { kind = "test", title = title, depth = new_depth, line = i })
end
depth = new_depth
end
while #stack > 0 and stack[#stack].depth > depth do
table.remove(stack)
end
end
return stack
end
local function suite_titles_from_stack(stack)
local suites = {}
for _, entry in ipairs(stack) do
if entry.kind == "suite" then
table.insert(suites, entry.title)
end
end
return suites
end
local function find_last_of_kind(stack, kind)
for i = #stack, 1, -1 do
if stack[i].kind == kind then
return stack[i]
end
end
return nil
end
local function build_grep_pattern(title)
local pattern = title or ""
pattern = pattern:gsub("%s+", ".*")
return pattern
end
local function ensure_state(state)
state.results = state.results or { passes = {}, failures = {}, skips = {} }
state.seen = state.seen or { passes = {}, failures = {}, skips = {} }
state.outputs = state.outputs or {}
state.locations = state.locations or {}
state.suite_stack = state.suite_stack or {}
state.mocha_titles = state.mocha_titles or {}
return state
end
local function extract_location(stack)
if not stack then
return nil
end
local filename, lnum, col = stack:match("%(([^%s%(%):]+):(%d+):(%d+)%)")
if not filename then
filename, lnum, col = stack:match("at%s+[^%s]+%s+([^%s%(%):]+):(%d+):(%d+)")
end
if not filename then
filename, lnum, col = stack:match("([^%s%(%):]+):(%d+):(%d+)")
end
if not filename then
return nil
end
return {
filename = filename,
lnum = tonumber(lnum),
col = tonumber(col),
}
end
local function record_result(state, event, title, payload)
local full_name = build_full_name(state.suite_stack, title)
local mocha_title = build_mocha_title(state.suite_stack, title)
state.mocha_titles[full_name] = mocha_title
local added = false
if event == "pass" then
if not state.seen.passes[full_name] then
state.seen.passes[full_name] = true
table.insert(state.results.passes, full_name)
added = true
end
elseif event == "fail" then
if not state.seen.failures[full_name] then
state.seen.failures[full_name] = true
table.insert(state.results.failures, full_name)
added = true
end
elseif event == "pending" then
if not state.seen.skips[full_name] then
state.seen.skips[full_name] = true
table.insert(state.results.skips, full_name)
added = true
end
end
if event == "fail" and payload then
local lines = {}
local message = nil
local stack = nil
if type(payload.err) == "table" then
message = payload.err.message
stack = payload.err.stack
elseif type(payload.err) == "string" then
message = payload.err
end
if type(payload.stack) == "string" and payload.stack ~= "" then
stack = payload.stack
end
if message and message ~= "" then
table.insert(lines, message)
end
if stack and stack ~= "" then
for _, line in ipairs(vim.split(stack, "\n")) do
table.insert(lines, line)
end
end
if #lines > 0 then
state.outputs[full_name] = lines
end
local location = extract_location(stack or "")
if location then
location.text = message or "test failure"
state.locations[full_name] = location
end
end
if added then
return full_name
end
return nil
end
local function parse_json_stream_line(line, state)
local ok, payload = pcall(vim.json.decode, line)
if not ok or type(payload) ~= "table" then
return nil
end
local event = payload.event
local data = payload
if event == nil and payload[1] then
event = payload[1]
data = payload[2] or {}
end
if event == "suite" then
if data.title and data.title ~= "" then
table.insert(state.suite_stack, data.title)
end
elseif event == "suite end" then
if data.title and data.title ~= "" and #state.suite_stack > 0 then
table.remove(state.suite_stack)
end
elseif event == "pass" or event == "fail" or event == "pending" then
local name = record_result(state, event, data.title or "", data)
if name then
return { event = event, name = name }
end
end
return data
end
local function parse_json_stream_output(output)
local state = ensure_state({})
for line in output:gmatch("[^\n]+") do
parse_json_stream_line(line, state)
end
return state
end
local function project_root_for_buf(bufnr)
local file = vim.api.nvim_buf_get_name(bufnr)
if file == "" then
return nil
end
return find_project_root(file)
end
function runner.is_test_file(bufnr)
local file = vim.api.nvim_buf_get_name(bufnr)
if file == "" then
return false
end
local root = find_project_root(file)
if not root then
return false
end
local pkg = load_package_json(root)
return has_mocha_dependency(pkg)
end
function runner.find_nearest(bufnr, row, _col)
local file = vim.api.nvim_buf_get_name(bufnr)
if file == "" then
return nil, "buffer has no name"
end
local root = find_project_root(file)
if not root then
return nil, "no package.json found"
end
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local stack = parse_buffer_scope(lines, row)
local suites = suite_titles_from_stack(stack)
local test_block = find_last_of_kind(stack, "test")
if test_block then
local full_name = build_full_name(suites, test_block.title)
return {
file = file,
cwd = root,
test_name = test_block.title,
full_name = full_name,
mocha_full_title = build_mocha_title(suites, test_block.title),
kind = "test",
}
end
local suite_block = find_last_of_kind(stack, "suite")
if suite_block then
local full_name = table.concat(suites, "/")
return {
file = file,
cwd = root,
test_name = suite_block.title,
full_name = full_name,
mocha_full_title = table.concat(suites, " "),
kind = "suite",
}
end
return {
file = file,
cwd = root,
kind = "file",
}
end
function runner.build_command(spec)
if not spec.cwd or spec.cwd == "" then
return { cmd = { "echo", "no package.json found" } }
end
if spec.kind == "file" then
return runner.build_file_command(spec.file)
end
local grep = spec.mocha_full_title or spec.full_name or spec.test_name
return {
cmd = {
"npx",
"mocha",
"--reporter",
"json-stream",
"--grep",
build_grep_pattern(grep),
spec.file,
},
cwd = spec.cwd,
}
end
function runner.build_file_command(bufnr_or_file)
local file = bufnr_or_file
if type(bufnr_or_file) == "number" then
file = vim.api.nvim_buf_get_name(bufnr_or_file)
end
local cwd = file and find_project_root(file) or nil
if not cwd then
return { cmd = { "echo", "no package.json found" } }
end
return {
cmd = {
"npx",
"mocha",
"--reporter",
"json-stream",
file,
},
cwd = cwd,
}
end
function runner.build_all_command(bufnr)
local cwd = project_root_for_buf(bufnr)
if not cwd then
return { cmd = { "echo", "no package.json found" } }
end
return {
cmd = {
"npx",
"mocha",
"--reporter",
"json-stream",
"test/**/*.{test,spec}.{t,j}s",
},
cwd = cwd,
}
end
function runner.build_failed_command(last_command, failures, _scope_kind)
local cwd = last_command and last_command.cwd or nil
if not failures or #failures == 0 then
return {
cmd = {
"npx",
"mocha",
"--reporter",
"json-stream",
},
cwd = cwd,
}
end
local patterns = {}
for _, failure in ipairs(failures) do
local mocha_title = runner._last_mocha_titles and runner._last_mocha_titles[failure]
if not mocha_title then
mocha_title = failure:gsub("/", " ")
end
table.insert(patterns, build_grep_pattern(mocha_title))
end
return {
cmd = {
"npx",
"mocha",
"--reporter",
"json-stream",
"--grep",
table.concat(patterns, "|"),
},
cwd = cwd,
}
end
function runner.parse_results(output)
local state = parse_json_stream_output(output)
return state.results
end
function runner.output_parser()
return {
on_line = function(line, state)
state = ensure_state(state or {})
local info = parse_json_stream_line(line, state)
runner._last_locations = state.locations
runner._last_mocha_titles = state.mocha_titles
if not info or not info.event or not info.name then
return nil
end
local results = { passes = {}, failures = {}, skips = {} }
if info.event == "pass" then
results.passes = { info.name }
elseif info.event == "fail" then
results.failures = { info.name }
results.failures_all = vim.deepcopy(state.results.failures)
elseif info.event == "pending" then
results.skips = { info.name }
end
return results
end,
on_complete = function(output, state)
state = ensure_state(state or {})
local parsed = parse_json_stream_output(output)
runner._last_locations = parsed.locations
runner._last_mocha_titles = parsed.mocha_titles
return nil
end,
}
end
function runner.parse_test_output(output)
local state = parse_json_stream_output(output)
return state.outputs
end
function runner.collect_failed_locations(failures, _command, _scope_kind)
local items = {}
if not failures or not runner._last_locations then
return items
end
for _, name in ipairs(failures) do
local location = runner._last_locations[name]
if location then
table.insert(items, {
filename = location.filename,
lnum = location.lnum,
col = location.col,
text = location.text or name,
})
end
end
return items
end
return runner

4
run_test.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
nvim --headless -u NONE -c "lua dofile('tests/run.lua')" -c "qa"

252
tests/run.lua Normal file
View File

@@ -0,0 +1,252 @@
local function ok(condition, message)
if not condition then
error(message or "assertion failed")
end
end
vim.opt.runtimepath:append(vim.fn.getcwd())
package.path = package.path
.. ";"
.. vim.fn.getcwd()
.. "/lua/?.lua;"
.. vim.fn.getcwd()
.. "/lua/?/init.lua"
local function eq(actual, expected, message)
if actual ~= expected then
error(message or string.format("expected %s, got %s", tostring(expected), tostring(actual)))
end
end
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 make_buffer(path, content)
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_name(bufnr, path)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, vim.split(content, "\n"))
return bufnr
end
local function with_project(structure, fn)
local root = vim.fn.tempname()
vim.fn.mkdir(root, "p")
for rel_path, content in pairs(structure) do
write_file(root .. "/" .. rel_path, content)
end
fn(root)
end
local function test_is_test_file_with_mocha_dependency()
with_project({
["package.json"] = [[{"devDependencies":{"mocha":"10.0.0"}}]],
}, function(root)
local runner = require("test-samurai-mocha-runner")
local bufnr = make_buffer(root .. "/test/sample.spec.js", "describe('x', function() {})")
ok(runner.is_test_file(bufnr), "expected mocha project to be detected")
end)
end
local function test_is_test_file_without_mocha_dependency()
with_project({
["package.json"] = [[{"devDependencies":{"jest":"29.0.0"}}]],
}, function(root)
local runner = require("test-samurai-mocha-runner")
local bufnr = make_buffer(root .. "/test/sample.spec.js", "describe('x', function() {})")
ok(not runner.is_test_file(bufnr), "expected non-mocha project to be ignored")
end)
end
local function test_find_nearest_priorities()
with_project({
["package.json"] = [[{"devDependencies":{"mocha":"10.0.0"}}]],
}, function(root)
local runner = require("test-samurai-mocha-runner")
local content = table.concat({
"describe(\"Math\", function() {",
" it(\"adds\", function() {",
" expect(1).to.equal(1)",
" })",
"",
" it(\"subs\", function() {",
" expect(1).to.equal(1)",
" })",
"})",
"",
"const value = 1",
}, "\n")
local bufnr = make_buffer(root .. "/test/math.spec.js", content)
local spec_test = assert(runner.find_nearest(bufnr, 3, 0))
eq(spec_test.kind, "test", "expected test kind")
eq(spec_test.full_name, "Math/adds", "expected full test name")
local spec_suite = assert(runner.find_nearest(bufnr, 5, 0))
eq(spec_suite.kind, "suite", "expected suite kind")
eq(spec_suite.full_name, "Math", "expected suite name")
local spec_file = assert(runner.find_nearest(bufnr, 11, 0))
eq(spec_file.kind, "file", "expected file kind")
end)
end
local function test_missing_package_json_errors()
local runner = require("test-samurai-mocha-runner")
local content = table.concat({
"describe(\"Math\", function() {",
" it(\"adds\", function() {",
" expect(1).to.equal(1)",
" })",
"})",
}, "\n")
local bufnr = make_buffer("/tmp/no-root.test.js", content)
local spec, err = runner.find_nearest(bufnr, 2, 0)
ok(spec == nil, "expected no spec without package.json")
eq(err, "no package.json found", "expected package.json error")
local command = runner.build_file_command(bufnr)
eq(command.cmd[1], "echo")
eq(command.cmd[2], "no package.json found")
end
local function test_command_building()
with_project({
["package.json"] = [[{"devDependencies":{"mocha":"10.0.0"}}]],
}, function(root)
local runner = require("test-samurai-mocha-runner")
local spec = {
file = root .. "/test/math.spec.js",
cwd = root,
kind = "test",
mocha_full_title = "Math adds",
full_name = "Math/adds",
}
local command = runner.build_command(spec)
eq(command.cmd[1], "npx")
eq(command.cmd[2], "mocha")
ok(vim.tbl_contains(command.cmd, "--reporter"), "expected reporter flag")
ok(vim.tbl_contains(command.cmd, "json-stream"), "expected json-stream reporter")
ok(vim.tbl_contains(command.cmd, "--grep"), "expected grep flag")
end)
end
local function test_build_all_command_includes_glob()
with_project({
["package.json"] = [[{"devDependencies":{"mocha":"10.0.0"}}]],
}, function(root)
local runner = require("test-samurai-mocha-runner")
local bufnr = make_buffer(root .. "/test/sample.spec.js", "")
local command = runner.build_all_command(bufnr)
ok(vim.tbl_contains(command.cmd, "test/**/*.{test,spec}.{t,j}s"), "expected test glob")
end)
end
local function test_output_parser_and_locations()
local runner = require("test-samurai-mocha-runner")
local output_lines = {
[=[["suite",{"title":"Math","fullTitle":"Math"}]]=],
[=[["pass",{"title":"adds","fullTitle":"Math adds","file":"/tmp/math.spec.js"}]]=],
[=[["pass",{"title":"adds","fullTitle":"Math adds","file":"/tmp/math.spec.js"}]]=],
[=[["fail",{"title":"subs","fullTitle":"Math subs","file":"/tmp/math.spec.js","err":"oops","stack":"Error: oops\n at Context.<anonymous> (/tmp/math.spec.js:10:5)"}]]=],
[=[["pending",{"title":"skips","fullTitle":"Math skips","file":"/tmp/math.spec.js"}]]=],
[=[["suite end",{"title":"Math","fullTitle":"Math"}]]=],
}
local parser = runner.output_parser()
local state = {}
local aggregated = { passes = {}, failures = {}, skips = {} }
for _, line in ipairs(output_lines) do
local results = parser.on_line(line, state)
if results then
for _, name in ipairs(results.passes or {}) do
table.insert(aggregated.passes, name)
end
for _, name in ipairs(results.failures or {}) do
table.insert(aggregated.failures, name)
end
for _, name in ipairs(results.skips or {}) do
table.insert(aggregated.skips, name)
end
end
end
eq(aggregated.passes[1], "Math/adds", "expected pass name")
eq(#aggregated.passes, 1, "expected no duplicate passes")
eq(aggregated.failures[1], "Math/subs", "expected failure name")
eq(aggregated.skips[1], "Math/skips", "expected skip name")
local items = runner.collect_failed_locations(aggregated.failures, {}, "nearest")
eq(items[1].filename, "/tmp/math.spec.js", "expected filename from stack")
eq(items[1].lnum, 10, "expected line from stack")
eq(items[1].col, 5, "expected col from stack")
end
local function test_failed_only_command()
local runner = require("test-samurai-mocha-runner")
runner._last_mocha_titles = {
["Math/adds"] = "Math adds",
["Math/subs"] = "Math subs",
}
local command = runner.build_failed_command({ cwd = "/tmp" }, { "Math/adds", "Math/subs" }, "file")
eq(command.cmd[1], "npx")
ok(vim.tbl_contains(command.cmd, "--grep"), "expected grep for failed-only")
local grep_index
for idx, value in ipairs(command.cmd) do
if value == "--grep" then
grep_index = idx + 1
break
end
end
ok(grep_index, "expected grep argument")
ok(command.cmd[grep_index]:match("Math%.%*adds"), "expected grep pattern for first failure")
end
local function test_parse_results_and_test_output()
local runner = require("test-samurai-mocha-runner")
local output = table.concat({
[=[["suite",{"title":"Math","fullTitle":"Math"}]]=],
[=[["fail",{"title":"subs","fullTitle":"Math subs","file":"/tmp/math.spec.js","err":"oops","stack":"Error: oops\n at Context.<anonymous> (/tmp/math.spec.js:10:5)\n at processImmediate"}]]=],
[=[["suite end",{"title":"Math","fullTitle":"Math"}]]=],
}, "\n")
local results = runner.parse_results(output)
eq(results.failures[1], "Math/subs", "expected failure from parse_results")
local outputs = runner.parse_test_output(output)
ok(outputs["Math/subs"], "expected test output for failure")
local found = false
for _, line in ipairs(outputs["Math/subs"]) do
if line:match("oops") then
found = true
break
end
end
ok(found, "expected error output line")
end
local function test_output_parser_on_complete_returns_nil()
local runner = require("test-samurai-mocha-runner")
local parser = runner.output_parser()
local state = {}
local output = [=[["pass",{"title":"adds","fullTitle":"Math adds","file":"/tmp/math.spec.js"}]]=]
local results = parser.on_complete(output, state)
ok(results == nil, "expected nil on_complete to avoid duplicate listings")
end
local tests = {
test_is_test_file_with_mocha_dependency,
test_is_test_file_without_mocha_dependency,
test_find_nearest_priorities,
test_missing_package_json_errors,
test_command_building,
test_build_all_command_includes_glob,
test_output_parser_and_locations,
test_failed_only_command,
test_parse_results_and_test_output,
test_output_parser_on_complete_returns_nil,
}
for _, test_fn in ipairs(tests) do
test_fn()
end