Skip to content

test: strengthen suite + restructure + convert e2e scenarios + fix explorer refresh race - #511

Merged
esmuellert merged 9 commits into
mainfrom
test-suite-audit-fixes
Aug 2, 2026
Merged

test: strengthen suite + restructure + convert e2e scenarios + fix explorer refresh race#511
esmuellert merged 9 commits into
mainfrom
test-suite-audit-fixes

Conversation

@esmuellert

Copy link
Copy Markdown
Owner

Summary

A test-suite audit pass. Nine commits, ranging from CI wire-up through a real behavior fix in the explorer's auto-refresh timer. All specs green on every platform (linux-x64/arm64, macos-x64/arm64, windows-x64/arm64, android-x64/arm64) plus 5 standalone-build variants; previous PR-validation run at branch tip already verified this before the rebase.

Net change to the test tree: 80 spec files (+ 5 orphaned scenarios) → 83 spec files, one uniform runner, ~30s wall-clock (previously 36s + a separate shell path).

Commits (in order)

1. test: strengthen 'success = pcall / assert.is_true(success)' checks

17 tests across lifecycle_spec.lua (7), view_spec.lua (6), core_spec.lua (rest) that only asserted "no throw" now also verify state predicates. Bug-injection proof: commenting out active_diffs[tabpage] = nil in cleanup.lua:130 now fails 3 tests that used to pass.

2. test(layout_toggle): implement 'keeps discard hunk working after toggle'

Was pending() because it used vim.wait(10000, () -> false) — an unconditional 10-second sleep that raced with the async discard chain. Converted to a real it() with a predicate-based wait on the welcome buffer becoming visible; stubbed vim.fn.confirm for headless. 585ms local run vs guaranteed 10s+ before.

3-5. E2E wire-up, then pivot to native specs

tests/e2e/ scenarios never ran in CI — the in-tree framework discovers *_spec.lua, not scenario tables. I initially wired a shell wrapper (#3, #4), then reconsidered and converted the 5 scenarios into native specs (#5), deleting scripts/nvim-e2e.lua (478 lines), tests/run_e2e.{sh,cmd}, the make test-e2e target (both live Makefile and CMake template), and the CI step. Net -988 lines / +436 lines, single invocation path.

6. test: restructure spec tree to mirror lua/ layout

  • Category A moves: tests/ui/scrollsync_spec.luatests/, tests/ui/keymap/tests/keymap/, tests/flatten_dirs_spec.luatests/ui/explorer/, tests/ui/core_spec.luatests/ui/render_core_spec.lua.
  • Category B split: explorer_spec.lua (34s serial, two describes) → explorer_spec.lua (17 tests) + explorer_refresh_spec.lua (5 tests), parallelizable across framework workers.
  • Category C merge: four *_modules_spec.lua → one tests/module_loading_spec.lua.
  • Category D renames: drop _e2e_spec suffix; rename tab_cycle_untracked_e2eissue_309_spec.lua to match sibling issue_XXX_spec.lua convention.

7. fix(explorer): skip auto-refresh ticks whose target repo is gone or not yet a repo

Real behavior fix, discovered by reading CI stderr on Windows. The 500ms explorer polling tick() could fire against a directory that had just been deleted (after_each teardown race with TabClosed cleanup) or wasn't yet a git repo (setup race on slow filesystems: :CodeDiff opens before git init finishes). git status returned "not a git repository"; process_result surfaced it as vim.notify(ERROR), flooding stderr in 8+ spec files.

Root-cause fix: guard tick() on isdirectory(git_root) and existence of .git (as directory OR file, so worktrees still work). Silently return in either race. User-visible: rm -rf on your own repo behind the explorer no longer flashes an ERROR notification.

8. test(explorer): tighten explorer_tree_render_spec ]f-navigation assertion

Discovered during the CI-log audit: my converted spec asserted only that the modified buffer still existed after ]f, which was trivially true because :CodeDiff opened with an initial selection. Verified with bug injection: with do return end at the top of navigation.next_file, the old assertion still passed. Now uses navigation.next_file() directly (avoiding a nvim_feedkeys shadowing problem that produced the E447 in the earlier CI runs), captures explorer.current_file_path before, and asserts (a) it changes and (b) the modified pane's buffer name matches the newly-selected file.

