initialize with first claude code interation
Some checks failed
tests / test (push) Failing after 7s

This commit is contained in:
2026-04-20 08:06:22 +02:00
commit 3f31707f01
8 changed files with 1585 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
name: tests
on:
push:
branches:
- "**"
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Neovim (AppImage)
run: |
curl -L -o nvim.appimage https://github.com/neovim/neovim/releases/download/v0.11.4/nvim-linux-arm64.appimage
chmod +x nvim.appimage
sudo mv nvim.appimage /usr/local/bin/nvim
- name: Run tests
run: bash run_test.sh

69
AGENTS.md Normal file
View File

@@ -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 <json>` 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.

131
README.md Normal file
View File

@@ -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 = '<my-button>Click me</my-button>'
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 |
|---------------|----------------|
| `<leader>tn` | `TSamNearest` |
| `<leader>tf` | `TSamFile` |
| `<leader>ta` | `TSamAll` |
| `<leader>tl` | `TSamLast` |
| `<leader>te` | `TSamFailedOnly` |
| `<leader>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/`.

View File

@@ -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

View File

@@ -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 <json>
*
* 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;
}

3
run_test.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
nvim --headless -u tests/minimal_init.lua -c "PlenaryBustedDirectory tests" -c qa

17
tests/minimal_init.lua Normal file
View File

@@ -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")

View File

@@ -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(",
" '<MyComponent/>',",
" () => {",
" 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("<MyComponent/>/renders properly.../the shadow root", spec.full_name)
assert.are.same({ "<MyComponent/>", "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('<MyComponent/>', () => {",
" 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("<MyComponent/>/renders properly...", spec.full_name)
assert.are.same({ "<MyComponent/>", "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 = { "<MyComponent/>", "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.<anonymous> (component.test.ts:5:3)" },
}),
}, "\n")
local results = runner.parse_test_output(output)
assert.are.same(
{ "Expected: true", "Received: false", "at Object.<anonymous> (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)