create runner with ChatGPT-Codex by using the runner-agents.md from the core
Some checks failed
tests / test (push) Failing after 4s
Some checks failed
tests / test (push) Failing after 4s
This commit is contained in:
20
.gitea/workflows/tests.yml
Normal file
20
.gitea/workflows/tests.yml
Normal 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
247
AGENTS.md
Normal 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
61
README.md
Normal 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
|
||||
```
|
||||
503
lua/test-samurai-mocha-runner/init.lua
Normal file
503
lua/test-samurai-mocha-runner/init.lua
Normal 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
4
run_test.sh
Executable 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
252
tests/run.lua
Normal 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
|
||||
Reference in New Issue
Block a user