9. chore: bump version to 2.66.0

Verification

  • Full local suite: 83 spec files, 28.4s, all passing.
  • Previous PR-validation run at branch tip before rebase: all 13 platform jobs green.
  • regression-check job fails on any workflow_dispatch trigger with empty github.base_ref — pre-existing infrastructure issue, unrelated. Runs correctly on pull_request events.

Compatibility

  • One lua/ change (ui/explorer/refresh.lua, +28 lines) — behavior improvement only (silent no-op instead of error notification for a legitimately-unpollable state). Cannot break any existing behavior.
  • All other changes are test-only or CI/build. No public API changes, no config changes, no keymap changes.

esmuellert and others added 9 commits August 2, 2026 01:49
The 5 scenarios under tests/e2e/ (explorer_layout, explorer_toggle,
explorer_tree, history_layout, tab_cycle_untracked) were consumed only
by scripts/nvim-e2e.lua and never invoked by any GitHub workflow — the
in-tree test framework discovers *_spec.lua under tests/, not the
scenario tables under tests/e2e/. Post-audit these were the only tests
in the tree that could break silently on main.

Add a thin shell wrapper (tests/run_e2e.sh + .cmd) that iterates the
scenarios, launches one Neovim per scenario via SCENARIO_FILE (which
triggers the runner's auto-cquit(1) on failure), and returns non-zero
if any scenario fails. Wire it via 'make test-e2e' and add the step to
_platform-linux.yml immediately after 'make test-lua'.

Linux-only for now: scenarios exercise UI layout which is
platform-agnostic; expanding to macOS/Windows if they stay green for a
release is straightforward.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Audit found 17 tests spread across lifecycle_spec.lua (7), view_spec.lua
(6), and core_spec.lua (rest) that only asserted 'no throw' after
wrapping the SUT in pcall. State-mutating bugs (leaked sessions,
render no-ops, buffer clobbering) would pass silently.

Verified via bug injection: commenting out 'active_diffs[tabpage] = nil'
in cleanup.lua:130 (silent session-registry leak) now fails 3 tests in
this file; before the strengthening it would have passed all of them.

Each strengthened case now also verifies a state predicate:
- lifecycle_spec.lua: cleanup_all removes registry entries;
  re-registration overwrites session fields; cleanup(invalid_tab)
  doesn't touch other sessions; cleanup with deleted buffers/closed
  windows leaves get_session(tp) == nil; empty-diff session is still
  registered with valid buffer numbers.
- view_spec.lua: view.create's promised session lands (with an
  async-safe vim.wait guard, since side_by_side registers via
  vim.schedule); buffers hold the expected content; iterating create
  produces distinct sessions matching each iteration's input.
- core_spec.lua: render_diff on empty-vs-content preserves both
  buffers' source data and doesn't corrupt them.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The test was previously marked pending() because it used
'vim.wait(10000, function() return false end, 50)' — an unconditional
10-second sleep that raced with the two-step async chain (git apply
--reverse -> refresh -> status -> re-render), yielding flakes on
Windows CI.

Convert to an it() with a deterministic predicate-based wait on the
welcome buffer becoming visible (that terminal state is reached only
after the entire chain lands). Also stub vim.fn.confirm (used by
discard_hunk for its destructive-op prompt) to return 1 = '&Discard'
since headless nvim otherwise returns 0 = 'no user input' and the
whole callback silently aborts.

Adds a working-tree file-content sanity check so a stale UI can't
disguise a real discard failure.

