initialize with first claude code interation
Some checks failed
tests / test (push) Failing after 7s
Some checks failed
tests / test (push) Failing after 7s
This commit is contained in:
20
.gitea/workflows/tests.yml
Normal file
20
.gitea/workflows/tests.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
name: tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Neovim (AppImage)
|
||||
run: |
|
||||
curl -L -o nvim.appimage https://github.com/neovim/neovim/releases/download/v0.11.4/nvim-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
69
AGENTS.md
Normal 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
131
README.md
Normal 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/`.
|
||||
717
lua/test-samurai-vitest-runner/init.lua
Normal file
717
lua/test-samurai-vitest-runner/init.lua
Normal 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
|
||||
86
reporter/test_samurai_vitest_reporter.js
Normal file
86
reporter/test_samurai_vitest_reporter.js
Normal 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
3
run_test.sh
Executable 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
17
tests/minimal_init.lua
Normal 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")
|
||||
542
tests/test_vitest_runner_spec.lua
Normal file
542
tests/test_vitest_runner_spec.lua
Normal 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)
|
||||
Reference in New Issue
Block a user