commit 3f31707f014a5a591e73e50c6ec64c58f5b5a8ae Author: M.Schirmer Date: Mon Apr 20 08:06:22 2026 +0200 initialize with first claude code interation diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml new file mode 100644 index 0000000..28fbbb1 --- /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-linux-arm64.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..9a589b8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,69 @@ +# AGENTS.md — test-samurai-vitest-runner + +## Entwicklungsrichtlinien + +### Rolle + +TDD-first Entwickler. Jede neue Funktion, jedes neue Kommando und jede +Verhaltensänderung muss durch Tests abgesichert sein. + +### Tests ausführen + +```bash +bash run_test.sh +``` + +Tests nach jeder Code-Änderung ausführen. 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. + +### Sprache + +Antworten immer auf Deutsch. Code-Bezeichner und `README.md` bleiben englisch. + +## Runner-API + +Der Runner implementiert alle 10 Pflichtfunktionen der test-samurai Runner-API. +Vollständige Spezifikation: `test-samurai/runner-agents.md`. + +## Reporter + +`reporter/test_samurai_vitest_reporter.js` ist ein ESM-Modul für Vitest 2.x. +Es emittiert pro Test eine Zeile `TSAMURAI_RESULT ` auf stdout. + +### Payload-Schema + +```json +{ + "name": "Describe/Subtest", + "status": "passed | failed | skipped", + "file": "/absoluter/pfad/zur/test.ts", + "location": { "line": 1, "column": 1 }, + "output": ["Fehlermeldung Zeile 1", "Stack Trace ..."] +} +``` + +`name` verbindet die Describe-Hierarchie mit `/` (Beispiel: `MyComponent/renders/the slot`). + +## Web-Components-Hinweise + +Für Shadow-DOM-Tests benötigt Vitest eine geeignete DOM-Umgebung. Empfohlen: + +- `happy-dom` oder `jsdom` als `environment` in `vitest.config.ts` +- Custom-Element-Registrierung in `vitest.setup.ts` + +Der Runner erkennt `vitest.setup.ts` / `vitest.setup.js` automatisch und übergibt +sie via `--setupFiles`. + +## Gitea CI + +Der Workflow unter `.gitea/workflows/tests.yml` installiert Neovim per AppImage +(ARM64, Raspberry Pi 5) und führt `bash run_test.sh` aus. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a7fcaf6 --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +# test-samurai-vitest-runner + +A [test-samurai](https://github.com/your-org/test-samurai.nvim) runner plugin for [Vitest](https://vitest.dev/) (v2.x). + +Supports Web Component tests with or without shadow DOM via `jsdom`, `happy-dom`, or `@vitest/browser`. + +## Requirements + +- Neovim ≥ 0.10 +- [test-samurai.nvim](https://github.com/your-org/test-samurai.nvim) +- [plenary.nvim](https://github.com/nvim-lua/plenary.nvim) +- Vitest 2.x in your project's `package.json` + +## Installation + +### lazy.nvim + +```lua +{ + "your-org/test-samurai.nvim", + dependencies = { + "your-org/test-samurai-vitest-runner", + }, + config = function() + require("test-samurai").setup({ + runners = { + require("test-samurai-vitest-runner"), + }, + }) + end, +} +``` + +## How it works + +The runner detects Vitest test files (`*.test.ts`, `*.spec.ts`, etc.) by checking +the file name suffix and verifying that `vitest` is listed in the project's +`package.json` dependencies. + +It uses a custom Vitest reporter (`reporter/test_samurai_vitest_reporter.js`) +that emits one JSON line per completed test case to stdout: + +``` +TSAMURAI_RESULT {"name":"MyComponent/renders the slot","status":"passed",...} +``` + +The `name` field uses `/` to join the describe hierarchy with the test title, +e.g. `MyComponent/shadow DOM/assigns slot content`. + +### Reporter payload schema + +| Field | Type | Description | +|------------|-------------------------------|------------------------------------------------| +| `name` | `string` | Describe hierarchy + test title, joined by `/` | +| `status` | `"passed"│"failed"│"skipped"` | Test result | +| `file` | `string│null` | Absolute path to the test file | +| `location` | `{line, column}│null` | Source location of the test | +| `output` | `string[]` | Error messages and stack traces (failures) | + +## Setup files + +If the project root (or `test/.bin/`) contains a `vitest.setup.ts` or +`vitest.setup.js` file, the runner passes it via `--setupFiles` automatically. + +This is useful for Web Component tests that need custom element registration: + +```ts +// vitest.setup.ts +import { MyButton } from './src/my-button.js' + +if (!customElements.get('my-button')) { + customElements.define('my-button', MyButton) +} +``` + +For shadow DOM access in tests use `element.shadowRoot`: + +```ts +it('renders slot content', async () => { + document.body.innerHTML = 'Click me' + const el = document.querySelector('my-button')! + await el.updateComplete // if using Lit + expect(el.shadowRoot!.querySelector('button')!.textContent).toBe('Click me') +}) +``` + +## Vitest configuration + +Configure the DOM environment in `vitest.config.ts`: + +```ts +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'happy-dom', // or 'jsdom' + setupFiles: ['./vitest.setup.ts'], + }, +}) +``` + +## Commands + +| Command | Description | +|------------------|------------------------------------------| +| `TSamNearest` | Run the test nearest to the cursor | +| `TSamFile` | Run all tests in the current file | +| `TSamAll` | Run all tests in the project | +| `TSamLast` | Re-run the last command | +| `TSamFailedOnly` | Re-run only the failed tests | +| `TSamShowOutput` | Show test output in a floating window | + +## Default keymaps + +| Key | Command | +|---------------|----------------| +| `tn` | `TSamNearest` | +| `tf` | `TSamFile` | +| `ta` | `TSamAll` | +| `tl` | `TSamLast` | +| `te` | `TSamFailedOnly` | +| `to` | `TSamShowOutput` | + +## Running the plugin tests + +```bash +bash run_test.sh +``` + +Tests use [plenary.nvim](https://github.com/nvim-lua/plenary.nvim) + Busted and +are located in `tests/`. diff --git a/lua/test-samurai-vitest-runner/init.lua b/lua/test-samurai-vitest-runner/init.lua new file mode 100644 index 0000000..a56ec57 --- /dev/null +++ b/lua/test-samurai-vitest-runner/init.lua @@ -0,0 +1,717 @@ +local runner = { + name = "vitest", + framework = "javascript", +} + +local RESULT_PREFIX = "TSAMURAI_RESULT " + +local STATUS_MAP = { + passed = "passes", + failed = "failures", + skipped = "skips", +} + +runner._last_locations = {} + +-- --------------------------------------------------------------------------- +-- Buffer helpers +-- --------------------------------------------------------------------------- + +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 + +-- --------------------------------------------------------------------------- +-- Filesystem helpers +-- --------------------------------------------------------------------------- + +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 find_nearest_package_root(path) + if not path or path == "" then + return nil + end + local dir = vim.fs.dirname(path) + 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.fs.dirname(dir) + end + return nil +end + +local function read_package_json(root) + if not root then + return nil + end + local ok, lines = pcall(vim.fn.readfile, root .. "/package.json") + if not ok then + return nil + end + local ok_decode, data = pcall(vim.json.decode, table.concat(lines, "\n")) + if not ok_decode then + return nil + end + return data +end + +local function has_vitest_dependency(pkg) + if not pkg or type(pkg) ~= "table" then + return false + end + local deps = pkg.dependencies or {} + local dev_deps = pkg.devDependencies or {} + return deps.vitest ~= nil or dev_deps.vitest ~= nil +end + +-- --------------------------------------------------------------------------- +-- Test-file pattern detection +-- --------------------------------------------------------------------------- + +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 + +-- --------------------------------------------------------------------------- +-- AST-level helpers (same logic as jest-runner) +-- --------------------------------------------------------------------------- + +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 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.vitest_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.vitest_parts = parts + end + + return tests, describes +end + +-- --------------------------------------------------------------------------- +-- Pattern helpers (same logic as jest-runner) +-- --------------------------------------------------------------------------- + +local function escape_regex(text) + text = text or "" + return (text:gsub("([\\.^$|()%%[%]{}*+?%-])", "\\\\%1")) +end + +local function build_vitest_pattern(parts) + return "^.*" .. escape_regex(parts[#parts] or "") .. "$" +end + +local function build_vitest_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 last_segment(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 parts[#parts] or name +end + +-- --------------------------------------------------------------------------- +-- Reporter / setup paths +-- --------------------------------------------------------------------------- + +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_vitest_reporter.js") +end + +local function find_vitest_setup(cwd) + if not cwd or cwd == "" then + return nil + end + local candidates = { + cwd .. "/vitest.setup.ts", + cwd .. "/vitest.setup.js", + cwd .. "/test/.bin/vitest.setup.ts", + cwd .. "/test/.bin/vitest.setup.js", + } + for _, candidate in ipairs(candidates) do + if vim.fn.filereadable(candidate) == 1 then + return candidate + end + end + return nil +end + +local VITEST_CONFIG_MARKERS = { + "vitest.config.ts", + "vitest.config.js", + "vite.config.ts", + "vite.config.js", + "package.json", +} + +local function base_cmd(cwd) + local cmd = { + "npx", + "vitest", + "run", + "--reporter", + reporter_path(), + } + local setup = find_vitest_setup(cwd) + if setup then + table.insert(cmd, "--setupFiles") + table.insert(cmd, setup) + end + return cmd +end + +-- --------------------------------------------------------------------------- +-- Output helpers +-- --------------------------------------------------------------------------- + +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 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, + } +end + +-- --------------------------------------------------------------------------- +-- Ordering helpers (same logic as jest-runner) +-- --------------------------------------------------------------------------- + +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 + +-- --------------------------------------------------------------------------- +-- Public API +-- --------------------------------------------------------------------------- + +function runner.is_test_file(bufnr) + local path = get_buf_path(bufnr) + if not test_file_path(path) then + return false + end + local root = find_nearest_package_root(path) + local pkg = read_package_json(root) + return has_vitest_dependency(pkg) +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 runner.is_test_file(bufnr) then + return nil, "not a vitest 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, VITEST_CONFIG_MARKERS) + return { + file = path, + cwd = cwd, + test_name = describe_match.name, + full_name = describe_match.full_name, + vitest_parts = describe_match.vitest_parts, + kind = "describe", + } + end + local cwd = find_root(path, VITEST_CONFIG_MARKERS) + return { + file = path, + cwd = cwd, + kind = "file", + } + end + + local cwd = find_root(path, VITEST_CONFIG_MARKERS) + return { + file = path, + cwd = cwd, + test_name = nearest.name, + full_name = nearest.full_name, + vitest_parts = nearest.vitest_parts, + kind = "test", + } +end + +function runner.build_command(spec) + local file = spec.file + if not file or file == "" then + return { cmd = base_cmd(spec.cwd), cwd = spec.cwd } + end + if spec.kind == "file" then + local cmd = base_cmd(spec.cwd) + table.insert(cmd, file) + return { cmd = cmd, cwd = spec.cwd } + end + local cmd = base_cmd(spec.cwd) + table.insert(cmd, file) + local ok, pattern = pcall(function() + if type(spec.vitest_parts) == "table" and #spec.vitest_parts > 0 then + if spec.kind == "describe" then + return build_vitest_prefix_pattern(spec.vitest_parts) + end + return build_vitest_pattern(spec.vitest_parts) + end + local name = 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, VITEST_CONFIG_MARKERS) + local cmd = base_cmd(cwd) + 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, VITEST_CONFIG_MARKERS) + local cmd = base_cmd(cwd) + 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 + local cwd = vim.loop.cwd() + return { cmd = base_cmd(cwd), cwd = cwd } + end + + local pattern_parts = {} + for _, name in ipairs(failures or {}) do + if name and name ~= "" then + local title = last_segment(name) + table.insert(pattern_parts, "^.*" .. escape_regex(title) .. "$") + 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(last_command and last_command.cwd or vim.loop.cwd()) + 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 = {} + if not output or output == "" then + return { passes = {}, failures = {}, skips = {}, display = { passes = {}, failures = {}, skips = {} } } + end + local passes = {} + local failures = {} + local 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 + if kind == "passes" then + table.insert(passes, data.name) + pass_display[data.name] = data.name + elseif kind == "failures" then + table.insert(failures, data.name) + fail_display[data.name] = data.name + else + table.insert(skips, data.name) + skip_display[data.name] = data.name + end + update_location_cache(data.name, data) + end + end + end + + passes = collect_unique(passes) + failures = collect_unique(failures) + skips = collect_unique(skips) + local display = { passes = {}, failures = {}, 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 = {} + 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.vitest = state.vitest 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.name } + elseif kind == "failures" then + results.failures = { data.name } + results.display.failures = { data.name } + if not state.vitest.failures_seen[data.name] then + state.vitest.failures_seen[data.name] = true + table.insert(state.vitest.failures_all, data.name) + end + else + results.skips = { data.name } + results.display.skips = { data.name } + end + results.failures_all = vim.deepcopy(state.vitest.failures_all) + 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_vitest_reporter.js b/reporter/test_samurai_vitest_reporter.js new file mode 100644 index 0000000..9eea45e --- /dev/null +++ b/reporter/test_samurai_vitest_reporter.js @@ -0,0 +1,86 @@ +/** + * test_samurai_vitest_reporter.js + * + * Custom Vitest 2.x reporter for test-samurai. + * + * Emits one line per completed test case to stdout: + * TSAMURAI_RESULT + * + * Payload schema: + * { + * name: string -- Describe-hierarchy + test title joined with "/" + * status: "passed" | "failed" | "skipped" + * file: string | null -- absolute file path + * location: { line: number, column: number } | null + * output: string[] -- error messages / stack traces (failures only) + * } + */ + +const RESULT_PREFIX = 'TSAMURAI_RESULT '; + +export default class TestSamuraiVitestReporter { + onTestCaseResult(testCase) { + const name = buildListingName(testCase); + const status = mapStatus(testCase.result?.state); + const output = buildOutput(testCase); + + const payload = { + name, + status, + file: testCase.file?.filepath ?? null, + location: testCase.location ?? null, + output, + }; + + process.stdout.write(`${RESULT_PREFIX}${JSON.stringify(payload)}\n`); + } +} + +/** + * Builds a slash-separated name from the describe hierarchy. + * E.g. describe('MyComponent') > describe('renders') > it('the button') + * → "MyComponent/renders/the button" + */ +function buildListingName(testCase) { + const parts = [testCase.name]; + let current = testCase.parent; + while (current && current.type === 'suite') { + parts.unshift(current.name); + current = current.parent; + } + return parts.filter(Boolean).join('/'); +} + +/** + * Maps Vitest result states to test-samurai status strings. + */ +function mapStatus(state) { + const map = { + pass: 'passed', + fail: 'failed', + skip: 'skipped', + todo: 'skipped', + }; + return map[state] ?? 'skipped'; +} + +/** + * Collects error messages and stack traces for failed tests. + */ +function buildOutput(testCase) { + const lines = []; + const errors = testCase.result?.errors ?? []; + for (const err of errors) { + if (typeof err.message === 'string' && err.message.length > 0) { + err.message.split('\n').forEach((line) => { + if (line !== '') lines.push(line); + }); + } + if (typeof err.stack === 'string' && err.stack.length > 0) { + err.stack.split('\n').forEach((line) => { + if (line !== '') lines.push(line); + }); + } + } + return lines; +} diff --git a/run_test.sh b/run_test.sh new file mode 100755 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..8db1a97 --- /dev/null +++ b/tests/minimal_init.lua @@ -0,0 +1,17 @@ +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", + data_path .. "/site/pack/core/opt/plenary.nvim", + data_path .. "/site/pack/core/start/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_vitest_runner_spec.lua b/tests/test_vitest_runner_spec.lua new file mode 100644 index 0000000..01af7ec --- /dev/null +++ b/tests/test_vitest_runner_spec.lua @@ -0,0 +1,542 @@ +local runner = require("test-samurai-vitest-runner") + +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 with_project(package_json, fn) + local root = vim.fn.tempname() + vim.fn.mkdir(root, "p") + root = vim.loop.fs_realpath(root) or root + if package_json then + write_file(root .. "/package.json", package_json) + end + fn(root) +end + +local VITEST_PACKAGE = [[ +{ + "devDependencies": { + "vitest": "^2.0.0" + } +} +]] + +local NO_VITEST_PACKAGE = [[ +{ + "devDependencies": { + "jest": "^29.0.0" + } +} +]] + +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_vitest_reporter.js") +end + +describe("test-samurai-vitest-runner", function() + it("detects Vitest test files by suffix and package.json", function() + with_project(VITEST_PACKAGE, function(root) + local bufnr1 = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr1, root .. "/example.test.ts") + assert.is_true(runner.is_test_file(bufnr1)) + + local bufnr2 = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr2, root .. "/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, root .. "/example.ts") + assert.is_false(runner.is_test_file(bufnr3)) + end) + end) + + it("rejects test files when vitest dependency is missing", function() + with_project(NO_VITEST_PACKAGE, function(root) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, root .. "/example.test.ts") + assert.is_false(runner.is_test_file(bufnr)) + end) + end) + + it("detects .test.js, .spec.mjs, .test.cjs suffixes", function() + with_project(VITEST_PACKAGE, function(root) + local cases = { + root .. "/a.test.js", + root .. "/b.spec.js", + root .. "/c.test.jsx", + root .. "/d.spec.tsx", + root .. "/e.test.mjs", + root .. "/f.spec.cjs", + } + for _, fname in ipairs(cases) do + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, fname) + assert.is_true(runner.is_test_file(bufnr), fname .. " should be detected") + end + end) + end) + + it("finds nearest test with describe hierarchy", function() + with_project(VITEST_PACKAGE, function(root) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, root .. "/math.test.ts") + local lines = { + "describe('Math', () => {", + " test('adds', () => {", + " expect(1 + 1).toBe(2)", + " })", + "})", + } + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + + local spec, err = runner.find_nearest(bufnr, 2, 0) + + assert.is_nil(err) + assert.equals("adds", spec.test_name) + assert.equals("Math/adds", spec.full_name) + assert.are.same({ "Math", "adds" }, spec.vitest_parts) + assert.is_true(spec.file:match("math%.test%.ts$") ~= nil) + assert.equals(root, spec.cwd) + end) + end) + + it("handles multiline describe and it declarations", function() + with_project(VITEST_PACKAGE, function(root) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, root .. "/multiline.test.ts") + local lines = { + "describe(", + " '',", + " () => {", + " describe(", + " 'renders properly...',", + " () => {", + " it(", + " 'the shadow root',", + " async () => {", + " expect(true).toBe(true)", + " }", + " )", + " }", + " )", + " }", + ")", + } + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + + local spec, err = runner.find_nearest(bufnr, 9, 0) + + assert.is_nil(err) + assert.equals("the shadow root", spec.test_name) + assert.equals("/renders properly.../the shadow root", spec.full_name) + assert.are.same({ "", "renders properly...", "the shadow root" }, spec.vitest_parts) + end) + end) + + it("uses describe block when cursor is between tests", function() + with_project(VITEST_PACKAGE, function(root) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, root .. "/between.test.ts") + local lines = { + "describe('', () => {", + " describe('renders properly...', () => {", + " it('the logo', async () => {", + " expect(true).toBe(true)", + " })", + " ", + " it('the shadow host', async () => {", + " expect(true).toBe(true)", + " })", + " })", + "})", + } + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + + local spec, err = runner.find_nearest(bufnr, 5, 0) + + assert.is_nil(err) + assert.equals("renders properly...", spec.test_name) + assert.equals("/renders properly...", spec.full_name) + assert.are.same({ "", "renders properly..." }, spec.vitest_parts) + assert.equals("describe", spec.kind) + end) + end) + + it("falls back to file command when outside any describe", function() + with_project(VITEST_PACKAGE, function(root) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, root .. "/outside.test.ts") + local lines = { + "const value = 1", + "function helper() { return value }", + } + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + + 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) + assert.are.same( + { + "npx", + "vitest", + "run", + "--reporter", + get_reporter_path(), + cmd_spec.cmd[#cmd_spec.cmd], + }, + cmd_spec.cmd + ) + assert.is_true(cmd_spec.cmd[#cmd_spec.cmd]:match("outside%.test%.ts$") ~= nil) + end) + end) + + it("build_command uses npx vitest run with reporter and pattern", function() + local spec = { + file = "/tmp/math.test.ts", + cwd = "/tmp", + full_name = "Math/adds", + vitest_parts = { "Math", "adds" }, + } + local cmd_spec = runner.build_command(spec) + assert.are.same( + { + "npx", + "vitest", + "run", + "--reporter", + get_reporter_path(), + "/tmp/math.test.ts", + "--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/component.test.ts", + cwd = "/tmp", + kind = "describe", + vitest_parts = { "", "renders properly..." }, + } + local cmd_spec = runner.build_command(spec) + local pattern = cmd_spec.cmd[#cmd_spec.cmd] + assert.are.same( + { + "npx", + "vitest", + "run", + "--reporter", + get_reporter_path(), + "/tmp/component.test.ts", + "--testNamePattern", + pattern, + }, + cmd_spec.cmd + ) + assert.is_true(pattern:find("MyComponent", 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() + with_project(VITEST_PACKAGE, function(root) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, root .. "/foo.test.ts") + + local cmd_spec = runner.build_file_command(bufnr) + + assert.are.same( + { + "npx", + "vitest", + "run", + "--reporter", + get_reporter_path(), + root .. "/foo.test.ts", + }, + cmd_spec.cmd + ) + assert.equals(root, cmd_spec.cwd) + end) + end) + + it("adds vitest.setup.ts from project root to --setupFiles", function() + with_project(VITEST_PACKAGE, function(root) + write_file(root .. "/vitest.setup.ts", "// setup") + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, root .. "/foo.test.ts") + + local cmd_spec = runner.build_file_command(bufnr) + + assert.are.same( + { + "npx", + "vitest", + "run", + "--reporter", + get_reporter_path(), + "--setupFiles", + root .. "/vitest.setup.ts", + root .. "/foo.test.ts", + }, + cmd_spec.cmd + ) + assert.equals(root, cmd_spec.cwd) + end) + end) + + it("adds vitest.setup.js from test/.bin when root setup is missing", function() + with_project(VITEST_PACKAGE, function(root) + write_file(root .. "/test/.bin/vitest.setup.js", "// setup") + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, root .. "/bar.test.ts") + + local cmd_spec = runner.build_file_command(bufnr) + + assert.are.same( + { + "npx", + "vitest", + "run", + "--reporter", + get_reporter_path(), + "--setupFiles", + root .. "/test/.bin/vitest.setup.js", + root .. "/bar.test.ts", + }, + cmd_spec.cmd + ) + assert.equals(root, cmd_spec.cwd) + end) + end) + + it("build_all_command runs all project tests without file filter", function() + with_project(VITEST_PACKAGE, function(root) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, root .. "/bar.test.ts") + + local cmd_spec = runner.build_all_command(bufnr) + + assert.are.same( + { + "npx", + "vitest", + "run", + "--reporter", + get_reporter_path(), + }, + cmd_spec.cmd + ) + assert.equals(root, cmd_spec.cwd) + end) + end) + + it("build_failed_command narrows to failed tests", function() + local last_command = { + cmd = { + "npx", + "vitest", + "run", + "--reporter", + get_reporter_path(), + "/tmp/math.test.ts", + "--testNamePattern", + "^Old$", + }, + cwd = "/tmp", + } + local failures = { "Math/adds", "edge (1+1)" } + + local cmd_spec = runner.build_failed_command(last_command, failures, "file") + + local pattern = cmd_spec.cmd[#cmd_spec.cmd] + assert.are.same( + { + "npx", + "vitest", + "run", + "--reporter", + get_reporter_path(), + "/tmp/math.test.ts", + "--testNamePattern", + pattern, + }, + cmd_spec.cmd + ) + assert.is_true(pattern:match("%^%..*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 caches locations", function() + local output = table.concat({ + "TSAMURAI_RESULT " .. vim.json.encode({ + name = "Math/adds", + status = "passed", + file = "/tmp/math.test.ts", + location = { line = 3, column = 4 }, + }), + "TSAMURAI_RESULT " .. vim.json.encode({ + name = "Math/subtracts", + status = "failed", + file = "/tmp/math.test.ts", + location = { line = 10, column = 2 }, + }), + "TSAMURAI_RESULT " .. vim.json.encode({ + name = "Math/skipped", + status = "skipped", + file = "/tmp/math.test.ts", + 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("parse_results deduplicates repeated entries", function() + local line = "TSAMURAI_RESULT " .. vim.json.encode({ + name = "Math/adds", + status = "passed", + }) + local results = runner.parse_results(line .. "\n" .. line) + assert.equals(1, #results.passes) + 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.ts", + 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("keeps failures_all across non-failure lines", function() + local parser = runner.output_parser() + local state = {} + local fail_line = "TSAMURAI_RESULT " .. vim.json.encode({ + name = "Math/adds", + status = "failed", + }) + local pass_line = "TSAMURAI_RESULT " .. vim.json.encode({ + name = "Math/other", + status = "passed", + }) + + parser.on_line(fail_line, state) + local results = parser.on_line(pass_line, state) + + assert.are.same({ "Math/adds" }, results.failures_all) + end) + + it("parse_test_output groups output lines per test", function() + local output = table.concat({ + "TSAMURAI_RESULT " .. vim.json.encode({ + name = "MyComponent/renders the slot", + status = "failed", + output = { "Expected: true", "Received: false" }, + }), + "TSAMURAI_RESULT " .. vim.json.encode({ + name = "MyComponent/renders the slot", + status = "failed", + output = { "at Object. (component.test.ts:5:3)" }, + }), + }, "\n") + + local results = runner.parse_test_output(output) + assert.are.same( + { "Expected: true", "Received: false", "at Object. (component.test.ts:5:3)" }, + results["MyComponent/renders the slot"] + ) + end) + + it("collect_failed_locations uses cached locations from parse_results", function() + local output = "TSAMURAI_RESULT " .. vim.json.encode({ + name = "MyComponent/shadow DOM slot", + status = "failed", + file = "/tmp/component.test.ts", + location = { line = 8, column = 2 }, + }) + runner.parse_results(output) + + local items = runner.collect_failed_locations({ "MyComponent/shadow DOM slot" }, nil, "file") + assert.equals(1, #items) + assert.equals("/tmp/component.test.ts", items[1].filename) + assert.equals(8, items[1].lnum) + assert.equals(2, items[1].col) + assert.equals("MyComponent/shadow DOM slot", items[1].text) + end) + + it("collect_failed_locations uses cached locations from output_parser", function() + local parser = runner.output_parser() + local state = {} + parser.on_line("TSAMURAI_RESULT " .. vim.json.encode({ + name = "MyComponent/adds slot", + status = "failed", + file = "/tmp/component.test.ts", + location = { line = 12, column = 5 }, + }), state) + + local items = runner.collect_failed_locations({ "MyComponent/adds slot" }, nil, "file") + assert.equals(1, #items) + assert.equals(12, items[1].lnum) + end) + + it("collect_failed_locations deduplicates identical entries", function() + local output = "TSAMURAI_RESULT " .. vim.json.encode({ + name = "Math/adds", + status = "failed", + file = "/tmp/math.test.ts", + location = { line = 3, column = 1 }, + }) + runner.parse_results(output) + + local items = runner.collect_failed_locations({ "Math/adds", "Math/adds" }, nil, "file") + assert.equals(1, #items) + end) + + it("returns empty results for empty output", function() + local results = runner.parse_results("") + assert.are.same({}, results.passes) + assert.are.same({}, results.failures) + assert.are.same({}, results.skips) + end) + + it("output_parser ignores non-TSAMURAI lines", function() + local parser = runner.output_parser() + local state = {} + local result = parser.on_line("some random vitest output line", state) + assert.is_nil(result) + end) +end)