Local run: 585ms (vs the pending version's guaranteed 10s+ sleep).
Full layout_toggle_spec.lua: 14 passed, 0 failed, 0 pending.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Makefile at the repo root is regenerated by 'file(WRITE ...)' in
CMakeLists.txt on every 'cmake -B build -S .' (which the CI 'make build'
step runs). My hand-edit to add 'test-e2e' to the tree Makefile got
silently overwritten in CI, causing 'make test-e2e' to fail with 'No
rule to make target'.

Fix at the source: add test-e2e to the .PHONY list, the default 'test'
target, and the help string in the Makefile template embedded in
CMakeLists.txt (Linux/macOS block). Windows nmake wrapper left as-is;
the e2e job is Linux-only for now.

Verified locally: regenerated Makefile via 'cmake -B build -S .',
'make test-e2e' now finds the rule and passes (5/5 scenarios).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pivot from the shell-wrapped scenario runner introduced in
6ddb099 + 0be9a1d to the framework's native describe/it pattern:

- Rewrite each of the 5 tests/e2e/*.lua scenarios into a proper
  *_spec.lua file that the in-tree framework auto-discovers.
- Delete the shell-runner scaffolding (scripts/nvim-e2e.lua,
  tests/run_e2e.sh, tests/run_e2e.cmd), the make test-e2e target
  (both live Makefile and the CMake template in CMakeLists.txt), and
  the extra CI step in _platform-linux.yml.
- Add find_window_by_filetype, wait_for_explorer, wait_for_diff_ready
  to tests/helpers.lua so the converted specs use the same helper
  surface as the other 80 specs.

Coverage is unchanged — every setup/run/validate/cleanup callback in
the scenarios maps 1:1 to before_each/it/after_each blocks with the
same assertions, plus more explicit assert.* calls (instead of one
boolean returned from validate()) so failures now surface with
precise messages. The scenarios were orphaned by the July 2026
in-tree framework rewrite (5d8c4cb): the framework discovers
*_spec.lua, not { setup, run, validate, cleanup } tables. This
migrates them onto the same runway.

Placement:
  tests/e2e/explorer_layout      -> tests/ui/explorer/explorer_layout_e2e_spec.lua
  tests/e2e/explorer_toggle      -> tests/ui/explorer/explorer_toggle_e2e_spec.lua
  tests/e2e/explorer_tree        -> tests/ui/explorer/explorer_tree_e2e_spec.lua
  tests/e2e/history_layout       -> tests/ui/history/history_layout_e2e_spec.lua
  tests/e2e/tab_cycle_untracked  -> tests/ui/explorer/tab_cycle_untracked_e2e_spec.lua

Full suite: 85 spec files, 35.0s, all green (vs 80 + 5-scenario shell
path in 36.1s before this commit). Net diff: -988 lines removed,
+436 lines added.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Four categories of changes; net -356 lines, 85 -> 83 spec files, and
the framework can now parallelize the previously monolithic
explorer_spec.lua across two workers (wall-clock 35.0s -> 28.4s in
a full local run).

A. Move specs whose source is elsewhere:
   tests/ui/scrollsync_spec.lua        -> tests/scrollsync_spec.lua
      (source: lua/codediff/scrollsync.lua, top-level not under ui/)
   tests/ui/keymap/                    -> tests/keymap/
      (source: lua/codediff/keymap/, top-level not under ui/)
   tests/flatten_dirs_spec.lua         -> tests/ui/explorer/flatten_dirs_spec.lua
      (tests an explorer feature; belongs with the other explorer specs)
   tests/ui/core_spec.lua              -> tests/ui/render_core_spec.lua
      (source: lua/codediff/ui/core.lua; renamed so it no longer collides
      mentally with tests/core/ which covers lua/codediff/core/*)

B. Split explorer_spec.lua at its natural describe seam:
   explorer_spec.lua                   -> explorer_spec.lua (17 tests)
                                        + explorer_refresh_spec.lua (5 tests)
   Same coverage; now runs as two parallel workers instead of one 34s
   serial file. Same before_each/setup_command scaffolding duplicated
   (deliberate — the two describes were self-contained already).

C. Consolidate 4 near-identical *_modules_spec.lua into one file:
   explorer_modules_spec.lua + lifecycle_modules_spec.lua +
   conflict_modules_spec.lua + history_modules_spec.lua
                                       -> tests/module_loading_spec.lua
   All 43 tests preserved; one place to keep the require-graph +
   public-API smoke in sync with the source.

D. Drop the _e2e_spec suffix on the recently-converted specs and
   normalize the issue-# convention:
   explorer_layout_e2e_spec.lua        -> explorer_window_layout_spec.lua
   explorer_toggle_e2e_spec.lua        -> explorer_visibility_spec.lua
   explorer_tree_e2e_spec.lua          -> explorer_tree_render_spec.lua
   history_layout_e2e_spec.lua         -> history_layout_spec.lua
   tab_cycle_untracked_e2e_spec.lua    -> issue_309_spec.lua
      (matches sibling issue_390_spec.lua / issue_496_spec.lua /
       issue_498_spec.lua naming in the same directory)
   Internal describe("… (E2E)") titles updated to match.

Verified: bash tests/run_tests.sh -> ALL TESTS PASSED (83 spec files,
28.4s), same 866-test surface as before this commit.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ot yet a repo

The 500ms explorer polling timer could fire against a directory that
is either being torn down or hasn't yet been initialized as a git
repo, at which point M.refresh -> git.get_status returned 'not a git
repository (or any of the parent directories): .git' and process_result
surfaced it as vim.notify(..., ERROR).

Two real races (both observed in Windows CI, one intermittently on Linux):

  1. Teardown race: an 'after_each' does rm -rf on the temp repo and
     the TabClosed autocmd running the timer cleanup hadn't fired yet.
     Any in-flight scheduled tick then polled the deleted dir.
  2. Setup race: on a slow filesystem, :CodeDiff opens the explorer
     before 'git init' has finished writing .git/. The first 500ms
     tick beats the initialization.

Fix at the source: at the top of tick(), if explorer.git_root doesn't
exist or has no .git (either directory or file, the latter covers
worktrees and submodules whose .git is a gitdir pointer), silently
return. The next tick will pick up the state once it's valid, or the
tab will be gone.

User-visible improvement: rm -rf on your own repo behind the explorer
no longer flashes an ERROR notification.

Test-visible improvement: eight or so spec files that were emitting
'Failed to refresh: fatal: not a git repository' / 'Invalid revision
HEAD' etc. to stderr (visible in the CI log's per-spec stderr blocks)
now run silently. The specs already passed, the fix is only about
noise, but the noise was masking real issues if any appeared.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…tion

The pre-strengthening assertion checked only that a modified buffer
still existed after ]f fired. That leftover buffer was ALWAYS present
because :CodeDiff opens with an initial selection, so the assertion
passed even when ]f itself no-op'd. Verified locally by injecting
`do return end` at the top of navigation.next_file — the old assertion
passed anyway, exactly the kind of hidden-failure test the audit was
meant to catch.

Two fixes:

  1. Call navigation.next_file() directly instead of feeding "]f"
     through nvim_feedkeys(..., "nx"). The tab-scoped keymap ]f is
     bound via lifecycle.set_tab_keymap, and feedkeys drains against
     whichever buffer is current at drain time, which in headless
     runs can end up being the explorer buffer with its own maps
     shadowing the tab-level entry, silently missing the navigation
     call and printing E447 to stderr. Calling the module directly
     tests the actual navigation logic without the keymap-plumbing
     noise.

  2. Assert that explorer.current_file_path CHANGES after next_file()
     and that the modified pane's buffer name matches the newly
     selected file. Uses a predicate-based vim.wait so the async diff
     render has time to land, then asserts the terminal state.
     Guard: if there's fewer than 2 changed files (next_file cycle
     is 1 element), fail early with a clear message instead of
     silently having next_file be a no-op-by-design.

Also add vim.fn.fnameescape to the initial :edit for path-safety.

Bug-injection verification: with `do return end` at the top of
navigation.next_file:
  BEFORE this commit: test passes (the bug is hidden)
  AFTER  this commit: test fails with 'next_file must select a
                      different file; still on src/a.txt'

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@esmuellert
esmuellert requested a review from yanuoma as a code owner August 2, 2026 05:53
@esmuellert
esmuellert enabled auto-merge August 2, 2026 05:53
@esmuellert
esmuellert disabled auto-merge August 2, 2026 07:07
@esmuellert
esmuellert merged commit 31510a9 into main Aug 2, 2026
19 of 20 checks passed
@esmuellert
esmuellert deleted the test-suite-audit-fixes branch August 2, 2026 07:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant