Headless Neovim for codediff
The one rule
Not "headless can't do it" — "synchronous code can't observe deferred events."
Specs run synchronously inside -c "run_and_exit(spec)". The main loop never
regains control, so events that fire from normal_check() — WinScrolled,
WinResized — are never dispatched. This is identical with or without a UI.
What works
Everything data-level:
- buffer read/write,
nvim_buf_get_lines / set_lines
- extmarks with
details=true: highlights, virt_lines, signs
nvim_get_hl (resolved colors)
- window splits, layout,
nvim_tabpage_list_wins
- cursor,
winsaveview (topline, topfill, leftcol)
- topfill accounting through
virt_lines blocks
- folds (
foldclosed, zf, zo)
- immediate autocmds:
BufEnter, WinEnter, CursorMoved, TabNew, …
vim.system + vim.wait (async child processes)
vim.uv timers under vim.wait
- keymaps via
feedkeys with "x" (execute immediately) flag
- treesitter parsers and queries
- git operations via
tests/helpers (create_temp_git_repo, git_cmd)
What needs a workaround
WinScrolled and WinResized — fire them manually after scrolling:
vim.cmd("normal! 20\5") -- 20x <C-e>
vim.api.nvim_exec_autocmds("WinScrolled", {})
This is what existing specs already do.
What is not possible
- Rendered screen content (which character is at row/col, its color).
Neovim's own test suite solves this by spawning a child
nvim --embed and
attaching a fake UI over RPC — a different architecture from ours.
UIEnter never fires. Plugins that lazy-load on UIEnter (e.g.
snacks.nvim) must be initialized manually with .enable() or equivalent.
Driving styles
| Style |
When to use |
WinScrolled? |
| Synchronous (default for specs) |
Most tests |
Manual exec_autocmds |
defer_fn coroutine chain |
Timer/animation interactions |
Fires naturally |
Separate nvim --embed child |
Screen-level assertions |
Fires naturally |
For a defer_fn chain (e.g. reproducing animation conflicts):
local co = coroutine.create(function()
-- step 1
coroutine.yield(200) -- ms to wait before next step
-- step 2
end)
local function resume()
local ok, delay = coroutine.resume(co)
if ok and coroutine.status(co) ~= "dead" then
vim.defer_fn(resume, delay or 50)
end
end
vim.defer_fn(resume, 100)
Traps
helpers.wait_for_diff_ready() captures tabpage at call time.
:CodeDiff file <rev> creates the tab asynchronously, so calling it
immediately polls the wrong tab. Use wait_for_new_tab first, or poll
get_current_tabpage() inside the condition.
Default terminal size is 24×80 (window height 22). Set vim.o.lines
explicitly when assertions depend on height.
vim.wait does NOT pump the normal-mode loop. It processes libuv
callbacks (timers, child process I/O) but not deferred display events.
Do not expect WinScrolled to fire inside vim.wait.
Entry points
| What |
Where |
| Test bootstrap |
tests/init.lua |
| Helpers (git repos, waiters) |
tests/helpers.lua |
| Framework (describe/it/assert) |
tests/framework/init.lua |
| Run all specs |
./tests/run_tests.sh or make test-lua |
| Run one spec |
nvim --headless --noplugin -u tests/init.lua -c "lua require('tests.framework').run_and_exit('tests/path/to_spec.lua')" |
| Throwaway repro |
Save to /tmp/repro.lua, run with nvim --headless -u tests/init.lua -c "luafile /tmp/repro.lua" -c "qa!" |
1---2name: nvim-headless3description: Reproduce issues, verify fixes, and write specs in headless Neovim. Defines what is observable without a UI, what needs a workaround, and what is not possible. Use when investigating issues, writing throwaway repro scripts, or adding test cases.4---56# Headless Neovim for codediff78## The one rule910Not "headless can't do it" — "synchronous code can't observe deferred events."1112Specs run synchronously inside `-c "run_and_exit(spec)"`. The main loop never13regains control, so events that fire from `normal_check()` — `WinScrolled`,14`WinResized` — are never dispatched. This is identical with or without a UI.1516## What works1718Everything data-level:1920- buffer read/write, `nvim_buf_get_lines` / `set_lines`21- extmarks with `details=true`: highlights, `virt_lines`, signs22- `nvim_get_hl` (resolved colors)23- window splits, layout, `nvim_tabpage_list_wins`24- cursor, `winsaveview` (topline, topfill, leftcol)25- topfill accounting through `virt_lines` blocks26- folds (`foldclosed`, `zf`, `zo`)27- immediate autocmds: `BufEnter`, `WinEnter`, `CursorMoved`, `TabNew`, …28- `vim.system` + `vim.wait` (async child processes)29- `vim.uv` timers under `vim.wait`30- keymaps via `feedkeys` with `"x"` (execute immediately) flag31- treesitter parsers and queries32- git operations via `tests/helpers` (`create_temp_git_repo`, `git_cmd`)3334## What needs a workaround3536`WinScrolled` and `WinResized` — fire them manually after scrolling:3738```lua39vim.cmd("normal! 20\5") -- 20x <C-e>40vim.api.nvim_exec_autocmds("WinScrolled", {})41```4243This is what existing specs already do.4445## What is not possible4647- **Rendered screen content** (which character is at row/col, its color).48 Neovim's own test suite solves this by spawning a child `nvim --embed` and49 attaching a fake UI over RPC — a different architecture from ours.50- **`UIEnter`** never fires. Plugins that lazy-load on `UIEnter` (e.g.51 `snacks.nvim`) must be initialized manually with `.enable()` or equivalent.5253## Driving styles5455| Style | When to use | WinScrolled? |56|---|---|---|57| Synchronous (default for specs) | Most tests | Manual `exec_autocmds` |58| `defer_fn` coroutine chain | Timer/animation interactions | Fires naturally |59| Separate `nvim --embed` child | Screen-level assertions | Fires naturally |6061For a `defer_fn` chain (e.g. reproducing animation conflicts):6263```lua64local co = coroutine.create(function()65 -- step 166 coroutine.yield(200) -- ms to wait before next step67 -- step 268end)69local function resume()70 local ok, delay = coroutine.resume(co)71 if ok and coroutine.status(co) ~= "dead" then72 vim.defer_fn(resume, delay or 50)73 end74end75vim.defer_fn(resume, 100)76```7778## Traps7980- **`helpers.wait_for_diff_ready()`** captures `tabpage` at call time.81 `:CodeDiff file <rev>` creates the tab asynchronously, so calling it82 immediately polls the wrong tab. Use `wait_for_new_tab` first, or poll83 `get_current_tabpage()` inside the condition.8485- **Default terminal size is 24×80** (window height 22). Set `vim.o.lines`86 explicitly when assertions depend on height.8788- **`vim.wait` does NOT pump the normal-mode loop.** It processes libuv89 callbacks (timers, child process I/O) but not deferred display events.90 Do not expect `WinScrolled` to fire inside `vim.wait`.9192## Entry points9394| What | Where |95|---|---|96| Test bootstrap | `tests/init.lua` |97| Helpers (git repos, waiters) | `tests/helpers.lua` |98| Framework (describe/it/assert) | `tests/framework/init.lua` |99| Run all specs | `./tests/run_tests.sh` or `make test-lua` |100| Run one spec | `nvim --headless --noplugin -u tests/init.lua -c "lua require('tests.framework').run_and_exit('tests/path/to_spec.lua')"` |101| Throwaway repro | Save to `/tmp/repro.lua`, run with `nvim --headless -u tests/init.lua -c "luafile /tmp/repro.lua" -c "qa!"` |