From 0c0f01efbe56fa0bed914465d01a6624db70bdc4 Mon Sep 17 00:00:00 2001 From: "M.Schirmer" Date: Sun, 4 Jan 2026 13:24:41 +0100 Subject: [PATCH] create runner with ChatGPT-Codex by using the runner-agents.md from the core --- .gitea/workflows/tests.yml | 20 + AGENTS.md | 247 ++++++++++++ README.md | 61 +++ lua/test-samurai-mocha-runner/init.lua | 503 +++++++++++++++++++++++++ run_test.sh | 4 + tests/run.lua | 252 +++++++++++++ 6 files changed, 1087 insertions(+) create mode 100644 .gitea/workflows/tests.yml create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 lua/test-samurai-mocha-runner/init.lua create mode 100755 run_test.sh create mode 100644 tests/run.lua diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml new file mode 100644 index 0000000..dd0dcf6 --- /dev/null +++ b/.gitea/workflows/tests.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0d206d7 --- /dev/null +++ b/AGENTS.md @@ -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 `` im Listing). +- `collect_failed_locations(failures, command, scope_kind) -> items` + - Quickfix-Unterstützung (Keymap `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 / 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 = , col = , 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 `nf`/`pf` genutzt. +- `items` (für Quickfix): + - `{ { filename = "...", lnum = 1, col = 1, text = "..." }, ... }` + - Wird von `qn` verwendet. + +## Keymaps (Datenlieferung) + +- `nf` / `pf` + - benötigt `[ FAIL ]`-Einträge im Listing. + - Runner muss `results.failures` (und optional `display.failures`) liefern. +- `qn` + - springt in die Quickfix-Liste. + - Runner muss `collect_failed_locations` implementieren und gültige `items` liefern. +- `` 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..121ae0e --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/lua/test-samurai-mocha-runner/init.lua b/lua/test-samurai-mocha-runner/init.lua new file mode 100644 index 0000000..f18dd9b --- /dev/null +++ b/lua/test-samurai-mocha-runner/init.lua @@ -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 diff --git a/run_test.sh b/run_test.sh new file mode 100755 index 0000000..719706e --- /dev/null +++ b/run_test.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +nvim --headless -u NONE -c "lua dofile('tests/run.lua')" -c "qa" diff --git a/tests/run.lua b/tests/run.lua new file mode 100644 index 0000000..82080fe --- /dev/null +++ b/tests/run.lua @@ -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. (/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. (/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