From b70d26256c4007921371da0d9a2e0613b4ca5955 Mon Sep 17 00:00:00 2001 From: "M.Schirmer" Date: Sat, 3 Jan 2026 14:53:56 +0100 Subject: [PATCH] create runner with ChatGPT-Codex by using the AGENTS.md --- .gitea/workflows/tests.yaml | 20 + AGENTS.md | 239 +++++++++ README.md | 61 +++ lua/test-samurai-jest-runner/init.lua | 660 +++++++++++++++++++++++++ reporter/test_samurai_jest_reporter.js | 52 ++ run_test.sh | 3 + tests/minimal_init.lua | 15 + tests/test_jest_runner_spec.lua | 409 +++++++++++++++ 8 files changed, 1459 insertions(+) create mode 100644 .gitea/workflows/tests.yaml create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 lua/test-samurai-jest-runner/init.lua create mode 100644 reporter/test_samurai_jest_reporter.js create mode 100644 run_test.sh create mode 100644 tests/minimal_init.lua create mode 100644 tests/test_jest_runner_spec.lua diff --git a/.gitea/workflows/tests.yaml b/.gitea/workflows/tests.yaml new file mode 100644 index 0000000..dd0dcf6 --- /dev/null +++ b/.gitea/workflows/tests.yaml @@ -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..287504a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,239 @@ +# runner-agents.md — test-samurai Runner-API + +Ziel: Diese Datei beschreibt die öffentliche Runner-API, die ein neuer Runner +implementieren muss, damit alle Commands vollständig unterstützt werden. + +## Modulform (Pflicht) + +- Der Runner ist ein Lua-Modul, das eine Table mit Funktionen zurückgibt. +- Beispiel: + - `local runner = {}` + - `return runner` +- Der Modulpfad in `runner_modules` muss exakt zum Dateipfad unter `lua/` passen. + - Beispiel: `lua/test-samurai-go-runner/init.lua` -> `require("test-samurai-go-runner")`. + - `lua/init.lua` wäre `require("init")` und ist kollisionsanfällig; vermeiden. + +## Pflichtfunktionen (volle Command- und Keymap-Unterstützung) + +- `is_test_file(bufnr) -> boolean` + - Wird für die Runner-Auswahl genutzt. +- `find_nearest(bufnr, row, col) -> spec|nil, err?` + - Für `TSamNearest`. + - `spec.file` muss gesetzt sein. + - Bei Fehler/kein Treffer: `nil, "reason"` zurückgeben. +- `build_command(spec) -> command_spec` + - Für `TSamNearest`. +- `build_file_command(bufnr) -> command_spec` + - Für `TSamFile`. +- `build_all_command(bufnr) -> command_spec` + - Für `TSamAll`. +- `build_failed_command(last_command, failures, scope_kind) -> command_spec` + - Für `TSamFailedOnly`. +- `parse_results(output) -> results` + - Fallback-Parser für vollständigen Output. +- `output_parser() -> { on_line, on_complete }` + - Muss Streaming unterstützen (Listing wird live befüllt). +- `parse_test_output(output) -> table` + - Detail-Ausgabe pro Test (Detail-Float und `` 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 +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..5981fcc --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# test-samurai-jest-runner.nvim + +Jest.js runner for `test-samurai.nvim`. + +Main plugin: https://gitea.mschirmer.com/m13r/test-samurai.nvim + +## Features + +- Detects Jest test files (`*.test.*`, `*.spec.*`). +- Finds nearest `test`/`it` within `describe` blocks. +- Builds `npx jest` commands for nearest, file, all, and failed-only runs. +- Streams results via a custom Jest reporter using `onTestCaseResult`. +- Uses `--testLocationInResults` for Quickfix and `o` support. + +## Installation (lazy.nvim) + +```lua +{ + "m13r/test-samurai.nvim", + dependencies = { + "m13r/test-samurai-jest-runner.nvim", + }, + config = function() + require("test-samurai").setup({ + runners = { + require("test-samurai-jest-runner"), + }, + }) + end, +} +``` + +## Local Development (lazy.nvim) + +```lua +{ + "m13r/test-samurai.nvim", + dependencies = { + { + "test-samurai-jest-runner.nvim", + dir = "/absolute/path/to/test-samurai-jest-runner.nvim", + }, + }, + config = function() + require("test-samurai").setup({ + runners = { + require("test-samurai-jest-runner"), + }, + }) + end, +} +``` + +## Usage + +Use the standard `test-samurai.nvim` commands (e.g. `TSamNearest`, `TSamFile`, `TSamAll`, `TSamFailedOnly`). + +## Notes + +- The reporter lives at `reporter/test_samurai_jest_reporter.js` and is loaded via `--reporters`. +- Test names are reported as `Describe/It` for grouping in the listing. diff --git a/lua/test-samurai-jest-runner/init.lua b/lua/test-samurai-jest-runner/init.lua new file mode 100644 index 0000000..adb6159 --- /dev/null +++ b/lua/test-samurai-jest-runner/init.lua @@ -0,0 +1,660 @@ +local runner = { + name = "jest", + framework = "javascript", +} + +local RESULT_PREFIX = "TSAMURAI_RESULT " +local STATUS_MAP = { + passed = "passes", + failed = "failures", + skipped = "skips", + pending = "skips", + todo = "skips", +} + +runner._last_locations = {} +runner._last_jest_names = {} + +local function get_buf_path(bufnr) + return vim.api.nvim_buf_get_name(bufnr) +end + +local function get_buf_lines(bufnr) + return vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) +end + +local function find_root(path, markers) + if not path or path == "" then + return nil + end + local dir = vim.fs.dirname(path) + if not dir or dir == "" then + return nil + end + local found = vim.fs.find(markers, { path = dir, upward = true }) + if not found or not found[1] then + return nil + end + return vim.fs.dirname(found[1]) +end + +local function count_char(line, ch) + local count = 0 + for i = 1, #line do + if line:sub(i, i) == ch then + count = count + 1 + end + end + return count +end + +local function find_block_end(lines, start_idx) + local depth = 0 + local started = false + for i = start_idx, #lines do + local line = lines[i] + for j = 1, #line do + local ch = line:sub(j, j) + if ch == "{" then + depth = depth + 1 + started = true + elseif ch == "}" then + if started then + depth = depth - 1 + if depth == 0 then + return i + end + end + end + end + end + return start_idx +end + +local function match_call_name(lines, idx, keywords) + local line = lines[idx] or "" + for _, key in ipairs(keywords) do + local pattern = "%f[%w_]" .. key .. "[%w_%.]*%s*%(%s*['\"]([^'\"]+)['\"]" + local name = line:match(pattern) + if name and name ~= "" then + return name + end + local has_call = line:match("%f[%w_]" .. key .. "[%w_%.]*%s*%(") + if has_call then + local max_idx = math.min(#lines, idx + 3) + for j = idx + 1, max_idx do + local next_line = lines[j] or "" + local next_name = next_line:match("['\"]([^'\"]+)['\"]") + if next_name and next_name ~= "" then + return next_name + end + if next_line:find("%)") then + break + end + end + end + end + return nil +end + +local function escape_regex(text) + text = text or "" + return (text:gsub("([\\.^$|()%%[%]{}*+?%-])", "\\\\%1")) +end + +local function build_jest_pattern(parts) + local escaped = {} + for _, part in ipairs(parts) do + table.insert(escaped, escape_regex(part)) + end + return "^.*" .. escape_regex(parts[#parts] or "") .. "$" +end + +local function build_jest_prefix_pattern(parts) + local tokens = {} + for _, part in ipairs(parts or {}) do + for token in part:gmatch("%w+") do + table.insert(tokens, token) + end + end + if #tokens == 0 then + return escape_regex(parts[#parts] or "") .. ".*" + end + return table.concat(tokens, ".*") .. ".*" +end + +local function to_jest_full_name(name) + if not name or name == "" then + return name + end + if not name:find("/", 1, true) then + return name + end + local parts = vim.split(name, "/", { plain = true, trimempty = true }) + return table.concat(parts, " ") +end + +local function find_tests(lines) + local tests = {} + local describes = {} + for i, _line in ipairs(lines) do + local describe_name = match_call_name(lines, i, { "describe", "context" }) + if describe_name then + local start_idx = i + local end_idx = find_block_end(lines, start_idx) + table.insert(describes, { + name = describe_name, + start = start_idx - 1, + ["end"] = end_idx - 1, + }) + end + + local test_name = match_call_name(lines, i, { "test", "it" }) + if test_name then + local start_idx = i + local end_idx = find_block_end(lines, start_idx) + table.insert(tests, { + name = test_name, + start = start_idx - 1, + ["end"] = end_idx - 1, + }) + end + end + + local function describe_chain(at_start) + local parents = {} + for _, describe in ipairs(describes) do + if at_start >= describe.start and at_start <= describe["end"] then + table.insert(parents, describe) + end + end + table.sort(parents, function(a, b) + if a.start == b.start then + return a["end"] < b["end"] + end + return a.start < b.start + end) + return parents + end + + for _, test in ipairs(tests) do + local parents = describe_chain(test.start) + local parts = {} + for _, parent in ipairs(parents) do + table.insert(parts, parent.name) + end + table.insert(parts, test.name) + test.full_name = table.concat(parts, "/") + test.jest_name = table.concat(parts, " ") + test.jest_parts = parts + end + + for _, describe in ipairs(describes) do + local parents = describe_chain(describe.start) + local parts = {} + for _, parent in ipairs(parents) do + table.insert(parts, parent.name) + end + describe.full_name = table.concat(parts, "/") + describe.jest_name = table.concat(parts, " ") + describe.jest_parts = parts + end + + return tests, describes +end + +local function test_file_path(path) + if not path or path == "" then + return false + end + return path:match("%.test%.[jt]sx?$") ~= nil + or path:match("%.spec%.[jt]sx?$") ~= nil + or path:match("%.test%.mjs$") ~= nil + or path:match("%.spec%.mjs$") ~= nil + or path:match("%.test%.cjs$") ~= nil + or path:match("%.spec%.cjs$") ~= nil +end + +local function collect_unique(list) + local out = {} + local seen = {} + for _, item in ipairs(list) do + if item and item ~= "" and not seen[item] then + seen[item] = true + table.insert(out, item) + end + end + return out +end + +local function order_by_root(names) + local roots = {} + local seen_root = {} + local buckets = {} + + for _, name in ipairs(names) do + local root = name:match("^[^/]+") or name + if not seen_root[root] then + seen_root[root] = true + table.insert(roots, root) + end + buckets[root] = buckets[root] or { main = nil, subs = {} } + if name == root then + buckets[root].main = name + else + table.insert(buckets[root].subs, name) + end + end + + local ordered = {} + for _, root in ipairs(roots) do + local bucket = buckets[root] + if bucket.main then + table.insert(ordered, bucket.main) + end + for _, sub in ipairs(bucket.subs) do + table.insert(ordered, sub) + end + end + + return ordered +end + +local function order_with_display(names, display_map) + local ordered = order_by_root(names) + local display = {} + for _, name in ipairs(ordered) do + display[#display + 1] = display_map[name] or name + end + return ordered, display +end + +local function split_output_lines(text) + if not text or text == "" then + return {} + end + local lines = vim.split(text, "\n", { plain = true }) + if #lines > 0 and lines[#lines] == "" then + table.remove(lines, #lines) + end + return lines +end + +local function reporter_path() + local source = debug.getinfo(1, "S").source + if source:sub(1, 1) == "@" then + source = source:sub(2) + end + local dir = vim.fs.dirname(source) + return vim.fs.normalize(dir .. "/../../reporter/test_samurai_jest_reporter.js") +end + +local function base_cmd() + return { + "npx", + "jest", + "--testLocationInResults", + "--reporters", + reporter_path(), + } +end + +local function parse_result_line(line) + if not line or line == "" then + return nil + end + if line:sub(1, #RESULT_PREFIX) ~= RESULT_PREFIX then + return nil + end + local payload = line:sub(#RESULT_PREFIX + 1) + local ok, data = pcall(vim.json.decode, payload) + if not ok or type(data) ~= "table" then + return nil + end + return data +end + +local function update_location_cache(name, data) + if not name or name == "" then + return + end + local location = data.location + if type(location) ~= "table" or not data.file then + return + end + runner._last_locations[name] = { + filename = data.file, + lnum = location.line or 1, + col = location.column or 1, + text = name, + } + if data.jestName and data.jestName ~= "" then + runner._last_jest_names[name] = data.jestName + end +end + +function runner.is_test_file(bufnr) + local path = get_buf_path(bufnr) + return test_file_path(path) +end + +function runner.find_nearest(bufnr, row, _col) + local path = get_buf_path(bufnr) + if not path or path == "" then + return nil, "no file name" + end + if not test_file_path(path) then + return nil, "not a jest test file" + end + local lines = get_buf_lines(bufnr) + local tests, describes = find_tests(lines) + local nearest = nil + for _, test in ipairs(tests) do + if row >= test.start and row <= test["end"] then + nearest = test + end + end + if not nearest then + local describe_match = nil + for _, describe in ipairs(describes) do + if row >= describe.start and row <= describe["end"] then + if not describe_match or describe.start >= describe_match.start then + describe_match = describe + end + end + end + if describe_match then + local cwd = find_root(path, { + "package.json", + "jest.config.js", + "jest.config.ts", + "jest.config.cjs", + "jest.config.mjs", + "jest.config.json", + }) + return { + file = path, + cwd = cwd, + test_name = describe_match.name, + full_name = describe_match.full_name, + jest_name = describe_match.jest_name, + jest_parts = describe_match.jest_parts, + kind = "describe", + } + end + local cwd = find_root(path, { + "package.json", + "jest.config.js", + "jest.config.ts", + "jest.config.cjs", + "jest.config.mjs", + "jest.config.json", + }) + return { + file = path, + cwd = cwd, + kind = "file", + } + end + local cwd = find_root(path, { + "package.json", + "jest.config.js", + "jest.config.ts", + "jest.config.cjs", + "jest.config.mjs", + "jest.config.json", + }) + return { + file = path, + cwd = cwd, + test_name = nearest.name, + full_name = nearest.full_name, + jest_name = nearest.jest_name, + jest_parts = nearest.jest_parts, + kind = "test", + } +end + +function runner.build_command(spec) + local file = spec.file + if not file or file == "" then + return { cmd = base_cmd(), cwd = spec.cwd } + end + if spec.kind == "file" then + local cmd = base_cmd() + table.insert(cmd, "--runTestsByPath") + table.insert(cmd, file) + return { cmd = cmd, cwd = spec.cwd } + end + local cmd = base_cmd() + table.insert(cmd, "--runTestsByPath") + table.insert(cmd, file) + local ok, pattern = pcall(function() + if type(spec.jest_parts) == "table" and #spec.jest_parts > 0 then + if spec.kind == "describe" then + return build_jest_prefix_pattern(spec.jest_parts) + end + return build_jest_pattern(spec.jest_parts) + end + local name = spec.jest_name or spec.test_name + if name and name ~= "" then + return "^" .. escape_regex(name) .. "$" + end + return nil + end) + if not ok then + pattern = nil + end + if pattern then + table.insert(cmd, "--testNamePattern") + table.insert(cmd, pattern) + end + return { cmd = cmd, cwd = spec.cwd } +end + +function runner.build_file_command(bufnr) + local path = get_buf_path(bufnr) + local cwd = find_root(path, { + "package.json", + "jest.config.js", + "jest.config.ts", + "jest.config.cjs", + "jest.config.mjs", + "jest.config.json", + }) + local cmd = base_cmd() + table.insert(cmd, "--runTestsByPath") + table.insert(cmd, path) + return { cmd = cmd, cwd = cwd } +end + +function runner.build_all_command(bufnr) + local path = get_buf_path(bufnr) + local cwd = find_root(path, { + "package.json", + "jest.config.js", + "jest.config.ts", + "jest.config.cjs", + "jest.config.mjs", + "jest.config.json", + }) + local cmd = base_cmd() + return { cmd = cmd, cwd = cwd } +end + +function runner.build_failed_command(last_command, failures, _scope_kind) + if not failures or #failures == 0 then + if last_command and last_command.cmd then + return { cmd = last_command.cmd, cwd = last_command.cwd } + end + return { cmd = base_cmd() } + end + + local pattern_parts = {} + for _, name in ipairs(failures or {}) do + if name and name ~= "" then + local jest_name = runner._last_jest_names[name] or to_jest_full_name(name) + table.insert(pattern_parts, "^" .. escape_regex(jest_name) .. "$") + end + end + local pattern = "(" .. table.concat(pattern_parts, "|") .. ")" + + local cmd = {} + local skip_next = false + for _, arg in ipairs(last_command and last_command.cmd or {}) do + if skip_next then + skip_next = false + elseif arg == "--testNamePattern" then + skip_next = true + else + table.insert(cmd, arg) + end + end + if #cmd == 0 then + cmd = base_cmd() + end + table.insert(cmd, "--testNamePattern") + table.insert(cmd, pattern) + + return { + cmd = cmd, + cwd = last_command and last_command.cwd or nil, + } +end + +function runner.parse_results(output) + runner._last_locations = {} + runner._last_jest_names = {} + if not output or output == "" then + return { passes = {}, failures = {}, skips = {}, display = { passes = {}, failures = {}, skips = {} } } + end + local passes = {} + local failures = {} + local skips = {} + local display = { passes = {}, failures = {}, skips = {} } + local pass_display = {} + local fail_display = {} + local skip_display = {} + local seen = { + passes = {}, + failures = {}, + skips = {}, + } + for line in output:gmatch("[^\n]+") do + local data = parse_result_line(line) + if data and data.name and data.status then + local kind = STATUS_MAP[data.status] + if kind and not seen[kind][data.name] then + seen[kind][data.name] = true + table.insert(kind == "passes" and passes or kind == "failures" and failures or skips, data.name) + if kind == "passes" then + pass_display[data.name] = data.display or data.name + elseif kind == "failures" then + fail_display[data.name] = data.display or data.name + else + skip_display[data.name] = data.display or data.name + end + update_location_cache(data.name, data) + end + end + end + + passes = collect_unique(passes) + failures = collect_unique(failures) + skips = collect_unique(skips) + passes, display.passes = order_with_display(passes, pass_display) + failures, display.failures = order_with_display(failures, fail_display) + skips, display.skips = order_with_display(skips, skip_display) + + return { passes = passes, failures = failures, skips = skips, display = display } +end + +function runner.output_parser() + runner._last_locations = {} + runner._last_jest_names = {} + return { + on_line = function(line, state) + local data = parse_result_line(line) + if not data or not data.name or not data.status then + return nil + end + local kind = STATUS_MAP[data.status] + if not kind then + return nil + end + state.jest = state.jest or { failures_all = {}, failures_seen = {} } + local results = { + passes = {}, + failures = {}, + skips = {}, + display = { passes = {}, failures = {}, skips = {} }, + } + if kind == "passes" then + results.passes = { data.name } + results.display.passes = { data.display or data.name } + elseif kind == "failures" then + results.failures = { data.name } + results.display.failures = { data.display or data.name } + if not state.jest.failures_seen[data.name] then + state.jest.failures_seen[data.name] = true + table.insert(state.jest.failures_all, data.name) + end + results.failures_all = vim.deepcopy(state.jest.failures_all) + else + results.skips = { data.name } + results.display.skips = { data.display or data.name } + end + update_location_cache(data.name, data) + return results + end, + on_complete = function(output, state) + return nil + end, + } +end + +function runner.parse_test_output(output) + local out = {} + if not output or output == "" then + return out + end + for line in output:gmatch("[^\n]+") do + local data = parse_result_line(line) + if data and data.name and data.output then + out[data.name] = out[data.name] or {} + if type(data.output) == "string" then + for _, item in ipairs(split_output_lines(data.output)) do + table.insert(out[data.name], item) + end + elseif type(data.output) == "table" then + for _, item in ipairs(data.output) do + if item and item ~= "" then + table.insert(out[data.name], item) + end + end + end + end + end + return out +end + +function runner.collect_failed_locations(failures, _command, _scope_kind) + if type(failures) ~= "table" or #failures == 0 then + return {} + end + local items = {} + local seen = {} + for _, name in ipairs(failures) do + local loc = runner._last_locations[name] + if loc then + local key = string.format("%s:%d:%d:%s", loc.filename or "", loc.lnum or 0, loc.col or 0, name) + if not seen[key] then + seen[key] = true + table.insert(items, loc) + end + end + end + return items +end + +return runner diff --git a/reporter/test_samurai_jest_reporter.js b/reporter/test_samurai_jest_reporter.js new file mode 100644 index 0000000..2b0329b --- /dev/null +++ b/reporter/test_samurai_jest_reporter.js @@ -0,0 +1,52 @@ +class TestSamuraiJestReporter { + constructor(_globalConfig, _options) {} + + onTestCaseResult(test, testCaseResult) { + const name = this.buildListingName(testCaseResult); + const jestName = this.buildJestName(testCaseResult); + const payload = { + name, + jestName, + status: testCaseResult.status, + file: test && test.path ? test.path : null, + location: testCaseResult.location || null, + output: this.buildOutput(testCaseResult), + }; + process.stdout.write(`TSAMURAI_RESULT ${JSON.stringify(payload)}\n`); + } + + buildListingName(testCaseResult) { + const ancestors = Array.isArray(testCaseResult.ancestorTitles) + ? testCaseResult.ancestorTitles + : []; + const title = testCaseResult.title ? String(testCaseResult.title) : ""; + return [...ancestors, title].filter(Boolean).join("/"); + } + + buildJestName(testCaseResult) { + const ancestors = Array.isArray(testCaseResult.ancestorTitles) + ? testCaseResult.ancestorTitles + : []; + const title = testCaseResult.title ? String(testCaseResult.title) : ""; + return [...ancestors, title].filter(Boolean).join(" "); + } + + buildOutput(testCaseResult) { + const lines = []; + const failures = Array.isArray(testCaseResult.failureMessages) + ? testCaseResult.failureMessages + : []; + failures.forEach((message) => { + if (typeof message === "string" && message.length > 0) { + message.split("\n").forEach((line) => { + if (line !== "") { + lines.push(line); + } + }); + } + }); + return lines; + } +} + +module.exports = TestSamuraiJestReporter; diff --git a/run_test.sh b/run_test.sh new file mode 100644 index 0000000..b68cb9d --- /dev/null +++ b/run_test.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +nvim --headless -u tests/minimal_init.lua -c "PlenaryBustedDirectory tests" -c qa diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua new file mode 100644 index 0000000..f1bfe85 --- /dev/null +++ b/tests/minimal_init.lua @@ -0,0 +1,15 @@ +vim.cmd("set rtp^=.") + +local data_path = vim.fn.stdpath("data") +local plenary_paths = { + data_path .. "/site/pack/packer/start/plenary.nvim", + data_path .. "/lazy/plenary.nvim", +} + +for _, path in ipairs(plenary_paths) do + if vim.fn.isdirectory(path) == 1 then + vim.cmd("set rtp^=" .. path) + end +end + +vim.cmd("runtime! plugin/plenary.vim") diff --git a/tests/test_jest_runner_spec.lua b/tests/test_jest_runner_spec.lua new file mode 100644 index 0000000..fcd2efb --- /dev/null +++ b/tests/test_jest_runner_spec.lua @@ -0,0 +1,409 @@ +local runner = require("test-samurai-jest-runner") + +local function get_reporter_path() + local source = debug.getinfo(runner.build_command, "S").source + if source:sub(1, 1) == "@" then + source = source:sub(2) + end + local dir = vim.fs.dirname(source) + return vim.fs.normalize(dir .. "/../../reporter/test_samurai_jest_reporter.js") +end + +describe("test-samurai-jest-runner", function() + it("detects Jest test files by suffix", function() + local bufnr1 = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr1, "/tmp/example.test.js") + assert.is_true(runner.is_test_file(bufnr1)) + + local bufnr2 = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr2, "/tmp/example.spec.tsx") + assert.is_true(runner.is_test_file(bufnr2)) + + local bufnr3 = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr3, "/tmp/example.js") + assert.is_false(runner.is_test_file(bufnr3)) + end) + + it("finds nearest test with describe hierarchy", function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/math.test.js") + local lines = { + "describe('Math', () => {", + " test('adds', () => {", + " expect(1 + 1).toBe(2)", + " })", + "})", + } + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + + local orig_fs_find = vim.fs.find + vim.fs.find = function(_, _) + return { "/tmp/package.json" } + end + + local spec, err = runner.find_nearest(bufnr, 2, 0) + + vim.fs.find = orig_fs_find + + assert.is_nil(err) + assert.equals("adds", spec.test_name) + assert.equals("Math/adds", spec.full_name) + assert.equals("Math adds", spec.jest_name) + assert.are.same({ "Math", "adds" }, spec.jest_parts) + assert.is_true(spec.file:match("math%.test%.js$") ~= nil) + assert.equals("/tmp", spec.cwd) + end) + + it("handles multiline describe and it declarations", function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/multiline.test.js") + local lines = { + "describe(", + " '
',", + " () => {", + " describe(", + " 'renders properly...',", + " () => {", + " it(", + " 'the teaser links',", + " async () => {", + " expect(true).toBe(true)", + " }", + " )", + " }", + " )", + " }", + ")", + } + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + + local orig_fs_find = vim.fs.find + vim.fs.find = function(_, _) + return { "/tmp/package.json" } + end + + local spec, err = runner.find_nearest(bufnr, 9, 0) + + vim.fs.find = orig_fs_find + + assert.is_nil(err) + assert.equals("the teaser links", spec.test_name) + assert.equals("
/renders properly.../the teaser links", spec.full_name) + assert.are.same({ "
", "renders properly...", "the teaser links" }, spec.jest_parts) + end) + + it("uses describe block when cursor is between tests", function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/between.test.js") + local lines = { + "describe('
', () => {", + " describe('renders properly...', () => {", + " it('the logo', async () => {", + " expect(true).toBe(true)", + " })", + " ", + " it('the teaser links', async () => {", + " expect(true).toBe(true)", + " })", + " })", + "})", + } + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + + local orig_fs_find = vim.fs.find + vim.fs.find = function(_, _) + return { "/tmp/package.json" } + end + + local spec, err = runner.find_nearest(bufnr, 5, 0) + + vim.fs.find = orig_fs_find + + assert.is_nil(err) + assert.equals("renders properly...", spec.test_name) + assert.equals("
/renders properly...", spec.full_name) + assert.are.same({ "
", "renders properly..." }, spec.jest_parts) + assert.equals("describe", spec.kind) + end) + + it("falls back to file command when outside any describe", function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/outside.test.js") + local lines = { + "const value = 1", + "function helper() { return value }", + } + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + + local orig_fs_find = vim.fs.find + vim.fs.find = function(_, _) + return { "/tmp/package.json" } + end + + local spec, err = runner.find_nearest(bufnr, 1, 0) + assert.is_nil(err) + assert.equals("file", spec.kind) + + local cmd_spec = runner.build_command(spec) + + vim.fs.find = orig_fs_find + + assert.are.same( + { + "npx", + "jest", + "--testLocationInResults", + "--reporters", + get_reporter_path(), + "--runTestsByPath", + cmd_spec.cmd[#cmd_spec.cmd], + }, + cmd_spec.cmd + ) + assert.is_true(cmd_spec.cmd[#cmd_spec.cmd]:match("outside%.test%.js$") ~= nil) + end) + + it("build_command uses npx jest with reporter and pattern", function() + local spec = { + file = "/tmp/math.test.js", + cwd = "/tmp", + full_name = "Math/adds", + jest_parts = { "Math", "adds" }, + } + local cmd_spec = runner.build_command(spec) + assert.are.same( + { + "npx", + "jest", + "--testLocationInResults", + "--reporters", + get_reporter_path(), + "--runTestsByPath", + "/tmp/math.test.js", + "--testNamePattern", + "^.*adds$", + }, + cmd_spec.cmd + ) + assert.equals("/tmp", cmd_spec.cwd) + end) + + it("build_command uses prefix pattern for describe blocks", function() + local spec = { + file = "/tmp/math.test.js", + cwd = "/tmp", + kind = "describe", + jest_parts = { "
", "renders properly..." }, + } + local cmd_spec = runner.build_command(spec) + local pattern = cmd_spec.cmd[#cmd_spec.cmd] + assert.are.same( + { + "npx", + "jest", + "--testLocationInResults", + "--reporters", + get_reporter_path(), + "--runTestsByPath", + "/tmp/math.test.js", + "--testNamePattern", + pattern, + }, + cmd_spec.cmd + ) + assert.is_true(pattern:find("Header", 1, true) ~= nil) + assert.is_true(pattern:find("renders", 1, true) ~= nil) + assert.is_true(pattern:find("properly", 1, true) ~= nil) + assert.is_true(pattern:sub(-2) == ".*") + end) + + it("build_file_command scopes to file", function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/project/foo.test.ts") + + local orig_fs_find = vim.fs.find + vim.fs.find = function(_, _) + return { "/tmp/project/package.json" } + end + + local cmd_spec = runner.build_file_command(bufnr) + + vim.fs.find = orig_fs_find + + assert.are.same( + { + "npx", + "jest", + "--testLocationInResults", + "--reporters", + get_reporter_path(), + "--runTestsByPath", + "/tmp/project/foo.test.ts", + }, + cmd_spec.cmd + ) + assert.equals("/tmp/project", cmd_spec.cwd) + end) + + it("build_all_command runs project tests", function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/project/bar.test.ts") + + local orig_fs_find = vim.fs.find + vim.fs.find = function(_, _) + return { "/tmp/project/package.json" } + end + + local cmd_spec = runner.build_all_command(bufnr) + + vim.fs.find = orig_fs_find + + assert.are.same( + { + "npx", + "jest", + "--testLocationInResults", + "--reporters", + get_reporter_path(), + }, + cmd_spec.cmd + ) + assert.equals("/tmp/project", cmd_spec.cwd) + end) + + it("build_failed_command narrows to failed tests", function() + local last_command = { + cmd = { + "npx", + "jest", + "--testLocationInResults", + "--reporters", + get_reporter_path(), + "--runTestsByPath", + "/tmp/math.test.js", + "--testNamePattern", + "^Old$", + }, + cwd = "/tmp", + } + local failures = { "Math/adds", "edge (1+1)" } + runner.parse_results(table.concat({ + "TSAMURAI_RESULT " .. vim.json.encode({ + name = "Math/adds", + jestName = "Math adds", + status = "failed", + }), + "TSAMURAI_RESULT " .. vim.json.encode({ + name = "edge (1+1)", + jestName = "edge (1+1)", + status = "failed", + }), + }, "\n")) + + local cmd_spec = runner.build_failed_command(last_command, failures, "file") + + local pattern = cmd_spec.cmd[#cmd_spec.cmd] + assert.are.same( + { + "npx", + "jest", + "--testLocationInResults", + "--reporters", + get_reporter_path(), + "--runTestsByPath", + "/tmp/math.test.js", + "--testNamePattern", + pattern, + }, + cmd_spec.cmd + ) + assert.is_true(pattern:match("%^Math adds%$") ~= nil) + assert.is_true(pattern:match("edge") ~= nil) + assert.is_true(pattern:find("\\(1", 1, true) ~= nil) + assert.is_true(pattern:find("\\+1", 1, true) ~= nil) + assert.equals("/tmp", cmd_spec.cwd) + end) + + it("parse_results collects statuses and locations", function() + local output = table.concat({ + "TSAMURAI_RESULT " .. vim.json.encode({ + name = "Math/adds", + status = "passed", + file = "/tmp/math.test.js", + location = { line = 3, column = 4 }, + }), + "TSAMURAI_RESULT " .. vim.json.encode({ + name = "Math/subtracts", + status = "failed", + file = "/tmp/math.test.js", + location = { line = 10, column = 2 }, + }), + "TSAMURAI_RESULT " .. vim.json.encode({ + name = "Math/skipped", + status = "skipped", + file = "/tmp/math.test.js", + location = { line = 20, column = 2 }, + }), + }, "\n") + + local results = runner.parse_results(output) + assert.are.same({ "Math/adds" }, results.passes) + assert.are.same({ "Math/subtracts" }, results.failures) + assert.are.same({ "Math/skipped" }, results.skips) + assert.are.same({ "Math/adds" }, results.display.passes) + assert.are.same({ "Math/subtracts" }, results.display.failures) + assert.are.same({ "Math/skipped" }, results.display.skips) + end) + + it("output_parser streams per test case", function() + local parser = runner.output_parser() + local state = {} + local line = "TSAMURAI_RESULT " .. vim.json.encode({ + name = "Math/adds", + status = "failed", + file = "/tmp/math.test.js", + location = { line = 3, column = 4 }, + }) + + local results = parser.on_line(line, state) + + assert.are.same({ "Math/adds" }, results.failures) + assert.are.same({ "Math/adds" }, results.display.failures) + assert.are.same({ "Math/adds" }, results.failures_all) + assert.is_nil(parser.on_complete("", state)) + end) + + it("parse_test_output groups output per test", function() + local output = table.concat({ + "TSAMURAI_RESULT " .. vim.json.encode({ + name = "Math/adds", + status = "failed", + output = { "line1", "line2" }, + }), + "TSAMURAI_RESULT " .. vim.json.encode({ + name = "Math/adds", + status = "failed", + output = { "line3" }, + }), + }, "\n") + + local results = runner.parse_test_output(output) + assert.are.same({ "line1", "line2", "line3" }, results["Math/adds"]) + end) + + it("collect_failed_locations uses cached locations", function() + local output = "TSAMURAI_RESULT " .. vim.json.encode({ + name = "Math/adds", + status = "failed", + file = "/tmp/math.test.js", + location = { line = 8, column = 2 }, + }) + runner.parse_results(output) + + local items = runner.collect_failed_locations({ "Math/adds" }, nil, "file") + assert.equals(1, #items) + assert.equals("/tmp/math.test.js", items[1].filename) + assert.equals(8, items[1].lnum) + assert.equals(2, items[1].col) + end) +end)