From d13aa3699a05f35057f0dad59eb92ae32f52b7ce Mon Sep 17 00:00:00 2001 From: Yanuo Ma Date: Sat, 1 Aug 2026 21:56:46 -0400 Subject: [PATCH 1/9] test(ci): wire up tests/e2e scenarios in the Linux job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .github/workflows/_platform-linux.yml | 3 ++ Makefile | 9 ++-- tests/run_e2e.cmd | 49 +++++++++++++++++++++ tests/run_e2e.sh | 61 +++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 tests/run_e2e.cmd create mode 100755 tests/run_e2e.sh diff --git a/.github/workflows/_platform-linux.yml b/.github/workflows/_platform-linux.yml index a30559b0..cc5e4be5 100644 --- a/.github/workflows/_platform-linux.yml +++ b/.github/workflows/_platform-linux.yml @@ -104,6 +104,9 @@ jobs: - name: Run Neovim tests run: make test-lua + - name: Run Neovim E2E scenarios + run: make test-e2e + - name: Upload build artifacts if: success() uses: actions/upload-artifact@v4 diff --git a/Makefile b/Makefile index 19019255..59750b79 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ # Makefile wrapper for developers (uses CMake underneath) # Users: Use build.sh instead (no CMake required) -.PHONY: all build test test-c test-lua lint format clean help bump-patch bump-minor bump-major bump-prerelease +.PHONY: all build test test-c test-lua test-e2e lint format clean help bump-patch bump-minor bump-major bump-prerelease all: build @@ -11,7 +11,7 @@ build: @cmake --build build @echo "✓ Build successful" -test: test-c test-lua +test: test-c test-lua test-e2e test-c: build @cd build && ctest --output-on-failure @@ -19,6 +19,9 @@ test-c: build test-lua: @./tests/run_tests.sh +test-e2e: + @./tests/run_e2e.sh + lint: @stylua --check lua @@ -43,5 +46,5 @@ bump-prerelease: @node scripts/bump_version.mjs prerelease help: - @echo "Targets: build, test, test-c, test-lua, lint, clean, help" + @echo "Targets: build, test, test-c, test-lua, test-e2e, lint, clean, help" @echo "Version: bump-patch, bump-minor, bump-major, bump-prerelease" diff --git a/tests/run_e2e.cmd b/tests/run_e2e.cmd new file mode 100644 index 00000000..f774ecff --- /dev/null +++ b/tests/run_e2e.cmd @@ -0,0 +1,49 @@ +@echo off +REM E2E scenario runner for codediff.nvim (Windows). +REM +REM Each tests/e2e/*.lua scenario is a table with { setup, run, validate, +REM cleanup } phases, driven by scripts/nvim-e2e.lua. This wrapper runs every +REM scenario in its own Neovim process (matching the *_spec framework's +REM isolation) and returns non-zero if any scenario fails. Kept in sync with +REM tests/run_e2e.sh. + +setlocal enabledelayedexpansion +pushd "%~dp0.." + +set /a TOTAL=0 +set /a PASSED=0 +set /a FAILED=0 +set FAILED_NAMES= + +for %%F in (tests\e2e\*.lua) do ( + set /a TOTAL+=1 + set NAME=%%~nF + "%TEMP%\e2e_!NAME!.log" 2>&1 + if !ERRORLEVEL! EQU 0 ( + echo PASS + set /a PASSED+=1 + ) else ( + echo FAIL + set /a FAILED+=1 + set FAILED_NAMES=!FAILED_NAMES! !NAME! + echo --- %%F output --- + type "%TEMP%\e2e_!NAME!.log" + echo --- end %%F output --- + ) +) + +echo. +echo E2E: !PASSED! passed, !FAILED! failed of !TOTAL! scenarios +if !FAILED! GTR 0 ( + echo Failed scenarios:!FAILED_NAMES! + set EXIT_CODE=1 +) else ( + set EXIT_CODE=0 +) + +popd +exit /b %EXIT_CODE% diff --git a/tests/run_e2e.sh b/tests/run_e2e.sh new file mode 100755 index 00000000..ad27c68c --- /dev/null +++ b/tests/run_e2e.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# E2E scenario runner for codediff.nvim. +# +# Each `tests/e2e/*.lua` scenario is a table with { setup, run, validate, +# cleanup } phases, driven by `scripts/nvim-e2e.lua`. This wrapper runs every +# scenario in its own Neovim process (matches the isolation the *_spec +# framework already gets) and returns non-zero if any scenario fails. +# +# CI parses only the final line of stdout for the summary; individual +# scenario output goes to stderr for the human reader / build log. + +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +cd "$PROJECT_ROOT" + +scenarios=(tests/e2e/*.lua) +if [ ${#scenarios[@]} -eq 0 ]; then + echo "No E2E scenarios found under tests/e2e/" + exit 0 +fi + +total=${#scenarios[@]} +passed=0 +failed=0 +failed_names=() + +for scenario in "${scenarios[@]}"; do + name="$(basename "$scenario" .lua)" + printf '[e2e] %-40s ' "$name" >&2 + + # Run each scenario in isolation. `--noplugin -u tests/init.lua` matches + # the *_spec bootstrap so scenarios see the same runtime environment. + # SCENARIO_FILE triggers the auto-run branch at the bottom of nvim-e2e.lua + # which cquit(1)s on failure, giving us a reliable exit code. + if SCENARIO_FILE="$scenario" \ + nvim --headless --noplugin -u tests/init.lua \ + -c "luafile scripts/nvim-e2e.lua" \ + -c "qa!" >/tmp/e2e_${name}.log 2>&1; then + echo "PASS" >&2 + passed=$((passed + 1)) + else + echo "FAIL" >&2 + failed=$((failed + 1)) + failed_names+=("$name") + # Surface the failing scenario's output so the CI log has the diagnostic. + echo "--- $scenario output ---" >&2 + cat "/tmp/e2e_${name}.log" >&2 + echo "--- end $scenario output ---" >&2 + fi +done + +echo "" >&2 +echo "E2E: $passed passed, $failed failed of $total scenarios" +if [ $failed -gt 0 ]; then + echo "Failed scenarios: ${failed_names[*]}" >&2 + exit 1 +fi +exit 0 From fb73c8dc9d011a2e633b2bed1d80799200265ef1 Mon Sep 17 00:00:00 2001 From: Yanuo Ma Date: Sat, 1 Aug 2026 21:57:07 -0400 Subject: [PATCH 2/9] test: strengthen 'success = pcall / assert.is_true(success)' checks 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> --- tests/ui/core_spec.lua | 9 ++- tests/ui/lifecycle/lifecycle_spec.lua | 54 +++++++++++++++-- tests/ui/view/view_spec.lua | 87 ++++++++++++++++++++++----- 3 files changed, 131 insertions(+), 19 deletions(-) diff --git a/tests/ui/core_spec.lua b/tests/ui/core_spec.lua index d269b058..98d8fdbb 100644 --- a/tests/ui/core_spec.lua +++ b/tests/ui/core_spec.lua @@ -433,9 +433,16 @@ describe("Render Core", function() assert.is_true(success, "Should handle empty file vs content without error") - -- Verify buffers are still valid after rendering + -- Buffers must still be valid AND still hold the source data — a defensive + -- render for the "one side is empty" case must not clobber the caller's + -- content or destroy the buffers. assert.is_true(vim.api.nvim_buf_is_valid(left_buf), "Left buffer should remain valid") assert.is_true(vim.api.nvim_buf_is_valid(right_buf), "Right buffer should remain valid") + assert.equal(0, vim.api.nvim_buf_line_count(left_buf) == 1 and #vim.api.nvim_buf_get_lines(left_buf, 0, 1, false)[1] or 1, + "left buffer must still be empty (an empty buffer reads as {\"\"}, line_count=1)") + assert.equal("line 1\nline 2\nline 3", + table.concat(vim.api.nvim_buf_get_lines(right_buf, 0, -1, false), "\n"), + "right buffer must still hold its source content") vim.api.nvim_buf_delete(left_buf, {force = true}) vim.api.nvim_buf_delete(right_buf, {force = true}) diff --git a/tests/ui/lifecycle/lifecycle_spec.lua b/tests/ui/lifecycle/lifecycle_spec.lua index d2df3716..66e0dd54 100644 --- a/tests/ui/lifecycle/lifecycle_spec.lua +++ b/tests/ui/lifecycle/lifecycle_spec.lua @@ -164,12 +164,16 @@ describe("Render Lifecycle", function() left_buf, right_buf, left_win, right_win, lines_diff) end - -- Should cleanup all without error + -- Should cleanup all without error AND leave no session registered. local success = pcall(function() lifecycle.cleanup_all() end) assert.is_true(success, "Should cleanup all sessions without error") + for _, tp in ipairs(tabs) do + assert.is_nil(lifecycle.get_session(tp), + "cleanup_all must remove every registered session, but tab " .. tostring(tp) .. " still has one") + end -- Cleanup tabs and buffers for _, tab in ipairs(tabs) do @@ -225,6 +229,15 @@ describe("Render Lifecycle", function() end) assert.is_true(success, "Should handle re-registration without error") + -- Re-registration must overwrite: the session for this tab reflects the + -- new paths, not the initial ones. Without this check a re-register that + -- silently no-ops would slip through. + local sess = lifecycle.get_session(tabpage) + assert.is_not_nil(sess, "session must still exist after re-registration") + assert.equal("test_file3.txt", sess.original, + "re-registration must overwrite `original` with the new value; got " .. tostring(sess.original)) + assert.equal("test_file4.txt", sess.modified, + "re-registration must overwrite `modified` with the new value; got " .. tostring(sess.modified)) vim.cmd('tabclose') vim.api.nvim_buf_delete(left_buf, {force = true}) @@ -233,14 +246,37 @@ describe("Render Lifecycle", function() -- Test 7: Cleanup invalid tabpage it("Handles cleanup of non-existent tabpage gracefully", function() + -- Register a real session first so we can assert cleanup with a bogus + -- tabpage doesn't accidentally wipe other sessions. + local left_buf = vim.api.nvim_create_buf(false, true) + local right_buf = vim.api.nvim_create_buf(false, true) + vim.cmd('tabnew') + local real_tab = vim.api.nvim_get_current_tabpage() + vim.cmd('vsplit') + local lw = vim.api.nvim_get_current_win() + vim.cmd('wincmd l') + local rw = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(lw, left_buf) + vim.api.nvim_win_set_buf(rw, right_buf) + local ld = diff.compute_diff({ "a" }, { "b" }) + lifecycle.create_session(real_tab, "standalone", nil, "a.txt", "b.txt", "WORKING", "WORKING", + left_buf, right_buf, lw, rw, ld) + assert.is_not_nil(lifecycle.get_session(real_tab)) + local fake_tabpage = 99999 - -- Should not crash + -- Should not crash AND must not clobber the real session. local success = pcall(function() lifecycle.cleanup(fake_tabpage) end) assert.is_true(success, "Should handle invalid tabpage cleanup gracefully") + assert.is_not_nil(lifecycle.get_session(real_tab), + "cleanup(bogus_tabpage) must not remove sessions belonging to other tabs") + + vim.cmd('tabclose') + vim.api.nvim_buf_delete(left_buf, { force = true }) + vim.api.nvim_buf_delete(right_buf, { force = true }) end) -- Test 8: Cleanup with invalid buffers @@ -269,12 +305,14 @@ describe("Render Lifecycle", function() vim.api.nvim_buf_delete(left_buf, {force = true}) vim.api.nvim_buf_delete(right_buf, {force = true}) - -- Cleanup should not crash + -- Cleanup should not crash AND must remove the entry from the registry. local success = pcall(function() lifecycle.cleanup(tabpage) end) assert.is_true(success, "Should handle cleanup with deleted buffers gracefully") + assert.is_nil(lifecycle.get_session(tabpage), + "cleanup must remove the session even when its buffers are already gone") -- Close tab manually (don't use tabclose which might fail if it's the last tab) if vim.api.nvim_tabpage_is_valid(tabpage) then @@ -427,6 +465,12 @@ describe("Render Lifecycle", function() end) assert.is_true(success, "Should handle empty lines without error") + -- Empty-diff sessions must still be registered — the plugin never treats + -- "no diff" as "no session" (that would defeat the welcome-page path). + local sess = lifecycle.get_session(tabpage) + assert.is_not_nil(sess, "session must be created even when the diff is empty") + assert.equal(left_buf, sess.original_bufnr) + assert.equal(right_buf, sess.modified_bufnr) vim.cmd('tabclose') vim.api.nvim_buf_delete(left_buf, {force = true}) @@ -508,12 +552,14 @@ describe("Render Lifecycle", function() -- Close one window vim.api.nvim_win_close(left_win, true) - -- Cleanup should not crash + -- Cleanup should not crash AND must remove the entry from the registry. local success = pcall(function() lifecycle.cleanup(tabpage) end) assert.is_true(success, "Should handle cleanup with closed windows gracefully") + assert.is_nil(lifecycle.get_session(tabpage), + "cleanup must remove the session even when one of its windows was already closed") vim.cmd('tabclose') vim.api.nvim_buf_delete(left_buf, {force = true}) diff --git a/tests/ui/view/view_spec.lua b/tests/ui/view/view_spec.lua index 31674b4a..6a6d22d6 100644 --- a/tests/ui/view/view_spec.lua +++ b/tests/ui/view/view_spec.lua @@ -241,12 +241,22 @@ describe("Render View", function() vim.fn.writefile(original, left_path) vim.fn.writefile(modified, right_path) - local success = pcall(function() - local result, tabpage = create_test_diff_view(original, modified, left_path, right_path) + local pre_tabs = vim.fn.tabpagenr("$") + local success, tabpage + success = pcall(function() + _, tabpage = create_test_diff_view(original, modified, left_path, right_path) end) - assert.is_true(success, "Should handle empty files without error") + -- A side-by-side view was actually created: new tab + registered session. + assert.equal(pre_tabs + 1, vim.fn.tabpagenr("$"), "a new tab should exist for the diff view") + assert.is_not_nil(tabpage) + vim.wait(2000, function() return lifecycle.get_session(tabpage) ~= nil end, 25) + local sess = lifecycle.get_session(tabpage) + assert.is_not_nil(sess, "empty-file diff view must still register a session") + assert.is_true(vim.api.nvim_buf_is_valid(sess.original_bufnr)) + assert.is_true(vim.api.nvim_buf_is_valid(sess.modified_bufnr)) + vim.fn.delete(left_path) vim.fn.delete(right_path) end) @@ -293,12 +303,20 @@ describe("Render View", function() vim.fn.writefile(lines, left_path) vim.fn.writefile(lines, right_path) + local result, tabpage local success = pcall(function() - local result, tabpage = create_test_diff_view(lines, lines, left_path, right_path) - return result ~= nil + result, tabpage = create_test_diff_view(lines, lines, left_path, right_path) end) - assert.is_true(success, "Should create view even with no changes") + assert.is_not_nil(result, "view.create should return non-nil for identical files") + -- Session exists and both panes show the shared content. + vim.wait(2000, function() return lifecycle.get_session(tabpage) ~= nil end, 25) + local sess = lifecycle.get_session(tabpage) + assert.is_not_nil(sess) + local orig = table.concat(vim.api.nvim_buf_get_lines(sess.original_bufnr, 0, -1, false), "\n") + local mod = table.concat(vim.api.nvim_buf_get_lines(sess.modified_bufnr, 0, -1, false), "\n") + assert.equal(orig, mod, "identical files must render identical content in both panes") + assert.is_true(orig:find("line 1", 1, true) ~= nil, "expected content missing from original pane") vim.fn.delete(left_path) vim.fn.delete(right_path) @@ -375,12 +393,21 @@ describe("Render View", function() vim.fn.writefile(original, left_path) vim.fn.writefile(modified, right_path) + local tabpage local success = pcall(function() - local result, tabpage = create_test_diff_view(original, modified, left_path, right_path) + _, tabpage = create_test_diff_view(original, modified, left_path, right_path) end) - assert.is_true(success, "Should handle single-line files") + -- The rendered content on each pane matches the source lines. + vim.wait(2000, function() return lifecycle.get_session(tabpage) ~= nil end, 25) + local sess = lifecycle.get_session(tabpage) + assert.is_not_nil(sess) + assert.equal("single line", + table.concat(vim.api.nvim_buf_get_lines(sess.original_bufnr, 0, -1, false), "\n")) + assert.equal("different line", + table.concat(vim.api.nvim_buf_get_lines(sess.modified_bufnr, 0, -1, false), "\n")) + vim.fn.delete(left_path) vim.fn.delete(right_path) end) @@ -396,12 +423,24 @@ describe("Render View", function() vim.fn.writefile(original, left_path) vim.fn.writefile(modified, right_path) + local tabpage local success = pcall(function() - local result, tabpage = create_test_diff_view(original, modified, left_path, right_path) + _, tabpage = create_test_diff_view(original, modified, left_path, right_path) end) - assert.is_true(success, "Should handle special characters") + -- Each special character survives round-tripping through the diff render + -- into the pane buffers (regression guard for shell-escape / quote-eating). + vim.wait(2000, function() return lifecycle.get_session(tabpage) ~= nil end, 25) + local sess = lifecycle.get_session(tabpage) + assert.is_not_nil(sess) + local orig = table.concat(vim.api.nvim_buf_get_lines(sess.original_bufnr, 0, -1, false), "\n") + local mod = table.concat(vim.api.nvim_buf_get_lines(sess.modified_bufnr, 0, -1, false), "\n") + assert.is_true(orig:find("'quotes'", 1, true) ~= nil, "single quotes must survive") + assert.is_true(orig:find('"double quotes"', 1, true) ~= nil, "double quotes must survive") + assert.is_true(mod:find("$dollar", 1, true) ~= nil, "dollar sign must survive") + assert.is_true(mod:find("`backtick`", 1, true) ~= nil, "backticks must survive") + vim.fn.delete(left_path) vim.fn.delete(right_path) end) @@ -454,12 +493,23 @@ describe("Render View", function() vim.fn.writefile(original, left_path) vim.fn.writefile(modified, right_path) + local tabpage local success = pcall(function() - local result, tabpage = create_test_diff_view(original, modified, left_path, right_path) + _, tabpage = create_test_diff_view(original, modified, left_path, right_path) end) - assert.is_true(success, "Should handle many hunks") + -- The rendered diff carries at least as many hunks as we injected — the + -- upstream diff engine can merge adjacent changes, so accept "many" (>= 10) + -- rather than exactly 25. + vim.wait(2000, function() return lifecycle.get_session(tabpage) ~= nil end, 25) + local sess = lifecycle.get_session(tabpage) + assert.is_not_nil(sess) + assert.is_not_nil(sess.stored_diff_result) + local changes = sess.stored_diff_result.changes or {} + assert.is_true(#changes >= 10, + "expected many change hunks in a 25-mod file; got " .. tostring(#changes)) + vim.fn.delete(left_path) vim.fn.delete(right_path) end) @@ -476,12 +526,21 @@ describe("Render View", function() vim.fn.writefile(original, left_path) vim.fn.writefile(modified, right_path) + local tabpage local success = pcall(function() - local result, tabpage = create_test_diff_view(original, modified, left_path, right_path) + _, tabpage = create_test_diff_view(original, modified, left_path, right_path) end) - assert.is_true(success, "Iteration " .. i .. " should succeed") + -- Each iteration must produce its OWN session (not silently reuse a stale + -- one) — check that the session's content matches THIS iteration's input. + vim.wait(2000, function() return lifecycle.get_session(tabpage) ~= nil end, 25) + local sess = lifecycle.get_session(tabpage) + assert.is_not_nil(sess, "iteration " .. i .. " should register its own session") + local mod = table.concat(vim.api.nvim_buf_get_lines(sess.modified_bufnr, 0, -1, false), "\n") + assert.is_true(mod:find("changed " .. i, 1, true) ~= nil, + "iteration " .. i .. " modified pane should show 'changed " .. i .. "', got: " .. mod) + vim.fn.delete(left_path) vim.fn.delete(right_path) end From 7fe4afd79523aad2846137adac3e30bb4eda8cd1 Mon Sep 17 00:00:00 2001 From: Yanuo Ma Date: Sat, 1 Aug 2026 21:57:25 -0400 Subject: [PATCH 3/9] test(layout_toggle): implement 'keeps discard hunk working after toggle' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- tests/ui/view/layout_toggle_spec.lua | 49 ++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/tests/ui/view/layout_toggle_spec.lua b/tests/ui/view/layout_toggle_spec.lua index 513eab17..ec248e67 100644 --- a/tests/ui/view/layout_toggle_spec.lua +++ b/tests/ui/view/layout_toggle_spec.lua @@ -736,10 +736,16 @@ describe("Layout toggle", function() assert.is_true(s and s.modified_revision == nil, "Unstaging a hunk should still work after toggling back") end) - -- SKIPPED: requires two back-to-back async git operations (apply + status) - -- which is unreliable on Windows CI. Re-enable when test helper API supports - -- deterministic async chains. - pending("keeps discard hunk working after toggle", function() + it("keeps discard hunk working after toggle", function() + -- Regression: after `t` toggles layout to inline, the discard-hunk + -- callback (bound to `K` by the outer describe's before_each) must still + -- fire against the correct buffer AND the follow-up refresh must observe + -- that the file now matches HEAD (no diff → welcome page). The prior + -- `pending(...)` marked this out because the assertion used + -- `vim.wait(10000, function() return false end)` — an unconditional 10s + -- sleep that raced with the two-step async chain (git apply → status + -- refresh → re-render). This version uses a real predicate on the + -- welcome buffer, so it terminates as soon as the chain lands. repo = h.create_temp_git_repo() repo.write_file("file.txt", { "line 1", "line 2", "line 3" }) repo.git("add file.txt") @@ -781,23 +787,38 @@ describe("Layout toggle", function() local session = lifecycle.get_session(tabpage) move_cursor_to_hunk(session.modified_win, session.modified_bufnr, session.stored_diff_result.changes[1].modified) - local old_select = vim.ui.select - vim.ui.select = function(items, _, on_choice) - on_choice(items[1]) - end + -- discard_hunk pops a confirm dialog via `vim.fn.confirm`; in headless + -- that returns 0 (no user input). Stub it to auto-select "Discard" (the + -- 1st option in "&Discard\n&Cancel") so the async chain proceeds unattended. + local old_confirm = vim.fn.confirm + vim.fn.confirm = function() return 1 end local discard_cb = get_buffer_mapping_callback(vim.api.nvim_win_get_buf(session.modified_win), "K") assert.is_function(discard_cb, "discard_hunk mapping should exist after toggle") discard_cb() - vim.wait(10000, function() - return false - end, 50) + -- Deterministic wait for the full chain: git apply --reverse writes the + -- working tree back to HEAD, the refresh callback re-runs `git status`, + -- the empty result triggers the welcome page. Poll for that terminal + -- state instead of sleeping a flat 10s (fixes the Windows-CI flake called + -- out in the previous `pending` comment). + local welcome_ready = vim.wait(15000, function() + local s = lifecycle.get_session(tabpage) + return s + and s.modified_bufnr + and vim.api.nvim_buf_is_valid(s.modified_bufnr) + and welcome.is_welcome_buffer(s.modified_bufnr) + end, 100) - vim.ui.select = old_select + vim.fn.confirm = old_confirm - local s = lifecycle.get_session(tabpage) - assert.is_true(s and welcome.is_welcome_buffer(s.modified_bufnr), "Discarding the last hunk after toggle should restore a clean welcome state") + assert.is_true(welcome_ready, + "discard_hunk after layout toggle should end in the welcome buffer once the async chain completes") + -- Sanity: the working tree really does match HEAD again (git-level check + -- so we don't confuse a stale UI with actual discard success). + local worktree = table.concat(vim.fn.readfile(repo.path("file.txt")), "\n") + assert.equal("line 1\nline 2\nline 3", worktree, + "working-tree file must be restored to its HEAD content after discard_hunk") end) it("does not persist the layout override across separate CodeDiff runs", function() From 7ac202512cf515d88650880bfa2745d91a02aba7 Mon Sep 17 00:00:00 2001 From: Yanuo Ma Date: Sat, 1 Aug 2026 22:04:42 -0400 Subject: [PATCH 4/9] fix(build): register test-e2e in the CMake-generated Makefile template 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> --- CMakeLists.txt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 9e01e009..c68de3f3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -50,7 +50,7 @@ file(WRITE "${CMAKE_CURRENT_SOURCE_DIR}/Makefile" # Makefile wrapper for developers (uses CMake underneath) # Users: Use build.sh instead (no CMake required) -.PHONY: all build test test-c test-lua lint format clean help bump-patch bump-minor bump-major bump-prerelease +.PHONY: all build test test-c test-lua test-e2e lint format clean help bump-patch bump-minor bump-major bump-prerelease all: build @@ -59,7 +59,7 @@ build: \t@cmake --build build \t@echo \"✓ Build successful\" -test: test-c test-lua +test: test-c test-lua test-e2e test-c: build \t@cd build && ctest --output-on-failure @@ -67,6 +67,9 @@ test-c: build test-lua: \t@./tests/run_tests.sh +test-e2e: +\t@./tests/run_e2e.sh + lint: \t@stylua --check lua @@ -91,7 +94,7 @@ bump-prerelease: \t@node scripts/bump_version.mjs prerelease help: -\t@echo \"Targets: build, test, test-c, test-lua, lint, clean, help\" +\t@echo \"Targets: build, test, test-c, test-lua, test-e2e, lint, clean, help\" \t@echo \"Version: bump-patch, bump-minor, bump-major, bump-prerelease\" ") From 3400fca25ca909377ed5c6853e68d2e69526e37c Mon Sep 17 00:00:00 2001 From: Yanuo Ma Date: Sun, 2 Aug 2026 01:05:28 -0400 Subject: [PATCH 5/9] test: convert tests/e2e scenarios to in-tree spec files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .github/workflows/_platform-linux.yml | 3 - CMakeLists.txt | 9 +- Makefile | 9 +- scripts/nvim-e2e.lua | 478 ------------------ tests/e2e/explorer_layout.lua | 57 --- tests/e2e/explorer_toggle.lua | 71 --- tests/e2e/explorer_tree.lua | 58 --- tests/e2e/history_layout.lua | 66 --- tests/e2e/tab_cycle_untracked.lua | 133 ----- tests/helpers.lua | 36 ++ tests/run_e2e.cmd | 49 -- tests/run_e2e.sh | 61 --- .../ui/explorer/explorer_layout_e2e_spec.lua | 62 +++ .../ui/explorer/explorer_toggle_e2e_spec.lua | 61 +++ tests/ui/explorer/explorer_tree_e2e_spec.lua | 64 +++ .../explorer/tab_cycle_untracked_e2e_spec.lua | 146 ++++++ tests/ui/history/history_layout_e2e_spec.lua | 61 +++ 17 files changed, 436 insertions(+), 988 deletions(-) delete mode 100644 scripts/nvim-e2e.lua delete mode 100644 tests/e2e/explorer_layout.lua delete mode 100644 tests/e2e/explorer_toggle.lua delete mode 100644 tests/e2e/explorer_tree.lua delete mode 100644 tests/e2e/history_layout.lua delete mode 100644 tests/e2e/tab_cycle_untracked.lua delete mode 100644 tests/run_e2e.cmd delete mode 100755 tests/run_e2e.sh create mode 100644 tests/ui/explorer/explorer_layout_e2e_spec.lua create mode 100644 tests/ui/explorer/explorer_toggle_e2e_spec.lua create mode 100644 tests/ui/explorer/explorer_tree_e2e_spec.lua create mode 100644 tests/ui/explorer/tab_cycle_untracked_e2e_spec.lua create mode 100644 tests/ui/history/history_layout_e2e_spec.lua diff --git a/.github/workflows/_platform-linux.yml b/.github/workflows/_platform-linux.yml index cc5e4be5..a30559b0 100644 --- a/.github/workflows/_platform-linux.yml +++ b/.github/workflows/_platform-linux.yml @@ -104,9 +104,6 @@ jobs: - name: Run Neovim tests run: make test-lua - - name: Run Neovim E2E scenarios - run: make test-e2e - - name: Upload build artifacts if: success() uses: actions/upload-artifact@v4 diff --git a/CMakeLists.txt b/CMakeLists.txt index c68de3f3..9e01e009 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -50,7 +50,7 @@ file(WRITE "${CMAKE_CURRENT_SOURCE_DIR}/Makefile" # Makefile wrapper for developers (uses CMake underneath) # Users: Use build.sh instead (no CMake required) -.PHONY: all build test test-c test-lua test-e2e lint format clean help bump-patch bump-minor bump-major bump-prerelease +.PHONY: all build test test-c test-lua lint format clean help bump-patch bump-minor bump-major bump-prerelease all: build @@ -59,7 +59,7 @@ build: \t@cmake --build build \t@echo \"✓ Build successful\" -test: test-c test-lua test-e2e +test: test-c test-lua test-c: build \t@cd build && ctest --output-on-failure @@ -67,9 +67,6 @@ test-c: build test-lua: \t@./tests/run_tests.sh -test-e2e: -\t@./tests/run_e2e.sh - lint: \t@stylua --check lua @@ -94,7 +91,7 @@ bump-prerelease: \t@node scripts/bump_version.mjs prerelease help: -\t@echo \"Targets: build, test, test-c, test-lua, test-e2e, lint, clean, help\" +\t@echo \"Targets: build, test, test-c, test-lua, lint, clean, help\" \t@echo \"Version: bump-patch, bump-minor, bump-major, bump-prerelease\" ") diff --git a/Makefile b/Makefile index 59750b79..19019255 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ # Makefile wrapper for developers (uses CMake underneath) # Users: Use build.sh instead (no CMake required) -.PHONY: all build test test-c test-lua test-e2e lint format clean help bump-patch bump-minor bump-major bump-prerelease +.PHONY: all build test test-c test-lua lint format clean help bump-patch bump-minor bump-major bump-prerelease all: build @@ -11,7 +11,7 @@ build: @cmake --build build @echo "✓ Build successful" -test: test-c test-lua test-e2e +test: test-c test-lua test-c: build @cd build && ctest --output-on-failure @@ -19,9 +19,6 @@ test-c: build test-lua: @./tests/run_tests.sh -test-e2e: - @./tests/run_e2e.sh - lint: @stylua --check lua @@ -46,5 +43,5 @@ bump-prerelease: @node scripts/bump_version.mjs prerelease help: - @echo "Targets: build, test, test-c, test-lua, test-e2e, lint, clean, help" + @echo "Targets: build, test, test-c, test-lua, lint, clean, help" @echo "Version: bump-patch, bump-minor, bump-major, bump-prerelease" diff --git a/scripts/nvim-e2e.lua b/scripts/nvim-e2e.lua deleted file mode 100644 index 73811aef..00000000 --- a/scripts/nvim-e2e.lua +++ /dev/null @@ -1,478 +0,0 @@ --- Neovim headless E2E runner for codediff plugin --- Runs scenario scripts that simulate full user workflows --- --- Usage: --- nvim --headless -u tests/init.lua -c "lua dofile('scripts/nvim-e2e.lua').run('path/to/scenario.lua')" -c "qa!" --- --- Or with inline scenario: --- SCENARIO_FILE=/tmp/scenario.lua nvim --headless -u tests/init.lua -c "luafile scripts/nvim-e2e.lua" -c "qa!" - -local M = {} - -------------------------------------------------------------------------------- --- Utilities -------------------------------------------------------------------------------- - -local function print_separator(title) - print(string.rep("=", 60)) - print(title) - print(string.rep("=", 60)) -end - -local function print_result(success, msg) - if success then - print("✓ PASS: " .. msg) - else - print("✗ FAIL: " .. msg) - end -end - -------------------------------------------------------------------------------- --- Git Repository Helpers -------------------------------------------------------------------------------- - -function M.create_temp_git_repo() - local temp_dir = vim.fn.tempname() - vim.fn.mkdir(temp_dir, "p") - - local function git(args) - local cmd = string.format('git -C "%s" %s', temp_dir, args) - local output = vim.fn.system(cmd) - return output, vim.v.shell_error - end - - git("init") - git("config user.email 'test@test.com'") - git("config user.name 'Test'") - git("branch -m main") - - -- Get canonical path from git - local output = git("rev-parse --show-toplevel") - if output then - local canonical = vim.trim(output) - if canonical and canonical ~= '' then - temp_dir = canonical - end - end - - return { - dir = temp_dir, - git = git, - write_file = function(rel_path, lines) - local full_path = temp_dir .. "/" .. rel_path - local parent = vim.fn.fnamemodify(full_path, ":h") - vim.fn.mkdir(parent, "p") - vim.fn.writefile(lines, full_path) - return full_path - end, - read_file = function(rel_path) - local full_path = temp_dir .. "/" .. rel_path - if vim.fn.filereadable(full_path) == 1 then - return vim.fn.readfile(full_path) - end - return nil - end, - path = function(rel_path) - return temp_dir .. "/" .. rel_path - end, - cleanup = function() - vim.fn.delete(temp_dir, "rf") - end - } -end - -------------------------------------------------------------------------------- --- Waiting Helpers -------------------------------------------------------------------------------- - -function M.wait(timeout_ms, condition_fn, interval_ms) - timeout_ms = timeout_ms or 5000 - interval_ms = interval_ms or 50 - if condition_fn then - return vim.wait(timeout_ms, condition_fn, interval_ms) - else - vim.wait(timeout_ms) - return true - end -end - -function M.wait_for_new_tab(timeout_ms) - timeout_ms = timeout_ms or 5000 - local initial_tabs = vim.fn.tabpagenr('$') - return vim.wait(timeout_ms, function() - return vim.fn.tabpagenr('$') > initial_tabs - end, 50) -end - -function M.wait_for_explorer(timeout_ms) - timeout_ms = timeout_ms or 5000 - return vim.wait(timeout_ms, function() - return M.find_window_by_filetype("codediff-explorer") ~= nil - end, 50) -end - -function M.wait_for_diff_ready(timeout_ms) - timeout_ms = timeout_ms or 10000 - local lifecycle = require('codediff.ui.lifecycle') - local tabpage = vim.api.nvim_get_current_tabpage() - - return vim.wait(timeout_ms, function() - local session = lifecycle.get_session(tabpage) - if not session then return false end - if not session.stored_diff_result then return false end - - local orig_buf, mod_buf = lifecycle.get_buffers(tabpage) - if not orig_buf or not mod_buf then return false end - - return vim.api.nvim_buf_is_valid(orig_buf) and vim.api.nvim_buf_is_valid(mod_buf) - end, 100) -end - -function M.wait_for_buffer_content(bufnr, expected_text, timeout_ms) - timeout_ms = timeout_ms or 5000 - return vim.wait(timeout_ms, function() - local content = M.get_buffer_content(bufnr) - return content and content:find(expected_text, 1, true) ~= nil - end, 50) -end - -------------------------------------------------------------------------------- --- Window and Buffer Helpers -------------------------------------------------------------------------------- - -function M.find_window_by_filetype(filetype) - for i = 1, vim.fn.winnr('$') do - local winid = vim.fn.win_getid(i) - local bufnr = vim.api.nvim_win_get_buf(winid) - if vim.bo[bufnr].filetype == filetype then - return winid, bufnr - end - end - return nil, nil -end - -function M.get_all_windows() - local windows = {} - for i = 1, vim.fn.winnr('$') do - local winid = vim.fn.win_getid(i) - local bufnr = vim.api.nvim_win_get_buf(winid) - table.insert(windows, { - winid = winid, - bufnr = bufnr, - filetype = vim.bo[bufnr].filetype, - bufname = vim.api.nvim_buf_get_name(bufnr), - }) - end - return windows -end - -function M.focus_window(winid) - if winid and vim.api.nvim_win_is_valid(winid) then - vim.api.nvim_set_current_win(winid) - return true - end - return false -end - -function M.focus_explorer() - local winid = M.find_window_by_filetype("codediff-explorer") - return M.focus_window(winid) -end - -function M.get_buffer_content(bufnr) - if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then - return nil - end - return table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), "\n") -end - -function M.get_buffer_lines(bufnr) - if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then - return nil - end - return vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) -end - -function M.get_cursor_position() - local pos = vim.api.nvim_win_get_cursor(0) - return { line = pos[1], col = pos[2] } -end - -function M.set_cursor_position(line, col) - vim.api.nvim_win_set_cursor(0, {line, col or 0}) -end - -------------------------------------------------------------------------------- --- Diff Session Helpers -------------------------------------------------------------------------------- - -function M.get_diff_buffers() - local lifecycle = require('codediff.ui.lifecycle') - local tabpage = vim.api.nvim_get_current_tabpage() - local orig_buf, mod_buf = lifecycle.get_buffers(tabpage) - return orig_buf, mod_buf -end - -function M.get_diff_session() - local lifecycle = require('codediff.ui.lifecycle') - local tabpage = vim.api.nvim_get_current_tabpage() - return lifecycle.get_session(tabpage) -end - -function M.get_original_content() - local orig_buf, _ = M.get_diff_buffers() - return M.get_buffer_content(orig_buf) -end - -function M.get_modified_content() - local _, mod_buf = M.get_diff_buffers() - return M.get_buffer_content(mod_buf) -end - -------------------------------------------------------------------------------- --- Explorer Helpers -------------------------------------------------------------------------------- - -function M.get_explorer_files() - local winid, bufnr = M.find_window_by_filetype("codediff-explorer") - if not bufnr then return nil end - return M.get_buffer_lines(bufnr) -end - -function M.select_explorer_item(line_number) - local winid = M.find_window_by_filetype("codediff-explorer") - if not winid then return false end - - M.focus_window(winid) - M.set_cursor_position(line_number) - M.feedkeys("") - return true -end - -------------------------------------------------------------------------------- --- Command and Keymap Helpers -------------------------------------------------------------------------------- - -function M.exec(cmd) - local ok, err = pcall(vim.cmd, cmd) - return ok, err -end - -function M.feedkeys(keys, mode) - mode = mode or "n" - vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(keys, true, false, true), mode, false) - vim.wait(100) -end - --- Execute keymap with wait for result -function M.press(keys, wait_ms) - M.feedkeys(keys) - if wait_ms then - M.wait(wait_ms) - end -end - --- Navigation keymaps (using plugin defaults) -function M.next_hunk() M.feedkeys("]c") end -function M.prev_hunk() M.feedkeys("[c") end -function M.next_file() M.feedkeys("]f") end -function M.prev_file() M.feedkeys("[f") end -function M.toggle_stage() M.feedkeys("-") end -function M.toggle_explorer() M.feedkeys("b") end -function M.quit_diff() M.feedkeys("q") end - --- Conflict keymaps -function M.accept_incoming() M.feedkeys("ct") end -function M.accept_current() M.feedkeys("co") end -function M.accept_both() M.feedkeys("cb") end -function M.next_conflict() M.feedkeys("]x") end -function M.prev_conflict() M.feedkeys("[x") end - --- Diff get/put -function M.diff_get() M.feedkeys("do") end -function M.diff_put() M.feedkeys("dp") end - -------------------------------------------------------------------------------- --- Git Status Helpers -------------------------------------------------------------------------------- - -function M.get_git_status(repo_dir) - local git = require('codediff.core.git') - local result = nil - local done = false - - git.get_status(repo_dir, function(err, status) - if not err then - result = status - end - done = true - end) - - M.wait(3000, function() return done end) - return result -end - -function M.is_file_staged(repo_dir, filename) - local status = M.get_git_status(repo_dir) - if not status or not status.staged then return false end - - for _, file in ipairs(status.staged) do - if file.path == filename or file.path:match(filename .. "$") then - return true - end - end - return false -end - -------------------------------------------------------------------------------- --- View API Helpers -------------------------------------------------------------------------------- - -function M.create_diff_view(config) - local view = require('codediff.ui.view') - return view.create(config) -end - -function M.update_diff_view(config) - local view = require('codediff.ui.view') - local tabpage = vim.api.nvim_get_current_tabpage() - view.update(tabpage, config, false) - return M.wait_for_diff_ready(5000) -end - -------------------------------------------------------------------------------- --- Assertion Helpers -------------------------------------------------------------------------------- - -function M.assert_contains(str, substr, msg) - local found = str and str:find(substr, 1, true) ~= nil - if not found then - print("ASSERT FAILED: " .. (msg or "Expected content not found")) - print(" Looking for: " .. substr) - print(" In: " .. (str and str:sub(1, 200) or "nil")) - end - return found -end - -function M.assert_equals(expected, actual, msg) - if expected ~= actual then - print("ASSERT FAILED: " .. (msg or "Values not equal")) - print(" Expected: " .. vim.inspect(expected)) - print(" Actual: " .. vim.inspect(actual)) - return false - end - return true -end - -function M.assert_true(value, msg) - if not value then - print("ASSERT FAILED: " .. (msg or "Expected true")) - return false - end - return true -end - -------------------------------------------------------------------------------- --- Setup and Cleanup -------------------------------------------------------------------------------- - -function M.setup_command() - local commands = require("codediff.commands") - vim.api.nvim_create_user_command("CodeDiff", function(opts) - commands.vscode_diff(opts) - end, { - nargs = "*", - bang = true, - complete = function() return { "file", "install" } end, - }) -end - -function M.cleanup_tabs() - vim.cmd("tabnew") - vim.cmd("tabonly") - vim.wait(200) -end - -------------------------------------------------------------------------------- --- Scenario Runner -------------------------------------------------------------------------------- - -function M.run(scenario_path) - print_separator("E2E Runner: " .. scenario_path) - - M.setup_command() - - local ok, scenario = pcall(dofile, scenario_path) - if not ok then - print("ERROR: Failed to load scenario: " .. tostring(scenario)) - return false - end - - if type(scenario) ~= "table" then - print("ERROR: Scenario must return a table with setup/run/validate functions") - return false - end - - local ctx = {} - local success = true - - -- Phase 1: Setup - if scenario.setup then - print_separator("Phase: Setup") - local setup_ok, setup_err = pcall(scenario.setup, ctx, M) - if not setup_ok then - print_result(false, "Setup failed: " .. tostring(setup_err)) - success = false - else - print_result(true, "Setup complete") - end - end - - -- Phase 2: Run - if success and scenario.run then - print_separator("Phase: Run") - local run_ok, run_err = pcall(scenario.run, ctx, M) - if not run_ok then - print_result(false, "Run failed: " .. tostring(run_err)) - success = false - else - print_result(true, "Run complete") - end - end - - -- Phase 3: Validate - if success and scenario.validate then - print_separator("Phase: Validate") - local validate_ok, validate_result = pcall(scenario.validate, ctx, M) - if not validate_ok then - print_result(false, "Validate error: " .. tostring(validate_result)) - success = false - elseif validate_result == false then - print_result(false, "Validation failed") - success = false - else - print_result(true, "Validation passed") - end - end - - -- Phase 4: Cleanup - if scenario.cleanup then - print_separator("Phase: Cleanup") - pcall(scenario.cleanup, ctx, M) - end - M.cleanup_tabs() - - print_separator("Result: " .. (success and "SUCCESS" or "FAILURE")) - return success -end - --- Auto-run if SCENARIO_FILE env var is set -local scenario_file = vim.env.SCENARIO_FILE -if scenario_file and scenario_file ~= "" then - local success = M.run(scenario_file) - if not success then - vim.cmd("cquit 1") - end -end - -return M diff --git a/tests/e2e/explorer_layout.lua b/tests/e2e/explorer_layout.lua deleted file mode 100644 index fe9049f0..00000000 --- a/tests/e2e/explorer_layout.lua +++ /dev/null @@ -1,57 +0,0 @@ --- E2E Scenario: Validate explorer window position and layout --- Tests that explorer appears at the LEFT edge (not between diff panes) -return { - setup = function(ctx, e2e) - ctx.repo = e2e.create_temp_git_repo() - ctx.repo.write_file("file1.txt", {"line 1", "line 2"}) - ctx.repo.write_file("file2.txt", {"hello"}) - ctx.repo.git("add .") - ctx.repo.git("commit -m 'initial'") - ctx.repo.write_file("file1.txt", {"line 1", "line 2 modified"}) - ctx.repo.write_file("file2.txt", {"hello world"}) - vim.cmd("edit " .. ctx.repo.path("file1.txt")) - end, - - run = function(ctx, e2e) - e2e.exec("CodeDiff") - e2e.wait_for_explorer(5000) - e2e.wait_for_diff_ready(5000) - - -- Collect window layout info - ctx.windows = e2e.get_all_windows() - ctx.explorer_win, ctx.explorer_buf = e2e.find_window_by_filetype("codediff-explorer") - end, - - validate = function(ctx, e2e) - local ok = true - - -- Must have explorer window - ok = ok and e2e.assert_true(ctx.explorer_win ~= nil, "Explorer window should exist") - if not ctx.explorer_win then return false end - - -- Must have 3 windows (explorer + 2 diff panes) - ok = ok and e2e.assert_true(#ctx.windows >= 3, "Should have at least 3 windows, got " .. #ctx.windows) - - -- Explorer must be the LEFTMOST window (col position = 0) - local explorer_col = vim.api.nvim_win_get_position(ctx.explorer_win)[2] - ok = ok and e2e.assert_equals(0, explorer_col, "Explorer should be at column 0 (leftmost), got " .. explorer_col) - - -- Explorer width should be reasonable (not full screen) - local explorer_width = vim.api.nvim_win_get_width(ctx.explorer_win) - ok = ok and e2e.assert_true(explorer_width <= 60, "Explorer should be reasonable width, got " .. explorer_width) - - -- Diff panes should be to the RIGHT of explorer - for _, win_info in ipairs(ctx.windows) do - if win_info.winid ~= ctx.explorer_win then - local col = vim.api.nvim_win_get_position(win_info.winid)[2] - ok = ok and e2e.assert_true(col > explorer_col, "Diff pane should be right of explorer") - end - end - - return ok - end, - - cleanup = function(ctx, e2e) - if ctx.repo then ctx.repo.cleanup() end - end -} diff --git a/tests/e2e/explorer_toggle.lua b/tests/e2e/explorer_toggle.lua deleted file mode 100644 index 801ae378..00000000 --- a/tests/e2e/explorer_toggle.lua +++ /dev/null @@ -1,71 +0,0 @@ --- E2E Scenario: Validate explorer hide/show (toggle visibility) -return { - setup = function(ctx, e2e) - ctx.repo = e2e.create_temp_git_repo() - ctx.repo.write_file("file.txt", {"original"}) - ctx.repo.git("add .") - ctx.repo.git("commit -m 'initial'") - ctx.repo.write_file("file.txt", {"modified"}) - vim.cmd("edit " .. ctx.repo.path("file.txt")) - end, - - run = function(ctx, e2e) - e2e.exec("CodeDiff") - e2e.wait_for_explorer(5000) - e2e.wait_for_diff_ready(5000) - - -- Record initial state - ctx.initial_explorer_win = e2e.find_window_by_filetype("codediff-explorer") - ctx.initial_win_count = #e2e.get_all_windows() - - -- Toggle explorer off via actions API - local actions = require("codediff.ui.explorer.actions") - local lifecycle = require("codediff.ui.lifecycle") - local tabpage = vim.api.nvim_get_current_tabpage() - local session = lifecycle.get_session(tabpage) - local explorer_obj = session and session.explorer - if explorer_obj then - actions.toggle_visibility(explorer_obj) - end - vim.wait(500) - ctx.hidden_explorer_win = e2e.find_window_by_filetype("codediff-explorer") - ctx.hidden_win_count = #e2e.get_all_windows() - - -- Toggle explorer back on - if explorer_obj then - actions.toggle_visibility(explorer_obj) - end - vim.wait(500) - ctx.restored_explorer_win = e2e.find_window_by_filetype("codediff-explorer") - ctx.restored_win_count = #e2e.get_all_windows() - - -- Check it's still on the left after restore - if ctx.restored_explorer_win then - ctx.restored_col = vim.api.nvim_win_get_position(ctx.restored_explorer_win)[2] - end - end, - - validate = function(ctx, e2e) - local ok = true - - -- Initially should have explorer - ok = ok and e2e.assert_true(ctx.initial_explorer_win ~= nil, "Should have explorer initially") - - -- After hide, explorer window should be gone - ok = ok and e2e.assert_true(ctx.hidden_explorer_win == nil, "Explorer should be hidden after toggle") - ok = ok and e2e.assert_true(ctx.hidden_win_count < ctx.initial_win_count, "Window count should decrease after hide") - - -- After show, explorer should be back - ok = ok and e2e.assert_true(ctx.restored_explorer_win ~= nil, "Explorer should be restored after second toggle") - ok = ok and e2e.assert_equals(ctx.initial_win_count, ctx.restored_win_count, "Window count should match original") - - -- Restored explorer should be at the left edge - ok = ok and e2e.assert_equals(0, ctx.restored_col, "Restored explorer should be at column 0 (leftmost)") - - return ok - end, - - cleanup = function(ctx, e2e) - if ctx.repo then ctx.repo.cleanup() end - end -} diff --git a/tests/e2e/explorer_tree.lua b/tests/e2e/explorer_tree.lua deleted file mode 100644 index 2a172e26..00000000 --- a/tests/e2e/explorer_tree.lua +++ /dev/null @@ -1,58 +0,0 @@ --- E2E Scenario: Validate explorer tree expand/collapse and file selection -return { - setup = function(ctx, e2e) - ctx.repo = e2e.create_temp_git_repo() - ctx.repo.write_file("src/a.txt", {"aaa"}) - ctx.repo.write_file("src/b.txt", {"bbb"}) - ctx.repo.write_file("c.txt", {"ccc"}) - ctx.repo.git("add .") - ctx.repo.git("commit -m 'initial'") - ctx.repo.write_file("src/a.txt", {"aaa modified"}) - ctx.repo.write_file("src/b.txt", {"bbb modified"}) - ctx.repo.write_file("c.txt", {"ccc modified"}) - vim.cmd("edit " .. ctx.repo.path("c.txt")) - end, - - run = function(ctx, e2e) - e2e.exec("CodeDiff") - e2e.wait_for_explorer(5000) - e2e.wait_for_diff_ready(5000) - - -- Get explorer content - local _, explorer_buf = e2e.find_window_by_filetype("codediff-explorer") - ctx.explorer_lines = e2e.get_buffer_lines(explorer_buf) - ctx.explorer_content = e2e.get_buffer_content(explorer_buf) - - -- Try selecting a different file via next_file - e2e.next_file() - vim.wait(500) - - -- Get diff content after navigation - ctx.modified_content = e2e.get_modified_content() - end, - - validate = function(ctx, e2e) - local ok = true - - -- Explorer should have content (tree rendered) - ok = ok and e2e.assert_true(#ctx.explorer_lines > 0, "Explorer should have lines") - - -- Explorer should show file names - ok = ok and e2e.assert_true( - ctx.explorer_content:find("a.txt") or ctx.explorer_content:find("b.txt") or ctx.explorer_content:find("c.txt"), - "Explorer should show file names" - ) - - -- Should have a group header - ok = ok and e2e.assert_contains(ctx.explorer_content, "Changes", "Explorer should have Changes group") - - -- After next_file, modified content should exist - ok = ok and e2e.assert_true(ctx.modified_content ~= nil and #ctx.modified_content > 0, "Should have modified content after navigation") - - return ok - end, - - cleanup = function(ctx, e2e) - if ctx.repo then ctx.repo.cleanup() end - end -} diff --git a/tests/e2e/history_layout.lua b/tests/e2e/history_layout.lua deleted file mode 100644 index ff823470..00000000 --- a/tests/e2e/history_layout.lua +++ /dev/null @@ -1,66 +0,0 @@ --- E2E Scenario: Validate history panel layout and content -return { - setup = function(ctx, e2e) - ctx.repo = e2e.create_temp_git_repo() - ctx.repo.write_file("file.txt", {"version 1"}) - ctx.repo.git("add .") - ctx.repo.git("commit -m 'first commit'") - ctx.repo.write_file("file.txt", {"version 2"}) - ctx.repo.git("add .") - ctx.repo.git("commit -m 'second commit'") - ctx.repo.write_file("file.txt", {"version 3"}) - ctx.repo.git("add .") - ctx.repo.git("commit -m 'third commit'") - vim.cmd("edit " .. ctx.repo.path("file.txt")) - end, - - run = function(ctx, e2e) - e2e.exec("CodeDiff history") - vim.wait(5000, function() - return e2e.find_window_by_filetype("codediff-history") ~= nil - end) - - -- Find history panel - ctx.history_win, ctx.history_buf = e2e.find_window_by_filetype("codediff-history") - if ctx.history_buf then - ctx.history_content = e2e.get_buffer_content(ctx.history_buf) - ctx.history_lines = e2e.get_buffer_lines(ctx.history_buf) - end - - -- Check layout - history should be at bottom - if ctx.history_win then - local win_pos = vim.api.nvim_win_get_position(ctx.history_win) - ctx.history_row = win_pos[1] - ctx.history_col = win_pos[2] - end - - ctx.all_windows = e2e.get_all_windows() - end, - - validate = function(ctx, e2e) - local ok = true - - -- History panel should exist - ok = ok and e2e.assert_true(ctx.history_win ~= nil, "History window should exist") - if not ctx.history_win then return false end - - -- History should have content with commit messages - ok = ok and e2e.assert_true(#ctx.history_lines > 0, "History should have lines") - ok = ok and e2e.assert_contains(ctx.history_content, "Commit History", "Should show Commit History title") - - -- History should be at the bottom (higher row than diff panes) - for _, win_info in ipairs(ctx.all_windows) do - if win_info.winid ~= ctx.history_win then - local other_row = vim.api.nvim_win_get_position(win_info.winid)[1] - ok = ok and e2e.assert_true(ctx.history_row >= other_row, - "History should be at bottom (row " .. ctx.history_row .. " vs other " .. other_row .. ")") - end - end - - return ok - end, - - cleanup = function(ctx, e2e) - if ctx.repo then ctx.repo.cleanup() end - end -} diff --git a/tests/e2e/tab_cycle_untracked.lua b/tests/e2e/tab_cycle_untracked.lua deleted file mode 100644 index 660a7e61..00000000 --- a/tests/e2e/tab_cycle_untracked.lua +++ /dev/null @@ -1,133 +0,0 @@ --- E2E Scenario: Tab cycling with untracked file should not crash (PR #309) --- --- Root cause: show_untracked_file() stored {} as stored_diff_result instead of --- {changes={}, moves={}}. When resume_diff() reuses that value (no recompute --- needed), render_diff() crashes on ipairs(nil) because {}.changes is nil. --- --- This test validates the invariant directly: after selecting an untracked file, --- stored_diff_result.changes must be a table (not nil). It then performs a full --- tab cycle to exercise the resume_diff path end-to-end. -return { - setup = function(ctx, e2e) - ctx.repo = e2e.create_temp_git_repo() - ctx.repo.write_file("tracked.txt", { "hello world" }) - ctx.repo.git("add . && git commit -m 'initial'") - ctx.repo.write_file("untracked.txt", { "I am untracked" }) - vim.cmd("edit " .. ctx.repo.path("tracked.txt")) - end, - - run = function(ctx, e2e) - e2e.exec("CodeDiff") - e2e.wait_for_explorer(5000) - e2e.wait_for_diff_ready(5000) - - -- Find the untracked file in the explorer tree - local function find_untracked() - local lines = e2e.get_explorer_files() - ctx.explorer_lines = lines - if not lines then return nil end - for i, line in ipairs(lines) do - if line:find("untracked.txt") then return i end - end - return nil - end - - local untracked_line = find_untracked() - - -- If section is collapsed, expand it first - if not untracked_line and ctx.explorer_lines then - for i, line in ipairs(ctx.explorer_lines) do - if line:find("ntracked") then - e2e.select_explorer_item(i) - vim.wait(500) - break - end - end - untracked_line = find_untracked() - end - - ctx.found_untracked = untracked_line ~= nil - if not untracked_line then - ctx.error = "Could not find untracked.txt in explorer" - return - end - - -- Select the untracked file → triggers show_untracked_file → single-pane view - e2e.select_explorer_item(untracked_line) - vim.wait(1000) - - -- KEY CHECK: Capture stored_diff_result IMMEDIATELY after show_untracked_file. - -- Before the fix this was {}, meaning .changes and .moves were nil. - -- After the fix this is {changes={}, moves={}}. - local session = e2e.get_diff_session() - if session and session.stored_diff_result then - ctx.immediate_has_changes = session.stored_diff_result.changes ~= nil - ctx.immediate_has_moves = session.stored_diff_result.moves ~= nil - ctx.immediate_changes_type = type(session.stored_diff_result.changes) - else - ctx.immediate_has_changes = false - ctx.immediate_has_moves = false - end - - -- Now exercise the full tab-cycle path (suspend → resume → render) - ctx.codediff_tabnr = vim.fn.tabpagenr() - ctx.codediff_tabpage = vim.api.nvim_get_current_tabpage() - - vim.cmd("tabnew") - vim.wait(500) - - -- Cycle back via tabnext (triggers TabEnter → vim.schedule → resume_diff) - ctx.cycle_ok, ctx.cycle_err = pcall(function() - vim.cmd("tabnext " .. ctx.codediff_tabnr) - end) - - -- Let TabEnter → vim.schedule → resume_diff complete - local lifecycle = require("codediff.ui.lifecycle") - vim.wait(3000, function() - local s = lifecycle.get_session(ctx.codediff_tabpage) - return s and not s.suspended - end, 50) - - -- Capture state after the full cycle - local after = lifecycle.get_session(ctx.codediff_tabpage) - ctx.session_alive = after ~= nil - if after then - ctx.after_suspended = after.suspended - ctx.after_mod_win_valid = after.modified_win and vim.api.nvim_win_is_valid(after.modified_win) - end - end, - - validate = function(ctx, e2e) - local ok = true - - ok = ok and e2e.assert_true(ctx.found_untracked, - "Should find untracked.txt in explorer (lines: " .. vim.inspect(ctx.explorer_lines) .. ")") - if not ctx.found_untracked then return false end - - ok = ok and e2e.assert_true(ctx.error == nil, "No error: " .. tostring(ctx.error)) - - -- Core invariant: stored_diff_result must have .changes right after show_untracked_file - ok = ok and e2e.assert_true(ctx.immediate_has_changes, - "stored_diff_result.changes must not be nil immediately after show_untracked_file (was: " - .. tostring(ctx.immediate_changes_type) .. ")") - - ok = ok and e2e.assert_true(ctx.immediate_has_moves, - "stored_diff_result.moves must not be nil immediately after show_untracked_file") - - -- Tab cycle should not crash - ok = ok and e2e.assert_true(ctx.cycle_ok ~= false, - "Tab cycle should not error: " .. tostring(ctx.cycle_err)) - - -- Session survives the cycle - ok = ok and e2e.assert_true(ctx.session_alive, "Session should exist after tab cycle") - ok = ok and e2e.assert_true(ctx.after_suspended == false, "Session should resume after tab cycle") - ok = ok and e2e.assert_true(ctx.after_mod_win_valid, "Modified window should be valid after tab cycle") - - return ok - end, - - cleanup = function(ctx, e2e) - pcall(function() e2e.cleanup_tabs() end) - if ctx.repo then ctx.repo.cleanup() end - end, -} diff --git a/tests/helpers.lua b/tests/helpers.lua index 02b76762..e1126467 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -247,6 +247,42 @@ function M.close_extra_tabs() end end +-- Find the first window in the current tab whose buffer has the given +-- filetype. Returns (winid, bufnr) or (nil, nil). +function M.find_window_by_filetype(filetype) + for _, winid in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + local bufnr = vim.api.nvim_win_get_buf(winid) + if vim.bo[bufnr].filetype == filetype then + return winid, bufnr + end + end + return nil, nil +end + +-- Wait for a codediff explorer window to appear in the current tab. +-- Returns true if it appeared before the timeout. +function M.wait_for_explorer(timeout_ms) + return vim.wait(timeout_ms or 5000, function() + return M.find_window_by_filetype("codediff-explorer") ~= nil + end, 50) +end + +-- Wait until the current tab has a codediff session with valid buffers. +-- Distinct from wait_for_session_ready (which polls stored_diff_result); this +-- is the minimal readiness check the pre-conversion E2E scenarios used. +function M.wait_for_diff_ready(timeout_ms) + local lifecycle = require("codediff.ui.lifecycle") + local tabpage = vim.api.nvim_get_current_tabpage() + return vim.wait(timeout_ms or 10000, function() + local session = lifecycle.get_session(tabpage) + if not session or not session.stored_diff_result then + return false + end + local orig, mod = lifecycle.get_buffers(tabpage) + return orig and mod and vim.api.nvim_buf_is_valid(orig) and vim.api.nvim_buf_is_valid(mod) + end, 100) +end + -- Assert that a string contains a substring function M.assert_contains(str, substr, msg) local found = str and str:find(substr, 1, true) ~= nil diff --git a/tests/run_e2e.cmd b/tests/run_e2e.cmd deleted file mode 100644 index f774ecff..00000000 --- a/tests/run_e2e.cmd +++ /dev/null @@ -1,49 +0,0 @@ -@echo off -REM E2E scenario runner for codediff.nvim (Windows). -REM -REM Each tests/e2e/*.lua scenario is a table with { setup, run, validate, -REM cleanup } phases, driven by scripts/nvim-e2e.lua. This wrapper runs every -REM scenario in its own Neovim process (matching the *_spec framework's -REM isolation) and returns non-zero if any scenario fails. Kept in sync with -REM tests/run_e2e.sh. - -setlocal enabledelayedexpansion -pushd "%~dp0.." - -set /a TOTAL=0 -set /a PASSED=0 -set /a FAILED=0 -set FAILED_NAMES= - -for %%F in (tests\e2e\*.lua) do ( - set /a TOTAL+=1 - set NAME=%%~nF - "%TEMP%\e2e_!NAME!.log" 2>&1 - if !ERRORLEVEL! EQU 0 ( - echo PASS - set /a PASSED+=1 - ) else ( - echo FAIL - set /a FAILED+=1 - set FAILED_NAMES=!FAILED_NAMES! !NAME! - echo --- %%F output --- - type "%TEMP%\e2e_!NAME!.log" - echo --- end %%F output --- - ) -) - -echo. -echo E2E: !PASSED! passed, !FAILED! failed of !TOTAL! scenarios -if !FAILED! GTR 0 ( - echo Failed scenarios:!FAILED_NAMES! - set EXIT_CODE=1 -) else ( - set EXIT_CODE=0 -) - -popd -exit /b %EXIT_CODE% diff --git a/tests/run_e2e.sh b/tests/run_e2e.sh deleted file mode 100755 index ad27c68c..00000000 --- a/tests/run_e2e.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash -# E2E scenario runner for codediff.nvim. -# -# Each `tests/e2e/*.lua` scenario is a table with { setup, run, validate, -# cleanup } phases, driven by `scripts/nvim-e2e.lua`. This wrapper runs every -# scenario in its own Neovim process (matches the isolation the *_spec -# framework already gets) and returns non-zero if any scenario fails. -# -# CI parses only the final line of stdout for the summary; individual -# scenario output goes to stderr for the human reader / build log. - -set -u - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - -cd "$PROJECT_ROOT" - -scenarios=(tests/e2e/*.lua) -if [ ${#scenarios[@]} -eq 0 ]; then - echo "No E2E scenarios found under tests/e2e/" - exit 0 -fi - -total=${#scenarios[@]} -passed=0 -failed=0 -failed_names=() - -for scenario in "${scenarios[@]}"; do - name="$(basename "$scenario" .lua)" - printf '[e2e] %-40s ' "$name" >&2 - - # Run each scenario in isolation. `--noplugin -u tests/init.lua` matches - # the *_spec bootstrap so scenarios see the same runtime environment. - # SCENARIO_FILE triggers the auto-run branch at the bottom of nvim-e2e.lua - # which cquit(1)s on failure, giving us a reliable exit code. - if SCENARIO_FILE="$scenario" \ - nvim --headless --noplugin -u tests/init.lua \ - -c "luafile scripts/nvim-e2e.lua" \ - -c "qa!" >/tmp/e2e_${name}.log 2>&1; then - echo "PASS" >&2 - passed=$((passed + 1)) - else - echo "FAIL" >&2 - failed=$((failed + 1)) - failed_names+=("$name") - # Surface the failing scenario's output so the CI log has the diagnostic. - echo "--- $scenario output ---" >&2 - cat "/tmp/e2e_${name}.log" >&2 - echo "--- end $scenario output ---" >&2 - fi -done - -echo "" >&2 -echo "E2E: $passed passed, $failed failed of $total scenarios" -if [ $failed -gt 0 ]; then - echo "Failed scenarios: ${failed_names[*]}" >&2 - exit 1 -fi -exit 0 diff --git a/tests/ui/explorer/explorer_layout_e2e_spec.lua b/tests/ui/explorer/explorer_layout_e2e_spec.lua new file mode 100644 index 00000000..32bb15ef --- /dev/null +++ b/tests/ui/explorer/explorer_layout_e2e_spec.lua @@ -0,0 +1,62 @@ +-- E2E: explorer window position and layout +-- Converted from tests/e2e/explorer_layout.lua. +-- +-- Verifies that :CodeDiff opens the explorer at the LEFTMOST column (not +-- between the diff panes) and that the diff panes appear to its right. + +local h = dofile("tests/helpers.lua") +h.ensure_plugin_loaded() + +describe("Explorer layout (E2E)", function() + local repo + + before_each(function() + require("codediff").setup({}) + repo = h.create_temp_git_repo() + repo.write_file("file1.txt", { "line 1", "line 2" }) + repo.write_file("file2.txt", { "hello" }) + repo.git("add .") + repo.git("commit -m initial") + repo.write_file("file1.txt", { "line 1", "line 2 modified" }) + repo.write_file("file2.txt", { "hello world" }) + vim.cmd("edit " .. repo.path("file1.txt")) + end) + + after_each(function() + h.close_extra_tabs() + if repo then + repo.cleanup() + end + end) + + it("puts the explorer at column 0 with diff panes to its right", function() + vim.cmd("CodeDiff") + assert.is_true(h.wait_for_explorer(5000), "explorer window should appear") + assert.is_true(h.wait_for_diff_ready(5000), "diff session should register") + + local explorer_win = h.find_window_by_filetype("codediff-explorer") + assert.is_not_nil(explorer_win, "explorer window not found") + + local wins = vim.api.nvim_tabpage_list_wins(0) + assert.is_true(#wins >= 3, "expected at least 3 windows (explorer + 2 diff panes), got " .. #wins) + + -- Explorer must be leftmost. + local explorer_col = vim.api.nvim_win_get_position(explorer_win)[2] + assert.equal(0, explorer_col, + "explorer should be at column 0 (leftmost), got " .. explorer_col) + + -- Reasonable width — not filling the whole screen. + local explorer_width = vim.api.nvim_win_get_width(explorer_win) + assert.is_true(explorer_width <= 60, + "explorer should be a reasonable width, got " .. explorer_width) + + -- Every other window is to the right of the explorer. + for _, w in ipairs(wins) do + if w ~= explorer_win then + local col = vim.api.nvim_win_get_position(w)[2] + assert.is_true(col > explorer_col, + "diff pane at column " .. col .. " should be right of explorer at column " .. explorer_col) + end + end + end) +end) diff --git a/tests/ui/explorer/explorer_toggle_e2e_spec.lua b/tests/ui/explorer/explorer_toggle_e2e_spec.lua new file mode 100644 index 00000000..6416bf97 --- /dev/null +++ b/tests/ui/explorer/explorer_toggle_e2e_spec.lua @@ -0,0 +1,61 @@ +-- E2E: explorer hide/show (toggle visibility) +-- Converted from tests/e2e/explorer_toggle.lua. + +local h = dofile("tests/helpers.lua") +h.ensure_plugin_loaded() + +describe("Explorer toggle visibility (E2E)", function() + local repo + + before_each(function() + require("codediff").setup({}) + repo = h.create_temp_git_repo() + repo.write_file("file.txt", { "original" }) + repo.git("add .") + repo.git("commit -m initial") + repo.write_file("file.txt", { "modified" }) + vim.cmd("edit " .. repo.path("file.txt")) + end) + + after_each(function() + h.close_extra_tabs() + if repo then + repo.cleanup() + end + end) + + it("hides then restores the explorer at the left edge", function() + vim.cmd("CodeDiff") + assert.is_true(h.wait_for_explorer(5000)) + assert.is_true(h.wait_for_diff_ready(5000)) + + local actions = require("codediff.ui.explorer.actions") + local lifecycle = require("codediff.ui.lifecycle") + local tabpage = vim.api.nvim_get_current_tabpage() + local explorer_obj = lifecycle.get_session(tabpage).explorer + assert.is_not_nil(explorer_obj, "explorer object should be attached to the session") + + -- Snapshot: initial state. + local initial_explorer_win = h.find_window_by_filetype("codediff-explorer") + local initial_win_count = #vim.api.nvim_tabpage_list_wins(tabpage) + assert.is_not_nil(initial_explorer_win, "explorer should exist initially") + + -- Hide → the explorer window disappears and the tab has fewer windows. + actions.toggle_visibility(explorer_obj) + vim.wait(500) + assert.is_nil(h.find_window_by_filetype("codediff-explorer"), + "explorer window should be gone after hiding") + assert.is_true(#vim.api.nvim_tabpage_list_wins(tabpage) < initial_win_count, + "window count should decrease after hide") + + -- Restore → the explorer reappears with the same window count and column 0. + actions.toggle_visibility(explorer_obj) + vim.wait(500) + local restored_win = h.find_window_by_filetype("codediff-explorer") + assert.is_not_nil(restored_win, "explorer should be restored after second toggle") + assert.equal(initial_win_count, #vim.api.nvim_tabpage_list_wins(tabpage), + "window count should match the original after restore") + assert.equal(0, vim.api.nvim_win_get_position(restored_win)[2], + "restored explorer should still be at column 0 (leftmost)") + end) +end) diff --git a/tests/ui/explorer/explorer_tree_e2e_spec.lua b/tests/ui/explorer/explorer_tree_e2e_spec.lua new file mode 100644 index 00000000..21d4f689 --- /dev/null +++ b/tests/ui/explorer/explorer_tree_e2e_spec.lua @@ -0,0 +1,64 @@ +-- E2E: explorer tree content and file navigation +-- Converted from tests/e2e/explorer_tree.lua. + +local h = dofile("tests/helpers.lua") +h.ensure_plugin_loaded() + +describe("Explorer tree (E2E)", function() + local repo + + before_each(function() + require("codediff").setup({}) + repo = h.create_temp_git_repo() + repo.write_file("src/a.txt", { "aaa" }) + repo.write_file("src/b.txt", { "bbb" }) + repo.write_file("c.txt", { "ccc" }) + repo.git("add .") + repo.git("commit -m initial") + repo.write_file("src/a.txt", { "aaa modified" }) + repo.write_file("src/b.txt", { "bbb modified" }) + repo.write_file("c.txt", { "ccc modified" }) + vim.cmd("edit " .. repo.path("c.txt")) + end) + + after_each(function() + h.close_extra_tabs() + if repo then + repo.cleanup() + end + end) + + it("renders the changed files and reacts to ]f navigation", function() + vim.cmd("CodeDiff") + assert.is_true(h.wait_for_explorer(5000)) + assert.is_true(h.wait_for_diff_ready(5000)) + + local _, explorer_buf = h.find_window_by_filetype("codediff-explorer") + local lines = h.get_buffer_lines(explorer_buf) + assert.is_true(#lines > 0, "explorer buffer should have content") + + local content = h.get_buffer_content(explorer_buf) + -- At least one of the changed files must be listed. + local has_a = content:find("a.txt", 1, true) ~= nil + local has_b = content:find("b.txt", 1, true) ~= nil + local has_c = content:find("c.txt", 1, true) ~= nil + assert.is_true(has_a or has_b or has_c, + "explorer should list at least one changed file (a/b/c.txt), got:\n" .. content) + + -- The unstaged group header must be present. + h.assert_contains(content, "Changes", + "explorer should show the 'Changes' group header for unstaged files") + + -- ]f navigates to the next file — after firing it the modified pane must + -- still hold visible content (empty implies a broken navigation path). + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("]f", true, false, true), "nx", false) + vim.wait(500) + + local lifecycle = require("codediff.ui.lifecycle") + local _, mod_buf = lifecycle.get_buffers(vim.api.nvim_get_current_tabpage()) + assert.is_not_nil(mod_buf, "modified buffer should still exist after ]f") + local mod_content = h.get_buffer_content(mod_buf) + assert.is_true(mod_content ~= nil and #mod_content > 0, + "modified pane should have content after ]f navigation") + end) +end) diff --git a/tests/ui/explorer/tab_cycle_untracked_e2e_spec.lua b/tests/ui/explorer/tab_cycle_untracked_e2e_spec.lua new file mode 100644 index 00000000..3d6cc8de --- /dev/null +++ b/tests/ui/explorer/tab_cycle_untracked_e2e_spec.lua @@ -0,0 +1,146 @@ +-- E2E: tab cycling with an untracked file must not crash (PR #309) +-- Converted from tests/e2e/tab_cycle_untracked.lua. +-- +-- Root cause the scenario was guarding: show_untracked_file() used to store +-- `{}` as stored_diff_result instead of `{changes={}, moves={}}`. When +-- resume_diff() later reused that value (no recompute needed on tab reentry), +-- render_diff() crashed on ipairs(nil) because {}.changes is nil. +-- +-- The spec first asserts the invariant directly (stored_diff_result.changes +-- must be a table right after selecting the untracked file), then exercises +-- the full suspend → resume path via a tabnew + tabnext to catch a re-break. + +local h = dofile("tests/helpers.lua") +h.ensure_plugin_loaded() + +describe("Tab cycle with an untracked file (E2E, PR #309)", function() + local repo + local lifecycle = require("codediff.ui.lifecycle") + + before_each(function() + require("codediff").setup({}) + repo = h.create_temp_git_repo() + repo.write_file("tracked.txt", { "hello world" }) + repo.git("add .") + repo.git("commit -m initial") + repo.write_file("untracked.txt", { "I am untracked" }) + vim.cmd("edit " .. repo.path("tracked.txt")) + end) + + after_each(function() + h.close_extra_tabs() + if repo then + repo.cleanup() + end + end) + + -- Locate the tree line for `untracked.txt` inside the explorer buffer, + -- expanding a collapsed section if necessary. Returns the line number or nil. + local function find_untracked_line(explorer_buf) + local function search() + local lines = h.get_buffer_lines(explorer_buf) + for i, line in ipairs(lines) do + if line:find("untracked.txt", 1, true) then + return i, lines + end + end + return nil, lines + end + + local line, lines = search() + if line then + return line + end + -- Section may be collapsed; expand any node whose text contains "ntracked". + for i, l in ipairs(lines) do + if l:find("ntracked") then + vim.api.nvim_win_set_cursor(0, { i, 0 }) + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes("", true, false, true), "nx", false) + vim.wait(500) + break + end + end + return (search()) + end + + it("selecting an untracked file initializes stored_diff_result.changes/moves", function() + vim.cmd("CodeDiff") + assert.is_true(h.wait_for_explorer(5000)) + assert.is_true(h.wait_for_diff_ready(5000)) + + -- Focus the explorer to run its keymaps. + local explorer_win, explorer_buf = h.find_window_by_filetype("codediff-explorer") + assert.is_not_nil(explorer_win) + vim.api.nvim_set_current_win(explorer_win) + + local target_line = find_untracked_line(explorer_buf) + assert.is_not_nil(target_line, + "untracked.txt should be findable in the explorer tree; buffer:\n" + .. h.get_buffer_content(explorer_buf)) + + -- Select it → triggers show_untracked_file → single-pane view. + vim.api.nvim_win_set_cursor(explorer_win, { target_line, 0 }) + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes("", true, false, true), "nx", false) + vim.wait(1000) + + -- Core invariant: stored_diff_result must have .changes and .moves as + -- tables right after show_untracked_file. Before the PR #309 fix these + -- were nil and the subsequent resume_diff crashed. + local tabpage = vim.api.nvim_get_current_tabpage() + local session = lifecycle.get_session(tabpage) + assert.is_not_nil(session, "session should exist after selecting a file") + assert.is_not_nil(session.stored_diff_result, + "stored_diff_result should be set after show_untracked_file") + assert.equal("table", type(session.stored_diff_result.changes), + "stored_diff_result.changes must be a table (was: " + .. type(session.stored_diff_result.changes) .. ")") + assert.equal("table", type(session.stored_diff_result.moves), + "stored_diff_result.moves must be a table (was: " + .. type(session.stored_diff_result.moves) .. ")") + end) + + it("tabnew then tabnext to a codediff tab with an untracked file selected does not crash", function() + -- Same setup as above — get to a state where untracked.txt is being viewed. + vim.cmd("CodeDiff") + assert.is_true(h.wait_for_explorer(5000)) + assert.is_true(h.wait_for_diff_ready(5000)) + + local explorer_win, explorer_buf = h.find_window_by_filetype("codediff-explorer") + vim.api.nvim_set_current_win(explorer_win) + local target_line = find_untracked_line(explorer_buf) + assert.is_not_nil(target_line) + + vim.api.nvim_win_set_cursor(explorer_win, { target_line, 0 }) + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes("", true, false, true), "nx", false) + vim.wait(1000) + + -- Now trigger the suspend → resume cycle: open a fresh tab, then jump + -- back. TabEnter runs resume_diff via vim.schedule, which used to crash + -- because stored_diff_result was `{}`. + local codediff_tabpage = vim.api.nvim_get_current_tabpage() + local codediff_tabnr = vim.fn.tabpagenr() + vim.cmd("tabnew") + vim.wait(500) + + local cycled_ok, cycle_err = pcall(function() + vim.cmd("tabnext " .. codediff_tabnr) + end) + assert.is_true(cycled_ok, "tabnext back to the codediff tab must not error: " .. tostring(cycle_err)) + + -- Let the scheduled resume_diff land. + vim.wait(3000, function() + local s = lifecycle.get_session(codediff_tabpage) + return s and not s.suspended + end, 50) + + local after = lifecycle.get_session(codediff_tabpage) + assert.is_not_nil(after, "session should survive the tab cycle") + assert.is_false(after.suspended, "session should be resumed after tab cycle") + assert.is_true( + after.modified_win and vim.api.nvim_win_is_valid(after.modified_win), + "modified window should be valid after tab cycle") + end) +end) diff --git a/tests/ui/history/history_layout_e2e_spec.lua b/tests/ui/history/history_layout_e2e_spec.lua new file mode 100644 index 00000000..c8773443 --- /dev/null +++ b/tests/ui/history/history_layout_e2e_spec.lua @@ -0,0 +1,61 @@ +-- E2E: history panel layout and content +-- Converted from tests/e2e/history_layout.lua. + +local h = dofile("tests/helpers.lua") +h.ensure_plugin_loaded() + +describe("History layout (E2E)", function() + local repo + + before_each(function() + require("codediff").setup({}) + repo = h.create_temp_git_repo() + repo.write_file("file.txt", { "version 1" }) + repo.git("add .") + repo.git("commit -m first") + repo.write_file("file.txt", { "version 2" }) + repo.git("add .") + repo.git("commit -m second") + repo.write_file("file.txt", { "version 3" }) + repo.git("add .") + repo.git("commit -m third") + vim.cmd("edit " .. repo.path("file.txt")) + end) + + after_each(function() + h.close_extra_tabs() + if repo then + repo.cleanup() + end + end) + + it("opens a history panel at the bottom with commit content", function() + vim.cmd("CodeDiff history") + + -- Wait for the history panel to appear. + local appeared = vim.wait(5000, function() + return h.find_window_by_filetype("codediff-history") ~= nil + end, 50) + assert.is_true(appeared, "history panel should appear within 5s") + + local history_win, history_buf = h.find_window_by_filetype("codediff-history") + assert.is_not_nil(history_win) + assert.is_not_nil(history_buf) + + local content = h.get_buffer_content(history_buf) + local lines = h.get_buffer_lines(history_buf) + assert.is_true(#lines > 0, "history buffer should have lines") + h.assert_contains(content, "Commit History", + "history panel should show 'Commit History' title") + + -- History is at the bottom: its row position is >= every other window's. + local history_row = vim.api.nvim_win_get_position(history_win)[1] + for _, other_win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + if other_win ~= history_win then + local other_row = vim.api.nvim_win_get_position(other_win)[1] + assert.is_true(history_row >= other_row, + "history should be at bottom (row " .. history_row .. " vs other " .. other_row .. ")") + end + end + end) +end) From 11d90b0ff436a2185aec75b4e5191630aae663bf Mon Sep 17 00:00:00 2001 From: Yanuo Ma Date: Sun, 2 Aug 2026 01:25:10 -0400 Subject: [PATCH 6/9] test: restructure spec tree to mirror lua/ layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- tests/{ui => }/keymap/golden_matrix_spec.lua | 0 .../keymap/issue_regressions_spec.lua | 0 .../{ui => }/keymap/keymap_coverage_spec.lua | 0 tests/{ui => }/keymap/keymap_help_spec.lua | 0 .../{ui => }/keymap/keymap_lifecycle_spec.lua | 0 tests/{ui => }/keymap/registry_spec.lua | 0 tests/module_loading_spec.lua | 366 ++++++++++++++++++ tests/{ui => }/scrollsync_spec.lua | 0 tests/ui/conflict/conflict_modules_spec.lua | 79 ---- tests/ui/explorer/explorer_modules_spec.lua | 105 ----- tests/ui/explorer/explorer_refresh_spec.lua | 216 +++++++++++ tests/ui/explorer/explorer_spec.lua | 196 ---------- ...spec.lua => explorer_tree_render_spec.lua} | 5 +- ..._spec.lua => explorer_visibility_spec.lua} | 5 +- ...ec.lua => explorer_window_layout_spec.lua} | 6 +- tests/{ => ui/explorer}/flatten_dirs_spec.lua | 0 ...racked_e2e_spec.lua => issue_309_spec.lua} | 14 +- ...t_e2e_spec.lua => history_layout_spec.lua} | 5 +- tests/ui/history/history_modules_spec.lua | 45 --- tests/ui/lifecycle/lifecycle_modules_spec.lua | 127 ------ .../{core_spec.lua => render_core_spec.lua} | 0 21 files changed, 597 insertions(+), 572 deletions(-) rename tests/{ui => }/keymap/golden_matrix_spec.lua (100%) rename tests/{ui => }/keymap/issue_regressions_spec.lua (100%) rename tests/{ui => }/keymap/keymap_coverage_spec.lua (100%) rename tests/{ui => }/keymap/keymap_help_spec.lua (100%) rename tests/{ui => }/keymap/keymap_lifecycle_spec.lua (100%) rename tests/{ui => }/keymap/registry_spec.lua (100%) create mode 100644 tests/module_loading_spec.lua rename tests/{ui => }/scrollsync_spec.lua (100%) delete mode 100644 tests/ui/conflict/conflict_modules_spec.lua delete mode 100644 tests/ui/explorer/explorer_modules_spec.lua create mode 100644 tests/ui/explorer/explorer_refresh_spec.lua rename tests/ui/explorer/{explorer_tree_e2e_spec.lua => explorer_tree_render_spec.lua} (94%) rename tests/ui/explorer/{explorer_toggle_e2e_spec.lua => explorer_visibility_spec.lua} (93%) rename tests/ui/explorer/{explorer_layout_e2e_spec.lua => explorer_window_layout_spec.lua} (93%) rename tests/{ => ui/explorer}/flatten_dirs_spec.lua (100%) rename tests/ui/explorer/{tab_cycle_untracked_e2e_spec.lua => issue_309_spec.lua} (91%) rename tests/ui/history/{history_layout_e2e_spec.lua => history_layout_spec.lua} (93%) delete mode 100644 tests/ui/history/history_modules_spec.lua delete mode 100644 tests/ui/lifecycle/lifecycle_modules_spec.lua rename tests/ui/{core_spec.lua => render_core_spec.lua} (100%) diff --git a/tests/ui/keymap/golden_matrix_spec.lua b/tests/keymap/golden_matrix_spec.lua similarity index 100% rename from tests/ui/keymap/golden_matrix_spec.lua rename to tests/keymap/golden_matrix_spec.lua diff --git a/tests/ui/keymap/issue_regressions_spec.lua b/tests/keymap/issue_regressions_spec.lua similarity index 100% rename from tests/ui/keymap/issue_regressions_spec.lua rename to tests/keymap/issue_regressions_spec.lua diff --git a/tests/ui/keymap/keymap_coverage_spec.lua b/tests/keymap/keymap_coverage_spec.lua similarity index 100% rename from tests/ui/keymap/keymap_coverage_spec.lua rename to tests/keymap/keymap_coverage_spec.lua diff --git a/tests/ui/keymap/keymap_help_spec.lua b/tests/keymap/keymap_help_spec.lua similarity index 100% rename from tests/ui/keymap/keymap_help_spec.lua rename to tests/keymap/keymap_help_spec.lua diff --git a/tests/ui/keymap/keymap_lifecycle_spec.lua b/tests/keymap/keymap_lifecycle_spec.lua similarity index 100% rename from tests/ui/keymap/keymap_lifecycle_spec.lua rename to tests/keymap/keymap_lifecycle_spec.lua diff --git a/tests/ui/keymap/registry_spec.lua b/tests/keymap/registry_spec.lua similarity index 100% rename from tests/ui/keymap/registry_spec.lua rename to tests/keymap/registry_spec.lua diff --git a/tests/module_loading_spec.lua b/tests/module_loading_spec.lua new file mode 100644 index 00000000..93351466 --- /dev/null +++ b/tests/module_loading_spec.lua @@ -0,0 +1,366 @@ +-- Module loading smoke tests for every codediff.ui.* submodule. +-- +-- Each folder has a set of internal Lua files plus an `init.lua` façade that +-- re-exports them. The specs below verify: +-- 1) `require(module_path)` succeeds without erroring (catches syntax +-- mistakes, missing files, circular-require breakage). +-- 2) The public API surface is preserved — each exported function name is +-- still a function on the returned table (catches accidental removal +-- of exports during refactors). +-- +-- Consolidated from four separate `*_modules_spec.lua` files (explorer, +-- lifecycle, conflict, history) because they were all the same shape: +-- pcall(require) + assert.is_function on each export. One file is easier +-- to locate, easier to keep in sync with the source, and slightly cheaper +-- to run. + +-- ── explorer ──────────────────────────────────────────────────────────── +describe("Explorer submodules", function() + describe("module loading", function() + it("loads actions module", function() + local ok, mod = pcall(require, "codediff.ui.explorer.actions") + assert.is_true(ok, "Failed to require codediff.ui.explorer.actions: " .. tostring(mod)) + assert.is_not_nil(mod) + end) + + it("loads keymaps module", function() + local ok, mod = pcall(require, "codediff.ui.explorer.keymaps") + assert.is_true(ok, "Failed to require codediff.ui.explorer.keymaps") + assert.is_not_nil(mod) + end) + + it("loads render module", function() + local ok, mod = pcall(require, "codediff.ui.explorer.render") + assert.is_true(ok, "Failed to require codediff.ui.explorer.render") + assert.is_not_nil(mod) + end) + + it("loads refresh module", function() + local ok, mod = pcall(require, "codediff.ui.explorer.refresh") + assert.is_true(ok, "Failed to require codediff.ui.explorer.refresh") + assert.is_not_nil(mod) + end) + + it("loads tree module", function() + local ok, mod = pcall(require, "codediff.ui.explorer.tree") + assert.is_true(ok, "Failed to require codediff.ui.explorer.tree") + assert.is_not_nil(mod) + end) + + it("loads formatter and line rendering modules", function() + local formatters_ok, formatters = pcall(require, "codediff.ui.explorer.formatters") + local highlights_ok, highlights = pcall(require, "codediff.ui.explorer.line_highlights") + local layout_ok, layout = pcall(require, "codediff.ui.explorer.line_layout") + local stats_ok, stats = pcall(require, "codediff.ui.explorer.line_stats") + assert.is_true(formatters_ok, "Failed to require codediff.ui.explorer.formatters") + assert.is_true(highlights_ok, "Failed to require codediff.ui.explorer.line_highlights") + assert.is_true(layout_ok, "Failed to require codediff.ui.explorer.line_layout") + assert.is_true(stats_ok, "Failed to require codediff.ui.explorer.line_stats") + assert.is_not_nil(formatters) + assert.is_not_nil(highlights) + assert.is_not_nil(layout) + assert.is_not_nil(stats) + end) + + it("loads init facade", function() + local ok, mod = pcall(require, "codediff.ui.explorer") + assert.is_true(ok, "Failed to require codediff.ui.explorer") + assert.is_not_nil(mod) + end) + end) + + describe("public API", function() + it("actions exports expected functions", function() + local mod = require("codediff.ui.explorer.actions") + assert.is_function(mod.navigate_next) + assert.is_function(mod.navigate_prev) + assert.is_function(mod.toggle_visibility) + assert.is_function(mod.toggle_view_mode) + assert.is_function(mod.toggle_stage_file) + assert.is_function(mod.toggle_stage_entry) + assert.is_function(mod.stage_all) + assert.is_function(mod.unstage_all) + assert.is_function(mod.restore_entry) + end) + + it("keymaps exports setup function", function() + local mod = require("codediff.ui.explorer.keymaps") + assert.is_function(mod.setup) + end) + + it("render exports create function", function() + local mod = require("codediff.ui.explorer.render") + assert.is_function(mod.create) + end) + + it("refresh exports expected functions", function() + local mod = require("codediff.ui.explorer.refresh") + assert.is_function(mod.setup_auto_refresh) + assert.is_function(mod.refresh) + assert.is_function(mod.get_all_files) + end) + + it("tree exports create_tree_data", function() + local mod = require("codediff.ui.explorer.tree") + assert.is_function(mod.create_tree_data) + end) + + it("formatters export callbacks for every explorer row type", function() + local mod = require("codediff.ui.explorer.formatters") + assert.is_function(mod.file) + assert.is_function(mod.folder) + assert.is_function(mod.group) + end) + + it("line stats exports aggregation", function() + assert.is_function(require("codediff.ui.explorer.line_stats").sum) + end) + end) +end) + +-- ── lifecycle ─────────────────────────────────────────────────────────── +describe("Lifecycle submodules", function() + describe("module loading", function() + it("loads session module", function() + local ok, mod = pcall(require, "codediff.ui.lifecycle.session") + assert.is_true(ok, "Failed to require codediff.ui.lifecycle.session") + assert.is_not_nil(mod) + end) + + it("loads accessors module", function() + local ok, mod = pcall(require, "codediff.ui.lifecycle.accessors") + assert.is_true(ok, "Failed to require codediff.ui.lifecycle.accessors") + assert.is_not_nil(mod) + end) + + it("loads state module", function() + local ok, mod = pcall(require, "codediff.ui.lifecycle.state") + assert.is_true(ok, "Failed to require codediff.ui.lifecycle.state") + assert.is_not_nil(mod) + end) + + it("loads cleanup module", function() + local ok, mod = pcall(require, "codediff.ui.lifecycle.cleanup") + assert.is_true(ok, "Failed to require codediff.ui.lifecycle.cleanup") + assert.is_not_nil(mod) + end) + + it("loads init facade without circular dependency", function() + local ok, mod = pcall(require, "codediff.ui.lifecycle") + assert.is_true(ok, "Failed to require codediff.ui.lifecycle - possible circular dependency") + assert.is_not_nil(mod) + end) + end) + + describe("public API", function() + it("session exports expected functions", function() + local mod = require("codediff.ui.lifecycle.session") + assert.is_function(mod.get_active_diffs) + assert.is_function(mod.create_session) + assert.is_function(mod.compute_virtual_uri) + end) + + it("accessors exports getter functions", function() + local mod = require("codediff.ui.lifecycle.accessors") + assert.is_function(mod.get_session) + assert.is_function(mod.get_mode) + assert.is_function(mod.get_git_context) + assert.is_function(mod.get_buffers) + assert.is_function(mod.get_windows) + assert.is_function(mod.get_paths) + assert.is_function(mod.find_tabpage_by_buffer) + assert.is_function(mod.is_original_virtual) + assert.is_function(mod.is_modified_virtual) + assert.is_function(mod.is_suspended) + assert.is_function(mod.get_explorer) + assert.is_function(mod.get_result) + assert.is_function(mod.get_conflict_blocks) + assert.is_function(mod.get_conflict_files) + end) + + it("accessors exports setter functions", function() + local mod = require("codediff.ui.lifecycle.accessors") + assert.is_function(mod.update_suspended) + assert.is_function(mod.update_diff_result) + assert.is_function(mod.update_changedtick) + assert.is_function(mod.update_mtime) + assert.is_function(mod.update_paths) + assert.is_function(mod.update_buffers) + assert.is_function(mod.update_git_root) + assert.is_function(mod.update_revisions) + assert.is_function(mod.set_explorer) + assert.is_function(mod.set_result) + assert.is_function(mod.set_conflict_blocks) + assert.is_function(mod.track_conflict_file) + end) + + it("state exports expected functions", function() + local mod = require("codediff.ui.lifecycle.state") + assert.is_function(mod.save_buffer_state) + assert.is_function(mod.restore_buffer_state) + assert.is_function(mod.clear_buffer_highlights) + assert.is_function(mod.get_file_mtime) + assert.is_function(mod.suspend_diff) + assert.is_function(mod.resume_diff) + end) + + it("cleanup exports expected functions", function() + local mod = require("codediff.ui.lifecycle.cleanup") + assert.is_function(mod.setup_autocmds) + assert.is_function(mod.cleanup) + assert.is_function(mod.cleanup_all) + assert.is_function(mod.setup) + end) + + it("init facade delegates all functions", function() + local mod = require("codediff.ui.lifecycle") + assert.is_function(mod.create_session) + assert.is_function(mod.cleanup) + assert.is_function(mod.cleanup_all) + assert.is_function(mod.setup) + assert.is_function(mod.get_session) + assert.is_function(mod.get_mode) + assert.is_function(mod.get_buffers) + assert.is_function(mod.get_windows) + assert.is_function(mod.set_explorer) + end) + end) + + describe("circular dependency safety", function() + it("accessors can call get_active_diffs at runtime", function() + local accessors = require("codediff.ui.lifecycle.accessors") + -- Should not error - returns nil for non-existent tabpage + local result = accessors.get_session(99999) + assert.is_nil(result) + end) + + it("state suspend_diff handles missing session gracefully", function() + local state = require("codediff.ui.lifecycle.state") + -- Should not error on non-existent tabpage + local ok = pcall(state.suspend_diff, 99999) + assert.is_true(ok, "suspend_diff should not error on missing session") + end) + end) +end) + +-- ── conflict ──────────────────────────────────────────────────────────── +describe("Conflict submodules", function() + describe("module loading", function() + it("loads actions module", function() + local ok, mod = pcall(require, "codediff.ui.conflict.actions") + assert.is_true(ok, "Failed to require codediff.ui.conflict.actions") + assert.is_not_nil(mod) + end) + + it("loads keymaps module", function() + local ok, mod = pcall(require, "codediff.ui.conflict.keymaps") + assert.is_true(ok, "Failed to require codediff.ui.conflict.keymaps") + assert.is_not_nil(mod) + end) + + it("loads navigation module", function() + local ok, mod = pcall(require, "codediff.ui.conflict.navigation") + assert.is_true(ok, "Failed to require codediff.ui.conflict.navigation") + assert.is_not_nil(mod) + end) + + it("loads signs module", function() + local ok, mod = pcall(require, "codediff.ui.conflict.signs") + assert.is_true(ok, "Failed to require codediff.ui.conflict.signs") + assert.is_not_nil(mod) + end) + + it("loads diffget module", function() + local ok, mod = pcall(require, "codediff.ui.conflict.diffget") + assert.is_true(ok, "Failed to require codediff.ui.conflict.diffget") + assert.is_not_nil(mod) + end) + + it("loads init facade", function() + local ok, mod = pcall(require, "codediff.ui.conflict") + assert.is_true(ok, "Failed to require codediff.ui.conflict") + assert.is_not_nil(mod) + end) + end) + + describe("public API", function() + it("actions exports expected functions", function() + local mod = require("codediff.ui.conflict.actions") + assert.is_function(mod.accept_incoming) + assert.is_function(mod.accept_current) + assert.is_function(mod.accept_both) + assert.is_function(mod.discard) + assert.is_function(mod.accept_all_incoming) + assert.is_function(mod.accept_all_current) + assert.is_function(mod.accept_all_both) + assert.is_function(mod.discard_all) + end) + + it("keymaps exports setup function", function() + local mod = require("codediff.ui.conflict.keymaps") + assert.is_function(mod.setup_keymaps) + end) + + it("navigation exports expected functions", function() + local mod = require("codediff.ui.conflict.navigation") + assert.is_function(mod.navigate_next_conflict) + assert.is_function(mod.navigate_prev_conflict) + end) + + it("signs exports expected functions", function() + local mod = require("codediff.ui.conflict.signs") + assert.is_function(mod.refresh_all_conflict_signs) + assert.is_function(mod.setup_sign_refresh_autocmd) + end) + + it("diffget exports expected functions", function() + local mod = require("codediff.ui.conflict.diffget") + assert.is_function(mod.diffget_incoming) + assert.is_function(mod.diffget_current) + end) + end) +end) + +-- ── history ───────────────────────────────────────────────────────────── +describe("History submodules", function() + describe("module loading", function() + it("loads refresh module", function() + local ok, mod = pcall(require, "codediff.ui.history.refresh") + assert.is_true(ok, "Failed to require codediff.ui.history.refresh") + assert.is_not_nil(mod) + end) + + it("loads render module", function() + local ok, mod = pcall(require, "codediff.ui.history.render") + assert.is_true(ok, "Failed to require codediff.ui.history.render") + assert.is_not_nil(mod) + end) + + it("loads init facade", function() + local ok, mod = pcall(require, "codediff.ui.history") + assert.is_true(ok, "Failed to require codediff.ui.history") + assert.is_not_nil(mod) + end) + end) + + describe("public API", function() + it("refresh exports expected functions", function() + local mod = require("codediff.ui.history.refresh") + assert.is_function(mod.setup_auto_refresh) + assert.is_function(mod.refresh) + end) + + it("render exports expected functions", function() + local mod = require("codediff.ui.history.render") + assert.is_function(mod.build_tree_nodes) + assert.is_function(mod.create) + assert.is_function(mod.get_all_files) + assert.is_function(mod.navigate_next) + assert.is_function(mod.navigate_prev) + assert.is_function(mod.get_all_commits) + assert.is_function(mod.navigate_next_commit) + assert.is_function(mod.navigate_prev_commit) + assert.is_function(mod.toggle_visibility) + end) + end) +end) diff --git a/tests/ui/scrollsync_spec.lua b/tests/scrollsync_spec.lua similarity index 100% rename from tests/ui/scrollsync_spec.lua rename to tests/scrollsync_spec.lua diff --git a/tests/ui/conflict/conflict_modules_spec.lua b/tests/ui/conflict/conflict_modules_spec.lua deleted file mode 100644 index a3d446ca..00000000 --- a/tests/ui/conflict/conflict_modules_spec.lua +++ /dev/null @@ -1,79 +0,0 @@ --- Module loading tests for conflict submodules --- Validates that require() wiring works correctly after _set_*_module removal - -describe("Conflict submodules", function() - describe("module loading", function() - it("loads actions module", function() - local ok, mod = pcall(require, "codediff.ui.conflict.actions") - assert.is_true(ok, "Failed to require codediff.ui.conflict.actions") - assert.is_not_nil(mod) - end) - - it("loads keymaps module", function() - local ok, mod = pcall(require, "codediff.ui.conflict.keymaps") - assert.is_true(ok, "Failed to require codediff.ui.conflict.keymaps") - assert.is_not_nil(mod) - end) - - it("loads navigation module", function() - local ok, mod = pcall(require, "codediff.ui.conflict.navigation") - assert.is_true(ok, "Failed to require codediff.ui.conflict.navigation") - assert.is_not_nil(mod) - end) - - it("loads signs module", function() - local ok, mod = pcall(require, "codediff.ui.conflict.signs") - assert.is_true(ok, "Failed to require codediff.ui.conflict.signs") - assert.is_not_nil(mod) - end) - - it("loads diffget module", function() - local ok, mod = pcall(require, "codediff.ui.conflict.diffget") - assert.is_true(ok, "Failed to require codediff.ui.conflict.diffget") - assert.is_not_nil(mod) - end) - - it("loads init facade", function() - local ok, mod = pcall(require, "codediff.ui.conflict") - assert.is_true(ok, "Failed to require codediff.ui.conflict") - assert.is_not_nil(mod) - end) - end) - - describe("public API", function() - it("actions exports expected functions", function() - local mod = require("codediff.ui.conflict.actions") - assert.is_function(mod.accept_incoming) - assert.is_function(mod.accept_current) - assert.is_function(mod.accept_both) - assert.is_function(mod.discard) - assert.is_function(mod.accept_all_incoming) - assert.is_function(mod.accept_all_current) - assert.is_function(mod.accept_all_both) - assert.is_function(mod.discard_all) - end) - - it("keymaps exports setup function", function() - local mod = require("codediff.ui.conflict.keymaps") - assert.is_function(mod.setup_keymaps) - end) - - it("navigation exports expected functions", function() - local mod = require("codediff.ui.conflict.navigation") - assert.is_function(mod.navigate_next_conflict) - assert.is_function(mod.navigate_prev_conflict) - end) - - it("signs exports expected functions", function() - local mod = require("codediff.ui.conflict.signs") - assert.is_function(mod.refresh_all_conflict_signs) - assert.is_function(mod.setup_sign_refresh_autocmd) - end) - - it("diffget exports expected functions", function() - local mod = require("codediff.ui.conflict.diffget") - assert.is_function(mod.diffget_incoming) - assert.is_function(mod.diffget_current) - end) - end) -end) diff --git a/tests/ui/explorer/explorer_modules_spec.lua b/tests/ui/explorer/explorer_modules_spec.lua deleted file mode 100644 index 2777e298..00000000 --- a/tests/ui/explorer/explorer_modules_spec.lua +++ /dev/null @@ -1,105 +0,0 @@ --- Module loading tests for explorer submodules --- Validates that require() wiring works correctly after _set_*_module removal - -describe("Explorer submodules", function() - describe("module loading", function() - it("loads actions module", function() - local ok, mod = pcall(require, "codediff.ui.explorer.actions") - assert.is_true(ok, "Failed to require codediff.ui.explorer.actions: " .. tostring(mod)) - assert.is_not_nil(mod) - end) - - it("loads keymaps module", function() - local ok, mod = pcall(require, "codediff.ui.explorer.keymaps") - assert.is_true(ok, "Failed to require codediff.ui.explorer.keymaps") - assert.is_not_nil(mod) - end) - - it("loads render module", function() - local ok, mod = pcall(require, "codediff.ui.explorer.render") - assert.is_true(ok, "Failed to require codediff.ui.explorer.render") - assert.is_not_nil(mod) - end) - - it("loads refresh module", function() - local ok, mod = pcall(require, "codediff.ui.explorer.refresh") - assert.is_true(ok, "Failed to require codediff.ui.explorer.refresh") - assert.is_not_nil(mod) - end) - - it("loads tree module", function() - local ok, mod = pcall(require, "codediff.ui.explorer.tree") - assert.is_true(ok, "Failed to require codediff.ui.explorer.tree") - assert.is_not_nil(mod) - end) - - it("loads formatter and line rendering modules", function() - local formatters_ok, formatters = pcall(require, "codediff.ui.explorer.formatters") - local highlights_ok, highlights = pcall(require, "codediff.ui.explorer.line_highlights") - local layout_ok, layout = pcall(require, "codediff.ui.explorer.line_layout") - local stats_ok, stats = pcall(require, "codediff.ui.explorer.line_stats") - assert.is_true(formatters_ok, "Failed to require codediff.ui.explorer.formatters") - assert.is_true(highlights_ok, "Failed to require codediff.ui.explorer.line_highlights") - assert.is_true(layout_ok, "Failed to require codediff.ui.explorer.line_layout") - assert.is_true(stats_ok, "Failed to require codediff.ui.explorer.line_stats") - assert.is_not_nil(formatters) - assert.is_not_nil(highlights) - assert.is_not_nil(layout) - assert.is_not_nil(stats) - end) - - it("loads init facade", function() - local ok, mod = pcall(require, "codediff.ui.explorer") - assert.is_true(ok, "Failed to require codediff.ui.explorer") - assert.is_not_nil(mod) - end) - end) - - describe("public API", function() - it("actions exports expected functions", function() - local mod = require("codediff.ui.explorer.actions") - assert.is_function(mod.navigate_next) - assert.is_function(mod.navigate_prev) - assert.is_function(mod.toggle_visibility) - assert.is_function(mod.toggle_view_mode) - assert.is_function(mod.toggle_stage_file) - assert.is_function(mod.toggle_stage_entry) - assert.is_function(mod.stage_all) - assert.is_function(mod.unstage_all) - assert.is_function(mod.restore_entry) - end) - - it("keymaps exports setup function", function() - local mod = require("codediff.ui.explorer.keymaps") - assert.is_function(mod.setup) - end) - - it("render exports create function", function() - local mod = require("codediff.ui.explorer.render") - assert.is_function(mod.create) - end) - - it("refresh exports expected functions", function() - local mod = require("codediff.ui.explorer.refresh") - assert.is_function(mod.setup_auto_refresh) - assert.is_function(mod.refresh) - assert.is_function(mod.get_all_files) - end) - - it("tree exports create_tree_data", function() - local mod = require("codediff.ui.explorer.tree") - assert.is_function(mod.create_tree_data) - end) - - it("formatters export callbacks for every explorer row type", function() - local mod = require("codediff.ui.explorer.formatters") - assert.is_function(mod.file) - assert.is_function(mod.folder) - assert.is_function(mod.group) - end) - - it("line stats exports aggregation", function() - assert.is_function(require("codediff.ui.explorer.line_stats").sum) - end) - end) -end) diff --git a/tests/ui/explorer/explorer_refresh_spec.lua b/tests/ui/explorer/explorer_refresh_spec.lua new file mode 100644 index 00000000..bbd0ce75 --- /dev/null +++ b/tests/ui/explorer/explorer_refresh_spec.lua @@ -0,0 +1,216 @@ +-- Two independent explorer regressions, both fixed in the file-refresh path. +-- +-- 1. Idle refresh loop: the explorer watches .git to notice external changes, +-- but its own `git status` momentarily writes .git/index.lock, which woke +-- the watcher and triggered another status, indefinitely (~2 refreshes/sec +-- while completely idle). The watcher now ignores *.lock events. +-- +-- 2. Single-file resize reset: untracked/added/deleted files render in a single +-- pane via show_single_file. A refresh re-selects the open file; real +-- two-pane diffs short-circuit that, but the single-file statuses rebuilt +-- the window every time and the layout pass discarded any manual sizing. +-- show_single_file now skips the rebuild when nothing changed. +-- +-- Split from tests/ui/explorer/explorer_spec.lua so the two describe blocks +-- (one for open/layout, one for refresh) can run as independent workers under +-- the framework's per-file parallelism. + +local config = require("codediff.config") +local h = dofile("tests/helpers.lua") + +-- Setup CodeDiff command for tests +local function setup_command() + local commands = require("codediff.commands") + vim.api.nvim_create_user_command("CodeDiff", function(opts) + commands.vscode_diff(opts) + end, { + nargs = "*", + bang = true, + complete = function() + return { "file", "install" } + end, + }) +end + +describe("Explorer refresh and single-file stability", function() + local temp_dir + local original_cwd + + local function open(focus_file) + local lifecycle = require("codediff.ui.lifecycle") + lifecycle.cleanup_all() + vim.cmd("edit " .. temp_dir .. "/" .. focus_file) + vim.cmd("CodeDiff") + local tabpage, explorer + local ready = vim.wait(10000, function() + for _, tp in ipairs(vim.api.nvim_list_tabpages()) do + local e = lifecycle.get_explorer(tp) + if e and e.winid and vim.api.nvim_win_is_valid(e.winid) then + tabpage, explorer = tp, e + return true + end + end + return false + end, 50) + assert.is_true(ready, "explorer should open") + return tabpage, explorer + end + + local function select_and_settle(explorer, path, status, group, opts) + explorer.on_file_select({ path = path, status = status, group = group, git_root = temp_dir }, opts or {}) + vim.wait(2500, function() + return false + end) + end + + local function single_win(tabpage) + local lifecycle = require("codediff.ui.lifecycle") + local s = lifecycle.get_session(tabpage) + if s.original_win and vim.api.nvim_win_is_valid(s.original_win) then + return s.original_win + end + if s.modified_win and vim.api.nvim_win_is_valid(s.modified_win) then + return s.modified_win + end + end + + before_each(function() + config.options = vim.deepcopy(config.defaults) + require("codediff").setup({ diff = { layout = "side-by-side" } }) + setup_command() + original_cwd = vim.fn.getcwd() + temp_dir = vim.fn.tempname() + vim.fn.mkdir(temp_dir, "p") + vim.fn.chdir(temp_dir) + h.git_cmd(temp_dir, "init") + h.git_cmd(temp_dir, "branch -m main") + h.git_cmd(temp_dir, 'config user.email "test@example.com"') + h.git_cmd(temp_dir, 'config user.name "Test User"') + vim.fn.writefile({ "line 1", "line 2" }, temp_dir .. "/file1.txt") + h.git_cmd(temp_dir, "add file1.txt") + h.git_cmd(temp_dir, 'commit -m "initial"') + -- file1.txt modified (unstaged), file3.txt untracked + vim.fn.writefile({ "line 1", "line 2 modified" }, temp_dir .. "/file1.txt") + vim.fn.writefile({ "untracked" }, temp_dir .. "/file3.txt") + end) + + after_each(function() + require("codediff.ui.lifecycle").cleanup_all() + vim.cmd("tabnew") + vim.cmd("tabonly") + vim.fn.chdir(original_cwd) + vim.wait(200) + if temp_dir and vim.fn.isdirectory(temp_dir) == 1 then + vim.fn.delete(temp_dir, "rf") + end + end) + + it("polls at a bounded, deterministic cadence while idle", function() + -- The old .git/ watcher self-triggered off its own index.lock writes at + -- roughly 2 refreshes/second — an accidental loop, not a designed cadence. + -- #480 killed that loop with a `*.lock` filter, but the filter also + -- suppressed events for external working-tree changes (e.g. `touch` + -- from another terminal), so those stopped surfacing until the user + -- refocused the explorer. The current design is an explicit 500ms poll: + -- same detection latency, deterministic idle cost, no self-triggering. + -- This test guards the *polling contract*: the tick fires steadily and + -- doesn't drift wildly (either much faster, i.e. a self-trigger loop + -- returning, or much slower, i.e. the timer stopping). + local refresh_module = require("codediff.ui.explorer.refresh") + local _, explorer = open("file1.txt") + select_and_settle(explorer, "file1.txt", "M", "unstaged", { force = true }) + vim.wait(1500, function() + return false + end) + + local count = 0 + local orig = refresh_module.refresh + refresh_module.refresh = function(e) + count = count + 1 + return orig(e) + end + vim.wait(4000, function() + return false + end) + refresh_module.refresh = orig + + -- Expected ~8 refreshes at 500ms cadence over 4s; allow a generous window + -- for scheduler jitter and coalesced ticks. + assert.is_true(count >= 5, "poll must actually tick, got " .. count) + assert.is_true(count <= 12, "poll must not exceed the 500ms cadence, got " .. count) + end) + + it("picks up an externally-created untracked file automatically", function() + -- Regression guard for the post-#480 behavior: after #480 killed the + -- .git/ watcher's self-triggering loop with a `*.lock` filter, external + -- working-tree changes (a `touch` from another terminal) stopped surfacing + -- until the user refocused the explorer. The polling replacement restores + -- automatic detection. + local _, explorer = open("file1.txt") + select_and_settle(explorer, "file1.txt", "M", "unstaged", { force = true }) + vim.wait(800, function() + return false + end) + + -- External change: brand-new untracked file, no nvim buffer, no BufEnter. + vim.fn.writefile({ "hello from outside" }, temp_dir .. "/brand_new.txt") + + -- Wait for the poll to notice. + local picked_up = vim.wait(3000, function() + for _, f in ipairs((explorer.status_result or {}).unstaged or {}) do + if f.path == "brand_new.txt" then + return true + end + end + return false + end, 100) + assert.is_true(picked_up, "external file must appear in the explorer without focus") + end) + + it("keeps a manually resized single-file pane across a refresh", function() + local tabpage, explorer = open("file1.txt") + select_and_settle(explorer, "file3.txt", "??", "unstaged", { force = true }) + + local win = single_win(tabpage) + assert.is_not_nil(win, "untracked file should be shown in a single pane") + local width = vim.api.nvim_win_get_width(win) - 10 + vim.api.nvim_win_call(win, function() + vim.cmd("vertical resize " .. width) + end) + assert.are.equal(width, vim.api.nvim_win_get_width(win), "resize should apply") + + -- A refresh re-selects the file that is already open. + select_and_settle(explorer, "file3.txt", "??", "unstaged") + + assert.are.equal(width, vim.api.nvim_win_get_width(single_win(tabpage)), "manual pane size must survive a refresh") + end) + + it("still re-renders a single-file view when the file's status changes", function() + local tabpage, explorer = open("file1.txt") + select_and_settle(explorer, "file3.txt", "??", "unstaged", { force = true }) + local before = vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(single_win(tabpage))) + + -- Staging turns ?? into A, shown from the index (:0) rather than the working + -- tree, so the view must rebuild rather than be skipped. + h.git_cmd(temp_dir, "add file3.txt") + select_and_settle(explorer, "file3.txt", "A", "staged") + + local after = vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(single_win(tabpage))) + assert.are_not.equal(before, after, "staged view must come from a different buffer than the working-tree view") + end) + + it("still restores both panes when returning to a real diff", function() + local tabpage, explorer = open("file1.txt") + select_and_settle(explorer, "file3.txt", "??", "unstaged", { force = true }) + assert.is_not_nil(single_win(tabpage), "should be in single-pane mode") + + select_and_settle(explorer, "file1.txt", "M", "unstaged", { force = true }) + + local lifecycle = require("codediff.ui.lifecycle") + local s = lifecycle.get_session(tabpage) + assert.is_true( + s.original_win ~= nil and vim.api.nvim_win_is_valid(s.original_win) and s.modified_win ~= nil and vim.api.nvim_win_is_valid(s.modified_win), + "returning to a modified file must restore both diff panes" + ) + end) +end) diff --git a/tests/ui/explorer/explorer_spec.lua b/tests/ui/explorer/explorer_spec.lua index 7378eec4..03bacad3 100644 --- a/tests/ui/explorer/explorer_spec.lua +++ b/tests/ui/explorer/explorer_spec.lua @@ -679,199 +679,3 @@ describe("Explorer Mode", function() end) end) - --- Two independent explorer regressions, both fixed in the file-refresh path. --- --- 1. Idle refresh loop: the explorer watches .git to notice external changes, --- but its own `git status` momentarily writes .git/index.lock, which woke --- the watcher and triggered another status, indefinitely (~2 refreshes/sec --- while completely idle). The watcher now ignores *.lock events. --- --- 2. Single-file resize reset: untracked/added/deleted files render in a single --- pane via show_single_file. A refresh re-selects the open file; real --- two-pane diffs short-circuit that, but the single-file statuses rebuilt --- the window every time and the layout pass discarded any manual sizing. --- show_single_file now skips the rebuild when nothing changed. -describe("Explorer refresh and single-file stability", function() - local temp_dir - local original_cwd - - local function open(focus_file) - local lifecycle = require("codediff.ui.lifecycle") - lifecycle.cleanup_all() - vim.cmd("edit " .. temp_dir .. "/" .. focus_file) - vim.cmd("CodeDiff") - local tabpage, explorer - local ready = vim.wait(10000, function() - for _, tp in ipairs(vim.api.nvim_list_tabpages()) do - local e = lifecycle.get_explorer(tp) - if e and e.winid and vim.api.nvim_win_is_valid(e.winid) then - tabpage, explorer = tp, e - return true - end - end - return false - end, 50) - assert.is_true(ready, "explorer should open") - return tabpage, explorer - end - - local function select_and_settle(explorer, path, status, group, opts) - explorer.on_file_select({ path = path, status = status, group = group, git_root = temp_dir }, opts or {}) - vim.wait(2500, function() - return false - end) - end - - local function single_win(tabpage) - local lifecycle = require("codediff.ui.lifecycle") - local s = lifecycle.get_session(tabpage) - if s.original_win and vim.api.nvim_win_is_valid(s.original_win) then - return s.original_win - end - if s.modified_win and vim.api.nvim_win_is_valid(s.modified_win) then - return s.modified_win - end - end - - before_each(function() - config.options = vim.deepcopy(config.defaults) - require("codediff").setup({ diff = { layout = "side-by-side" } }) - setup_command() - original_cwd = vim.fn.getcwd() - temp_dir = vim.fn.tempname() - vim.fn.mkdir(temp_dir, "p") - vim.fn.chdir(temp_dir) - h.git_cmd(temp_dir, "init") - h.git_cmd(temp_dir, "branch -m main") - h.git_cmd(temp_dir, 'config user.email "test@example.com"') - h.git_cmd(temp_dir, 'config user.name "Test User"') - vim.fn.writefile({ "line 1", "line 2" }, temp_dir .. "/file1.txt") - h.git_cmd(temp_dir, "add file1.txt") - h.git_cmd(temp_dir, 'commit -m "initial"') - -- file1.txt modified (unstaged), file3.txt untracked - vim.fn.writefile({ "line 1", "line 2 modified" }, temp_dir .. "/file1.txt") - vim.fn.writefile({ "untracked" }, temp_dir .. "/file3.txt") - end) - - after_each(function() - require("codediff.ui.lifecycle").cleanup_all() - vim.cmd("tabnew") - vim.cmd("tabonly") - vim.fn.chdir(original_cwd) - vim.wait(200) - if temp_dir and vim.fn.isdirectory(temp_dir) == 1 then - vim.fn.delete(temp_dir, "rf") - end - end) - - it("polls at a bounded, deterministic cadence while idle", function() - -- The old .git/ watcher self-triggered off its own index.lock writes at - -- roughly 2 refreshes/second — an accidental loop, not a designed cadence. - -- #480 killed that loop with a `*.lock` filter, but the filter also - -- suppressed events for external working-tree changes (e.g. `touch` - -- from another terminal), so those stopped surfacing until the user - -- refocused the explorer. The current design is an explicit 500ms poll: - -- same detection latency, deterministic idle cost, no self-triggering. - -- This test guards the *polling contract*: the tick fires steadily and - -- doesn't drift wildly (either much faster, i.e. a self-trigger loop - -- returning, or much slower, i.e. the timer stopping). - local refresh_module = require("codediff.ui.explorer.refresh") - local _, explorer = open("file1.txt") - select_and_settle(explorer, "file1.txt", "M", "unstaged", { force = true }) - vim.wait(1500, function() - return false - end) - - local count = 0 - local orig = refresh_module.refresh - refresh_module.refresh = function(e) - count = count + 1 - return orig(e) - end - vim.wait(4000, function() - return false - end) - refresh_module.refresh = orig - - -- Expected ~8 refreshes at 500ms cadence over 4s; allow a generous window - -- for scheduler jitter and coalesced ticks. - assert.is_true(count >= 5, "poll must actually tick, got " .. count) - assert.is_true(count <= 12, "poll must not exceed the 500ms cadence, got " .. count) - end) - - it("picks up an externally-created untracked file automatically", function() - -- Regression guard for the post-#480 behavior: after #480 killed the - -- .git/ watcher's self-triggering loop with a `*.lock` filter, external - -- working-tree changes (a `touch` from another terminal) stopped surfacing - -- until the user refocused the explorer. The polling replacement restores - -- automatic detection. - local _, explorer = open("file1.txt") - select_and_settle(explorer, "file1.txt", "M", "unstaged", { force = true }) - vim.wait(800, function() - return false - end) - - -- External change: brand-new untracked file, no nvim buffer, no BufEnter. - vim.fn.writefile({ "hello from outside" }, temp_dir .. "/brand_new.txt") - - -- Wait for the poll to notice. - local picked_up = vim.wait(3000, function() - for _, f in ipairs((explorer.status_result or {}).unstaged or {}) do - if f.path == "brand_new.txt" then - return true - end - end - return false - end, 100) - assert.is_true(picked_up, "external file must appear in the explorer without focus") - end) - - it("keeps a manually resized single-file pane across a refresh", function() - local tabpage, explorer = open("file1.txt") - select_and_settle(explorer, "file3.txt", "??", "unstaged", { force = true }) - - local win = single_win(tabpage) - assert.is_not_nil(win, "untracked file should be shown in a single pane") - local width = vim.api.nvim_win_get_width(win) - 10 - vim.api.nvim_win_call(win, function() - vim.cmd("vertical resize " .. width) - end) - assert.are.equal(width, vim.api.nvim_win_get_width(win), "resize should apply") - - -- A refresh re-selects the file that is already open. - select_and_settle(explorer, "file3.txt", "??", "unstaged") - - assert.are.equal(width, vim.api.nvim_win_get_width(single_win(tabpage)), "manual pane size must survive a refresh") - end) - - it("still re-renders a single-file view when the file's status changes", function() - local tabpage, explorer = open("file1.txt") - select_and_settle(explorer, "file3.txt", "??", "unstaged", { force = true }) - local before = vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(single_win(tabpage))) - - -- Staging turns ?? into A, shown from the index (:0) rather than the working - -- tree, so the view must rebuild rather than be skipped. - h.git_cmd(temp_dir, "add file3.txt") - select_and_settle(explorer, "file3.txt", "A", "staged") - - local after = vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(single_win(tabpage))) - assert.are_not.equal(before, after, "staged view must come from a different buffer than the working-tree view") - end) - - it("still restores both panes when returning to a real diff", function() - local tabpage, explorer = open("file1.txt") - select_and_settle(explorer, "file3.txt", "??", "unstaged", { force = true }) - assert.is_not_nil(single_win(tabpage), "should be in single-pane mode") - - select_and_settle(explorer, "file1.txt", "M", "unstaged", { force = true }) - - local lifecycle = require("codediff.ui.lifecycle") - local s = lifecycle.get_session(tabpage) - assert.is_true( - s.original_win ~= nil and vim.api.nvim_win_is_valid(s.original_win) and s.modified_win ~= nil and vim.api.nvim_win_is_valid(s.modified_win), - "returning to a modified file must restore both diff panes" - ) - end) -end) - diff --git a/tests/ui/explorer/explorer_tree_e2e_spec.lua b/tests/ui/explorer/explorer_tree_render_spec.lua similarity index 94% rename from tests/ui/explorer/explorer_tree_e2e_spec.lua rename to tests/ui/explorer/explorer_tree_render_spec.lua index 21d4f689..5995d9e3 100644 --- a/tests/ui/explorer/explorer_tree_e2e_spec.lua +++ b/tests/ui/explorer/explorer_tree_render_spec.lua @@ -1,10 +1,9 @@ --- E2E: explorer tree content and file navigation --- Converted from tests/e2e/explorer_tree.lua. +-- Explorer tree content and file navigation. local h = dofile("tests/helpers.lua") h.ensure_plugin_loaded() -describe("Explorer tree (E2E)", function() +describe("Explorer tree render", function() local repo before_each(function() diff --git a/tests/ui/explorer/explorer_toggle_e2e_spec.lua b/tests/ui/explorer/explorer_visibility_spec.lua similarity index 93% rename from tests/ui/explorer/explorer_toggle_e2e_spec.lua rename to tests/ui/explorer/explorer_visibility_spec.lua index 6416bf97..4188cc56 100644 --- a/tests/ui/explorer/explorer_toggle_e2e_spec.lua +++ b/tests/ui/explorer/explorer_visibility_spec.lua @@ -1,10 +1,9 @@ --- E2E: explorer hide/show (toggle visibility) --- Converted from tests/e2e/explorer_toggle.lua. +-- Explorer hide/show (toggle visibility). local h = dofile("tests/helpers.lua") h.ensure_plugin_loaded() -describe("Explorer toggle visibility (E2E)", function() +describe("Explorer toggle visibility", function() local repo before_each(function() diff --git a/tests/ui/explorer/explorer_layout_e2e_spec.lua b/tests/ui/explorer/explorer_window_layout_spec.lua similarity index 93% rename from tests/ui/explorer/explorer_layout_e2e_spec.lua rename to tests/ui/explorer/explorer_window_layout_spec.lua index 32bb15ef..7772d04b 100644 --- a/tests/ui/explorer/explorer_layout_e2e_spec.lua +++ b/tests/ui/explorer/explorer_window_layout_spec.lua @@ -1,13 +1,11 @@ --- E2E: explorer window position and layout --- Converted from tests/e2e/explorer_layout.lua. --- +-- Explorer window position and layout. -- Verifies that :CodeDiff opens the explorer at the LEFTMOST column (not -- between the diff panes) and that the diff panes appear to its right. local h = dofile("tests/helpers.lua") h.ensure_plugin_loaded() -describe("Explorer layout (E2E)", function() +describe("Explorer window layout", function() local repo before_each(function() diff --git a/tests/flatten_dirs_spec.lua b/tests/ui/explorer/flatten_dirs_spec.lua similarity index 100% rename from tests/flatten_dirs_spec.lua rename to tests/ui/explorer/flatten_dirs_spec.lua diff --git a/tests/ui/explorer/tab_cycle_untracked_e2e_spec.lua b/tests/ui/explorer/issue_309_spec.lua similarity index 91% rename from tests/ui/explorer/tab_cycle_untracked_e2e_spec.lua rename to tests/ui/explorer/issue_309_spec.lua index 3d6cc8de..f309bf6d 100644 --- a/tests/ui/explorer/tab_cycle_untracked_e2e_spec.lua +++ b/tests/ui/explorer/issue_309_spec.lua @@ -1,10 +1,10 @@ --- E2E: tab cycling with an untracked file must not crash (PR #309) --- Converted from tests/e2e/tab_cycle_untracked.lua. +-- Regression test for https://github.com/esmuellert/codediff.nvim/pull/309 +-- Tab cycling with an untracked file selected must not crash. -- --- Root cause the scenario was guarding: show_untracked_file() used to store --- `{}` as stored_diff_result instead of `{changes={}, moves={}}`. When --- resume_diff() later reused that value (no recompute needed on tab reentry), --- render_diff() crashed on ipairs(nil) because {}.changes is nil. +-- Root cause: show_untracked_file() used to store `{}` as stored_diff_result +-- instead of `{changes={}, moves={}}`. When resume_diff() later reused that +-- value (no recompute needed on tab reentry), render_diff() crashed on +-- ipairs(nil) because {}.changes is nil. -- -- The spec first asserts the invariant directly (stored_diff_result.changes -- must be a table right after selecting the untracked file), then exercises @@ -13,7 +13,7 @@ local h = dofile("tests/helpers.lua") h.ensure_plugin_loaded() -describe("Tab cycle with an untracked file (E2E, PR #309)", function() +describe("PR #309 regression — tab cycle with an untracked file", function() local repo local lifecycle = require("codediff.ui.lifecycle") diff --git a/tests/ui/history/history_layout_e2e_spec.lua b/tests/ui/history/history_layout_spec.lua similarity index 93% rename from tests/ui/history/history_layout_e2e_spec.lua rename to tests/ui/history/history_layout_spec.lua index c8773443..a05b16c8 100644 --- a/tests/ui/history/history_layout_e2e_spec.lua +++ b/tests/ui/history/history_layout_spec.lua @@ -1,10 +1,9 @@ --- E2E: history panel layout and content --- Converted from tests/e2e/history_layout.lua. +-- History panel layout and content. local h = dofile("tests/helpers.lua") h.ensure_plugin_loaded() -describe("History layout (E2E)", function() +describe("History layout", function() local repo before_each(function() diff --git a/tests/ui/history/history_modules_spec.lua b/tests/ui/history/history_modules_spec.lua deleted file mode 100644 index 4ea9fe00..00000000 --- a/tests/ui/history/history_modules_spec.lua +++ /dev/null @@ -1,45 +0,0 @@ --- Module loading tests for history submodules --- Validates that require() wiring works correctly after _set_*_module removal - -describe("History submodules", function() - describe("module loading", function() - it("loads refresh module", function() - local ok, mod = pcall(require, "codediff.ui.history.refresh") - assert.is_true(ok, "Failed to require codediff.ui.history.refresh") - assert.is_not_nil(mod) - end) - - it("loads render module", function() - local ok, mod = pcall(require, "codediff.ui.history.render") - assert.is_true(ok, "Failed to require codediff.ui.history.render") - assert.is_not_nil(mod) - end) - - it("loads init facade", function() - local ok, mod = pcall(require, "codediff.ui.history") - assert.is_true(ok, "Failed to require codediff.ui.history") - assert.is_not_nil(mod) - end) - end) - - describe("public API", function() - it("refresh exports expected functions", function() - local mod = require("codediff.ui.history.refresh") - assert.is_function(mod.setup_auto_refresh) - assert.is_function(mod.refresh) - end) - - it("render exports expected functions", function() - local mod = require("codediff.ui.history.render") - assert.is_function(mod.build_tree_nodes) - assert.is_function(mod.create) - assert.is_function(mod.get_all_files) - assert.is_function(mod.navigate_next) - assert.is_function(mod.navigate_prev) - assert.is_function(mod.get_all_commits) - assert.is_function(mod.navigate_next_commit) - assert.is_function(mod.navigate_prev_commit) - assert.is_function(mod.toggle_visibility) - end) - end) -end) diff --git a/tests/ui/lifecycle/lifecycle_modules_spec.lua b/tests/ui/lifecycle/lifecycle_modules_spec.lua deleted file mode 100644 index d7dbda32..00000000 --- a/tests/ui/lifecycle/lifecycle_modules_spec.lua +++ /dev/null @@ -1,127 +0,0 @@ --- Module loading tests for lifecycle submodules --- Validates that require() wiring works correctly after _set_*_module removal --- Special attention to session ↔ accessors circular dependency - -describe("Lifecycle submodules", function() - describe("module loading", function() - it("loads session module", function() - local ok, mod = pcall(require, "codediff.ui.lifecycle.session") - assert.is_true(ok, "Failed to require codediff.ui.lifecycle.session") - assert.is_not_nil(mod) - end) - - it("loads accessors module", function() - local ok, mod = pcall(require, "codediff.ui.lifecycle.accessors") - assert.is_true(ok, "Failed to require codediff.ui.lifecycle.accessors") - assert.is_not_nil(mod) - end) - - it("loads state module", function() - local ok, mod = pcall(require, "codediff.ui.lifecycle.state") - assert.is_true(ok, "Failed to require codediff.ui.lifecycle.state") - assert.is_not_nil(mod) - end) - - it("loads cleanup module", function() - local ok, mod = pcall(require, "codediff.ui.lifecycle.cleanup") - assert.is_true(ok, "Failed to require codediff.ui.lifecycle.cleanup") - assert.is_not_nil(mod) - end) - - it("loads init facade without circular dependency", function() - local ok, mod = pcall(require, "codediff.ui.lifecycle") - assert.is_true(ok, "Failed to require codediff.ui.lifecycle - possible circular dependency") - assert.is_not_nil(mod) - end) - end) - - describe("public API", function() - it("session exports expected functions", function() - local mod = require("codediff.ui.lifecycle.session") - assert.is_function(mod.get_active_diffs) - assert.is_function(mod.create_session) - assert.is_function(mod.compute_virtual_uri) - end) - - it("accessors exports getter functions", function() - local mod = require("codediff.ui.lifecycle.accessors") - assert.is_function(mod.get_session) - assert.is_function(mod.get_mode) - assert.is_function(mod.get_git_context) - assert.is_function(mod.get_buffers) - assert.is_function(mod.get_windows) - assert.is_function(mod.get_paths) - assert.is_function(mod.find_tabpage_by_buffer) - assert.is_function(mod.is_original_virtual) - assert.is_function(mod.is_modified_virtual) - assert.is_function(mod.is_suspended) - assert.is_function(mod.get_explorer) - assert.is_function(mod.get_result) - assert.is_function(mod.get_conflict_blocks) - assert.is_function(mod.get_conflict_files) - end) - - it("accessors exports setter functions", function() - local mod = require("codediff.ui.lifecycle.accessors") - assert.is_function(mod.update_suspended) - assert.is_function(mod.update_diff_result) - assert.is_function(mod.update_changedtick) - assert.is_function(mod.update_mtime) - assert.is_function(mod.update_paths) - assert.is_function(mod.update_buffers) - assert.is_function(mod.update_git_root) - assert.is_function(mod.update_revisions) - assert.is_function(mod.set_explorer) - assert.is_function(mod.set_result) - assert.is_function(mod.set_conflict_blocks) - assert.is_function(mod.track_conflict_file) - end) - - it("state exports expected functions", function() - local mod = require("codediff.ui.lifecycle.state") - assert.is_function(mod.save_buffer_state) - assert.is_function(mod.restore_buffer_state) - assert.is_function(mod.clear_buffer_highlights) - assert.is_function(mod.get_file_mtime) - assert.is_function(mod.suspend_diff) - assert.is_function(mod.resume_diff) - end) - - it("cleanup exports expected functions", function() - local mod = require("codediff.ui.lifecycle.cleanup") - assert.is_function(mod.setup_autocmds) - assert.is_function(mod.cleanup) - assert.is_function(mod.cleanup_all) - assert.is_function(mod.setup) - end) - - it("init facade delegates all functions", function() - local mod = require("codediff.ui.lifecycle") - assert.is_function(mod.create_session) - assert.is_function(mod.cleanup) - assert.is_function(mod.cleanup_all) - assert.is_function(mod.setup) - assert.is_function(mod.get_session) - assert.is_function(mod.get_mode) - assert.is_function(mod.get_buffers) - assert.is_function(mod.get_windows) - assert.is_function(mod.set_explorer) - end) - end) - - describe("circular dependency safety", function() - it("accessors can call get_active_diffs at runtime", function() - local accessors = require("codediff.ui.lifecycle.accessors") - -- Should not error - returns nil for non-existent tabpage - local result = accessors.get_session(99999) - assert.is_nil(result) - end) - - it("state suspend_diff handles missing session gracefully", function() - local state = require("codediff.ui.lifecycle.state") - -- Should not error on non-existent tabpage - local ok = pcall(state.suspend_diff, 99999) - assert.is_true(ok, "suspend_diff should not error on missing session") - end) - end) -end) diff --git a/tests/ui/core_spec.lua b/tests/ui/render_core_spec.lua similarity index 100% rename from tests/ui/core_spec.lua rename to tests/ui/render_core_spec.lua From fe909aee7de5df7fd1aa30d4fbd3184e2e59c6f4 Mon Sep 17 00:00:00 2001 From: Yanuo Ma Date: Sun, 2 Aug 2026 01:46:31 -0400 Subject: [PATCH 7/9] fix(explorer): skip auto-refresh ticks whose target repo is gone or not 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> --- lua/codediff/ui/explorer/refresh.lua | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/lua/codediff/ui/explorer/refresh.lua b/lua/codediff/ui/explorer/refresh.lua index 73e165ec..2586684c 100644 --- a/lua/codediff/ui/explorer/refresh.lua +++ b/lua/codediff/ui/explorer/refresh.lua @@ -52,6 +52,34 @@ function M.setup_auto_refresh(explorer, tabpage) if explorer.is_hidden then return end + -- Skip ticks whose target directory is gone or not yet a git repo. + -- This closes two race windows that used to emit a noisy + -- `vim.notify("Failed to refresh: fatal: not a git repository ...", ERROR)` + -- to the user (and to test stderr): + -- 1. A tab is closing but the timer is still scheduled between the + -- `after_each`-triggered `rm -rf repo` and the TabClosed autocmd + -- running the cleanup — a stale tick fires against the deleted + -- directory. + -- 2. First tick after :CodeDiff on a slow filesystem (Windows CI): + -- the explorer opens before `git init` has finished writing + -- `.git/`, and the first 500ms tick beats the initialization. + -- Either way, a poll aimed at a directory that isn't a git repo now is + -- correctly a no-op — the next tick (500ms later) either finds the repo + -- or the tab is gone. A user who `rm -rf`s their own repo behind the + -- explorer gets silence, not an error dialog. + local git_root = explorer.git_root + if git_root and git_root ~= "" then + if vim.fn.isdirectory(git_root) == 0 then + return + end + -- `.git` may be either a directory (normal repo) or a file (worktrees, + -- submodules — `gitdir: ` pointer). Missing on both counts means + -- the directory exists but isn't a repo yet. + local dot_git = git_root .. "/.git" + if vim.fn.isdirectory(dot_git) == 0 and vim.fn.filereadable(dot_git) == 0 then + return + end + end M.refresh(explorer) local auto_refresh = require("codediff.ui.auto_refresh") auto_refresh.sync_mutable_buffers(tabpage) From 9950c481c12ca6cdf4c7863132e6e0ca8417ba8a Mon Sep 17 00:00:00 2001 From: Yanuo Ma Date: Sun, 2 Aug 2026 01:47:13 -0400 Subject: [PATCH 8/9] test(explorer): tighten explorer_tree_render_spec ]f-navigation assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .../ui/explorer/explorer_tree_render_spec.lua | 82 +++++++++++++------ 1 file changed, 56 insertions(+), 26 deletions(-) diff --git a/tests/ui/explorer/explorer_tree_render_spec.lua b/tests/ui/explorer/explorer_tree_render_spec.lua index 5995d9e3..9507eb8b 100644 --- a/tests/ui/explorer/explorer_tree_render_spec.lua +++ b/tests/ui/explorer/explorer_tree_render_spec.lua @@ -17,7 +17,7 @@ describe("Explorer tree render", function() repo.write_file("src/a.txt", { "aaa modified" }) repo.write_file("src/b.txt", { "bbb modified" }) repo.write_file("c.txt", { "ccc modified" }) - vim.cmd("edit " .. repo.path("c.txt")) + vim.cmd("edit " .. vim.fn.fnameescape(repo.path("c.txt"))) end) after_each(function() @@ -27,37 +27,67 @@ describe("Explorer tree render", function() end end) - it("renders the changed files and reacts to ]f navigation", function() + it("renders the changed files and reacts to next-file navigation", function() vim.cmd("CodeDiff") assert.is_true(h.wait_for_explorer(5000)) assert.is_true(h.wait_for_diff_ready(5000)) - local _, explorer_buf = h.find_window_by_filetype("codediff-explorer") - local lines = h.get_buffer_lines(explorer_buf) - assert.is_true(#lines > 0, "explorer buffer should have content") + local lifecycle = require("codediff.ui.lifecycle") + local tabpage = vim.api.nvim_get_current_tabpage() + local session = lifecycle.get_session(tabpage) + assert.is_not_nil(session, "explorer session should exist") + local explorer = session.explorer + assert.is_not_nil(explorer, "explorer object should be attached to the session") + local _, explorer_buf = h.find_window_by_filetype("codediff-explorer") local content = h.get_buffer_content(explorer_buf) - -- At least one of the changed files must be listed. - local has_a = content:find("a.txt", 1, true) ~= nil - local has_b = content:find("b.txt", 1, true) ~= nil - local has_c = content:find("c.txt", 1, true) ~= nil - assert.is_true(has_a or has_b or has_c, - "explorer should list at least one changed file (a/b/c.txt), got:\n" .. content) - - -- The unstaged group header must be present. - h.assert_contains(content, "Changes", - "explorer should show the 'Changes' group header for unstaged files") - - -- ]f navigates to the next file — after firing it the modified pane must - -- still hold visible content (empty implies a broken navigation path). - vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("]f", true, false, true), "nx", false) - vim.wait(500) - local lifecycle = require("codediff.ui.lifecycle") - local _, mod_buf = lifecycle.get_buffers(vim.api.nvim_get_current_tabpage()) - assert.is_not_nil(mod_buf, "modified buffer should still exist after ]f") - local mod_content = h.get_buffer_content(mod_buf) - assert.is_true(mod_content ~= nil and #mod_content > 0, - "modified pane should have content after ]f navigation") + -- Every changed file must appear — no partial listing / silent filter. + h.assert_contains(content, "a.txt", "explorer should list src/a.txt") + h.assert_contains(content, "b.txt", "explorer should list src/b.txt") + h.assert_contains(content, "c.txt", "explorer should list c.txt") + h.assert_contains(content, "Changes", "explorer should show the 'Changes' group header") + + -- Next-file navigation must actually move the current selection to a + -- different file. Calling `navigation.next_file()` directly (what `]f` + -- binds to via lifecycle.set_tab_keymap) sidesteps the issue where + -- nvim_feedkeys("]f", "nx") relies on which buffer is current at the + -- time it drains — an artifact of feeding a tab-scoped keymap from an + -- explorer buffer that shadows navigation entries with its own maps. + local navigation = require("codediff.ui.view.navigation") + local before = explorer.current_file_path + assert.is_not_nil(before, "explorer should have a currently-selected file after opening") + + -- Only one changed file? Then next_file is a no-op by design (cycle over + -- a single item), and asserting a change would be wrong. Guard. + local refresh_module = require("codediff.ui.explorer.refresh") + local all_files = refresh_module.get_all_files(explorer.tree) + assert.is_true(#all_files >= 2, + "test setup should produce >= 2 changed files, got " .. #all_files) + + navigation.next_file() + -- next_file updates explorer.current_file_path synchronously, but the + -- diff render (view.update) runs via vim.schedule + async git.get_file. + -- Wait for BOTH the explorer selection AND the modified buffer name to + -- catch up, so the assertions below verify the full end-to-end path. + vim.wait(5000, function() + if explorer.current_file_path == before then return false end + local _, buf = lifecycle.get_buffers(tabpage) + if not buf or not vim.api.nvim_buf_is_valid(buf) then return false end + return vim.api.nvim_buf_get_name(buf):find(explorer.current_file_path, 1, true) ~= nil + end, 25) + + assert.are_not.equal(before, explorer.current_file_path, + "next_file must select a different file; still on '" .. tostring(before) .. "'") + + -- And the modified pane must show that new file's content, not stale + -- data from the previous selection. + local _, mod_buf = lifecycle.get_buffers(tabpage) + assert.is_not_nil(mod_buf) + local mod_name = vim.api.nvim_buf_get_name(mod_buf) + assert.is_true( + mod_name:find(explorer.current_file_path, 1, true) ~= nil, + "modified buffer name '" .. mod_name .. "' should match the newly-selected file '" .. explorer.current_file_path .. "'") end) end) + From 9407f4c4ace909b13eaf67e7ea1f1ebeffe5a352 Mon Sep 17 00:00:00 2001 From: Yanuo Ma Date: Sun, 2 Aug 2026 01:50:51 -0400 Subject: [PATCH 9/9] chore: bump version to 2.66.0 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 9fbc3d99..3d6ac35b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.65.0 +2.66.0