From 55bdf7189f945b3f018689a6c97f58ffd66b1a02 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 11:09:42 +0000 Subject: [PATCH 1/6] test: wait for the plugin to settle in the held-selection spec A fixed wait() ends in the loop iteration that fires the timers, before their vim.schedule_wrap callbacks run, and cannot wait for a chain (the debounce arms the demotion). When the main loop stalls for about 300 ms under load, the next step reads a plugin that is not done. settle() waits until no debounce or demotion timer is pending and every callback queued before it has run. --- tests/integration/selection_held_spec.lua | 102 +++++++++++++--------- 1 file changed, 63 insertions(+), 39 deletions(-) diff --git a/tests/integration/selection_held_spec.lua b/tests/integration/selection_held_spec.lua index 80da4b9a7..3bbac64b2 100644 --- a/tests/integration/selection_held_spec.lua +++ b/tests/integration/selection_held_spec.lua @@ -22,13 +22,32 @@ describe("a held selection with the real plugin", function() vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(keys, true, false, true), "xt", false) end - -- Lets the real timers run: the debounce is 100 ms. + -- Lets the real timers run for at least ms. Use it only where the time is the point of the step. local function wait(ms) vim.wait(ms, function() return false end, 10) end + -- Waits until the plugin has finished what it started: no debounce timer is pending, no demotion timer is pending + -- (unless keep_demotion), and every vim.schedule callback that was queued before the call has run (the check after + -- an edit is one). A fixed wait() cannot say that. When the main loop stalls for 300 ms under load, vim.wait returns + -- in the iteration that fires the timers, before their vim.schedule_wrap callbacks run. The step after the wait then + -- reads a plugin that is not done: the debounce has not read the cursor, or a demotion fires after the step has + -- renamed the buffer. + local function settle(keep_demotion) + local drained = false + vim.schedule(function() + drained = true + end) + local done = vim.wait(10000, function() + return drained + and selection.state.debounce_timer == nil + and (keep_demotion or selection.state.demotion_timer == nil) + end, 10) + assert.is_true(done, "the plugin still has a timer pending") + end + local function start(terminal_config, delay_ms) claudecode.setup({ auto_start = false, @@ -67,12 +86,17 @@ describe("a held selection with the real plugin", function() vim.cmd("edit " .. vim.fn.fnameescape(path)) end - -- Select alpha (the first word) and release it with Esc: Claude holds it. - local function select_and_release() + -- Select alpha (the first word) and release it with Esc: Claude holds it. The plugin reads the selection when the + -- debounce fires, and arms the demotion when it reads the release. keep_demotion leaves that demotion pending, for a + -- spec that needs it pending (a visual_demotion_delay_ms of 1000). + local function select_and_release(keep_demotion) press("ve") - wait(150) + settle(true) press("") - wait(300) + settle(keep_demotion) + if keep_demotion then + assert.is_not_nil(selection.state.demotion_timer, "the demotion is not pending") + end assert.are.equal("alpha", selection.get_latest_selection().text) end @@ -102,13 +126,13 @@ describe("a held selection with the real plugin", function() it("keeps the selection when the Claude terminal first opens (delay " .. delay_ms .. ")", function() start({ provider = "native", show_native_term_exit_tip = false }, delay_ms) open_file("alpha beta gamma") - select_and_release() + select_and_release(delay_ms > 500) -- a demotion that late is still pending when the terminal opens require("claudecode.terminal").open() vim.cmd("stopinsert") - wait(300) + settle() vim.cmd("wincmd p") - wait(600 + delay_ms) -- the debounce, then the demotion + settle() -- the debounce, then the demotion assert.are.equal("alpha", selection.get_latest_selection().text) end) @@ -122,14 +146,14 @@ describe("a held selection with the real plugin", function() local term_buf = vim.api.nvim_get_current_buf() vim.cmd("stopinsert") vim.cmd("buffer " .. file_buf) - wait(200) + settle() select_and_release() vim.cmd("buffer " .. term_buf) vim.cmd("stopinsert") - wait(300) + settle() vim.cmd("buffer #") - wait(600) + settle() assert.are.equal(file_buf, vim.api.nvim_get_current_buf()) assert.are.equal("alpha", selection.get_latest_selection().text) @@ -143,12 +167,12 @@ describe("a held selection with the real plugin", function() local term_buf = vim.api.nvim_get_current_buf() vim.cmd("stopinsert") vim.cmd("buffer " .. file_buf) - wait(200) + settle() select_and_release() -- Claude holds "alpha" press("$vd") -- a second selection, on the last character, consumed before the debounce vim.cmd("buffer " .. term_buf) -- and the Claude terminal, where the debounce does nothing - wait(300) + settle() local held = selection.get_latest_selection() assert.is_true(held.selection.isEmpty) @@ -180,14 +204,14 @@ describe("a held selection with the real plugin", function() local term_buf = vim.api.nvim_get_current_buf() vim.cmd("stopinsert") vim.cmd("buffer " .. file_buf) - wait(200) + settle() select_and_release() vim.cmd("buffer " .. term_buf) vim.cmd("stopinsert") - wait(300) + settle() vim.api.nvim_buf_set_text(file_buf, 0, 0, 0, 6, {}) -- a plugin deletes "alpha " - wait(300) + settle() assert.are.equal(term_buf, vim.api.nvim_get_current_buf()) local held = selection.get_latest_selection() @@ -205,14 +229,14 @@ describe("a held selection with the real plugin", function() local term_win = vim.api.nvim_get_current_win() vim.cmd("stopinsert") vim.api.nvim_set_current_win(file_win) - wait(200) + settle() select_and_release() vim.api.nvim_set_current_win(term_win) vim.cmd("stopinsert") - wait(300) + settle() rewrite_on_disk_and_reload(file_buf, "beta gamma") - wait(300) + settle() assert.are.same({ "beta gamma" }, vim.api.nvim_buf_get_lines(file_buf, 0, -1, false)) assert.are.equal(term_win, vim.api.nvim_get_current_win()) @@ -232,24 +256,24 @@ describe("a held selection with the real plugin", function() local term_buf = vim.api.nvim_get_current_buf() vim.cmd("stopinsert") vim.cmd("buffer " .. file_buf) - wait(200) + settle() select_and_release() vim.cmd("buffer " .. term_buf) vim.cmd("stopinsert") - wait(300) + settle() vim.cmd("bunload! " .. file_buf) - wait(200) + settle() vim.cmd("buffer " .. file_buf) vim.api.nvim_win_set_cursor(0, { 1, 0 }) -- Neovim put the cursor back where it was - wait(200) + settle() select_and_release() vim.cmd("buffer " .. term_buf) vim.cmd("stopinsert") - wait(300) + settle() vim.api.nvim_buf_set_text(file_buf, 0, 0, 0, 6, {}) -- a plugin deletes "alpha " - wait(300) + settle() assert.are.equal(term_buf, vim.api.nvim_get_current_buf()) assert.is_true(selection.get_latest_selection().selection.isEmpty) @@ -268,7 +292,7 @@ describe("a held selection with the real plugin", function() vim.v.errmsg = "" vim.api.nvim_buf_set_text(file_buf, 0, 0, 0, 6, {}) -- a plugin deletes "alpha " - wait(300) + settle() assert.are.equal("", vim.v.errmsg) assert.are.same({}, sent) -- no selection and no cursor of the terminal reached Claude @@ -283,13 +307,13 @@ describe("a held selection with the real plugin", function() start({ provider = "none" }, 1000) open_file("alpha beta gamma") local file_buf = vim.api.nvim_get_current_buf() - select_and_release() + select_and_release(true) local sent = record_broadcasts() rename_to_claude_terminal(file_buf) vim.v.errmsg = "" vim.api.nvim_buf_set_text(file_buf, 0, 0, 0, 6, {}) -- a plugin deletes "alpha " - wait(1300) -- the demotion delay passes + settle() -- a demotion that the check did not cancel fires here assert.are.equal("", vim.v.errmsg) assert.are.same({}, sent) @@ -306,16 +330,16 @@ describe("a held selection with the real plugin", function() local term_buf = vim.api.nvim_get_current_buf() vim.cmd("stopinsert") vim.cmd("buffer " .. file_buf) - wait(200) + settle() select_and_release() vim.cmd("buffer " .. term_buf) vim.cmd("stopinsert") - wait(300) + settle() local sent = record_broadcasts() rename_to_claude_terminal(file_buf) vim.api.nvim_buf_set_text(file_buf, 0, 0, 0, 6, {}) -- a plugin deletes "alpha " - wait(300) + settle() assert.are.equal(term_buf, vim.api.nvim_get_current_buf()) assert.are.same({}, sent) @@ -341,12 +365,12 @@ describe("a held selection with the real plugin", function() vim.api.nvim_win_set_cursor(0, { 1, 0 }) select_and_release() vim.cmd("enew") -- another file: the debounce replaces the selection with that buffer's cursor - wait(300) + settle() assert.is_true(selection.get_latest_selection().selection.isEmpty) rewrite_on_disk_and_reload(file_buf, "alpha beta gamma, cycle " .. cycle) -- on_reload, nothing held - wait(200) + settle() vim.cmd("buffer " .. file_buf) - wait(200) + settle() end end) vim.api.nvim_buf_attach = real_attach @@ -364,21 +388,21 @@ describe("a held selection with the real plugin", function() local term_win = vim.api.nvim_get_current_win() vim.cmd("stopinsert") vim.api.nvim_set_current_win(file_win) - wait(200) + settle() select_and_release() vim.api.nvim_set_current_win(term_win) vim.cmd("stopinsert") - wait(300) + settle() rewrite_on_disk_and_reload(file_buf, "alpha beta gamma, and more") - wait(300) + settle() assert.are.same({ "alpha beta gamma, and more" }, vim.api.nvim_buf_get_lines(file_buf, 0, -1, false)) assert.are.equal("alpha", selection.get_latest_selection().text) -- The listener survives the reload: the next change is seen too. vim.api.nvim_buf_set_text(file_buf, 0, 0, 0, 6, {}) - wait(300) + settle() assert.is_true(selection.get_latest_selection().selection.isEmpty) end) end) @@ -407,7 +431,7 @@ describe("a held selection with the real plugin", function() vim.api.nvim_buf_delete(scratch, { force = true }) end end) - wait(400) + settle() assert.is_false(vim.api.nvim_buf_is_valid(scratch)) assert.are.equal(file_buf, vim.api.nvim_get_current_buf()) From ff9a19555bd28b896a30c3e9f1107a181231cbc5 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 11:32:41 +0000 Subject: [PATCH 2/6] test: say in the settle comment that a late debounce arms a pending demotion --- tests/integration/selection_held_spec.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integration/selection_held_spec.lua b/tests/integration/selection_held_spec.lua index 3bbac64b2..ff9084262 100644 --- a/tests/integration/selection_held_spec.lua +++ b/tests/integration/selection_held_spec.lua @@ -32,9 +32,9 @@ describe("a held selection with the real plugin", function() -- Waits until the plugin has finished what it started: no debounce timer is pending, no demotion timer is pending -- (unless keep_demotion), and every vim.schedule callback that was queued before the call has run (the check after -- an edit is one). A fixed wait() cannot say that. When the main loop stalls for 300 ms under load, vim.wait returns - -- in the iteration that fires the timers, before their vim.schedule_wrap callbacks run. The step after the wait then - -- reads a plugin that is not done: the debounce has not read the cursor, or a demotion fires after the step has - -- renamed the buffer. + -- in the iteration that fires the timers, before their vim.schedule_wrap callbacks run, and a debounce that fires + -- late arms a demotion that is still pending. The step after the wait then reads a plugin that is not done: the + -- debounce has not read the cursor, the demotion has not fired, or it fires after the step has renamed the buffer. local function settle(keep_demotion) local drained = false vim.schedule(function() From a5cdcd550f75035a59bb4dffd0b3bfef1d9b4dad Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 11:34:05 +0000 Subject: [PATCH 3/6] test(selection): wait 400 ms after the cursor and cover a selection that the debounce sent before d After an operator the plugin has held a cursor, wait_for_cursor lets the debounce and the demotion run for 400 ms again (it was 200), so a selection sent up to 400 ms late shows. The new spec lets the debounce send the selection in visual mode before d, so assert_is_the_selection and the check that the last message is a cursor run on every run, not only when the host is slow. --- .../selection_visual_edit_spec.lua | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/tests/integration/selection_visual_edit_spec.lua b/tests/integration/selection_visual_edit_spec.lua index fc6fdaa3f..4cf5812ff 100644 --- a/tests/integration/selection_visual_edit_spec.lua +++ b/tests/integration/selection_visual_edit_spec.lua @@ -31,14 +31,14 @@ describe("a visual selection released after the buffer changed, with the real pl -- After an operator the plugin reports the cursor once its flush has run, and a busy host can delay that past any fixed -- wait (get_latest_selection() is nil until then). So wait until it holds a cursor, then let the debounce and the - -- demotion run, so that a selection sent late would show. + -- demotion run for 400 ms, so that a selection sent late would show. local function wait_for_cursor() local held = vim.wait(5000, function() local latest = selection.get_latest_selection() return latest ~= nil and latest.selection.isEmpty end, 5) assert.is_true(held, "the plugin holds a cursor after the operator") - wait(200) + wait(400) end local function start() @@ -275,6 +275,31 @@ describe("a visual selection released after the buffer changed, with the real pl assert.is_true(selection.get_latest_selection().selection.isEmpty) assert_last_sent_is_a_cursor() end) + + -- The two specs above press the operator within the 100 ms debounce, so usually nothing was sent in visual mode and + -- their checks of an early selection (assert_is_the_selection, and a cursor as the last message) have nothing to + -- look at. Here the debounce has sent the selection before d, on every run. + it("sends a cursor for d after the debounce sent the selection in visual mode", function() + start() + open_file({ "alpha beta gamma", "second line" }) + wait(50) + + press("ve") + local sent_in_visual_mode = vim.wait(5000, function() + return held_text() == "alpha" + end, 5) + assert.is_true(sent_in_visual_mode, "the debounce sent the selection in visual mode") + local before_operator = #sent + assert.is_true(before_operator >= 1) + press("d") + wait_for_cursor() + + assert.are.equal(" beta gamma", vim.api.nvim_buf_get_lines(0, 0, 1, false)[1]) + assert.are.same({ true }, { sent_no_selection(before_operator) }) + assert_is_the_selection(before_operator) + assert.is_true(selection.get_latest_selection().selection.isEmpty) + assert_last_sent_is_a_cursor() + end) end) it("does not carry an edit from one visual selection into the next", function() From 14a2a3a38da4fcab32ac74dc7f57f3a2b47c31af Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 13:16:17 +0000 Subject: [PATCH 4/6] test: give a late send 300 ms to show in the three tests that expect nothing sent settle() returns when no timer is pending, so a send that a later timer makes would pass. The old fixed waits gave it 300 ms (1300 ms in one test). A mutant that sends the cursor of the terminal buffer 200 ms late failed 3 tests of the old spec and none of the new one. With wait(300) after settle() it fails the same 3 again. --- tests/integration/selection_held_spec.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/integration/selection_held_spec.lua b/tests/integration/selection_held_spec.lua index ff9084262..96776201c 100644 --- a/tests/integration/selection_held_spec.lua +++ b/tests/integration/selection_held_spec.lua @@ -293,6 +293,7 @@ describe("a held selection with the real plugin", function() vim.v.errmsg = "" vim.api.nvim_buf_set_text(file_buf, 0, 0, 0, 6, {}) -- a plugin deletes "alpha " settle() + wait(300) -- settle() ends when nothing is pending, so give a send that a later timer makes 300 ms to show assert.are.equal("", vim.v.errmsg) assert.are.same({}, sent) -- no selection and no cursor of the terminal reached Claude @@ -314,6 +315,7 @@ describe("a held selection with the real plugin", function() vim.v.errmsg = "" vim.api.nvim_buf_set_text(file_buf, 0, 0, 0, 6, {}) -- a plugin deletes "alpha " settle() -- a demotion that the check did not cancel fires here + wait(300) -- and a send that a later timer makes shows too assert.are.equal("", vim.v.errmsg) assert.are.same({}, sent) @@ -340,6 +342,7 @@ describe("a held selection with the real plugin", function() rename_to_claude_terminal(file_buf) vim.api.nvim_buf_set_text(file_buf, 0, 0, 0, 6, {}) -- a plugin deletes "alpha " settle() + wait(300) -- a send that a later timer makes shows too assert.are.equal(term_buf, vim.api.nvim_get_current_buf()) assert.are.same({}, sent) From 1de8f59664856a59c29686ac2e54feb3e95da185 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 13:28:07 +0000 Subject: [PATCH 5/6] test: drain the callbacks after the late-send window A timer that fires in the last iteration of the 300 ms wait has not run its vim.schedule_wrap callback when the wait returns. The three tests that expect nothing sent now call wait_for_late_send(), which waits 300 ms and then settles again. --- tests/integration/selection_held_spec.lua | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/integration/selection_held_spec.lua b/tests/integration/selection_held_spec.lua index 96776201c..f1118463d 100644 --- a/tests/integration/selection_held_spec.lua +++ b/tests/integration/selection_held_spec.lua @@ -48,6 +48,14 @@ describe("a held selection with the real plugin", function() assert.is_true(done, "the plugin still has a timer pending") end + -- For a spec that expects nothing to be sent: settle() ends as soon as no timer is pending, so a send that a later + -- timer makes would pass. Wait 300 ms for it (the old fixed waits gave it that), then settle() again: a timer that + -- fires in the last iteration of the wait has not run its callback yet. + local function wait_for_late_send() + wait(300) + settle() + end + local function start(terminal_config, delay_ms) claudecode.setup({ auto_start = false, @@ -293,7 +301,7 @@ describe("a held selection with the real plugin", function() vim.v.errmsg = "" vim.api.nvim_buf_set_text(file_buf, 0, 0, 0, 6, {}) -- a plugin deletes "alpha " settle() - wait(300) -- settle() ends when nothing is pending, so give a send that a later timer makes 300 ms to show + wait_for_late_send() assert.are.equal("", vim.v.errmsg) assert.are.same({}, sent) -- no selection and no cursor of the terminal reached Claude @@ -315,7 +323,7 @@ describe("a held selection with the real plugin", function() vim.v.errmsg = "" vim.api.nvim_buf_set_text(file_buf, 0, 0, 0, 6, {}) -- a plugin deletes "alpha " settle() -- a demotion that the check did not cancel fires here - wait(300) -- and a send that a later timer makes shows too + wait_for_late_send() assert.are.equal("", vim.v.errmsg) assert.are.same({}, sent) @@ -342,7 +350,7 @@ describe("a held selection with the real plugin", function() rename_to_claude_terminal(file_buf) vim.api.nvim_buf_set_text(file_buf, 0, 0, 0, 6, {}) -- a plugin deletes "alpha " settle() - wait(300) -- a send that a later timer makes shows too + wait_for_late_send() assert.are.equal(term_buf, vim.api.nvim_get_current_buf()) assert.are.same({}, sent) From 3da5cf4ba063f0ffa7d1472d0cec74c57ab13929 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 12 Nov 2026 15:34:59 +0000 Subject: [PATCH 6/6] test(selection): drain the callbacks after the 400 ms wait for a late selection A timer that fires in the last iteration of the wait has not run its vim.schedule_wrap callback when the wait returns, so a phantom selection sent at the end of the window would still pass. wait_for_cursor now drains the queued callbacks after the 400 ms. --- tests/integration/selection_visual_edit_spec.lua | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/integration/selection_visual_edit_spec.lua b/tests/integration/selection_visual_edit_spec.lua index 2022e09c2..0638d43ad 100644 --- a/tests/integration/selection_visual_edit_spec.lua +++ b/tests/integration/selection_visual_edit_spec.lua @@ -29,9 +29,21 @@ describe("a visual selection released after the buffer changed, with the real pl end, 5) end + -- Runs every vim.schedule callback that is queued now. A timer that fires in the last iteration of a wait has not run + -- its vim.schedule_wrap callback when the wait returns. + local function drain() + local drained = false + vim.schedule(function() + drained = true + end) + vim.wait(5000, function() + return drained + end, 5) + end + -- After an operator the plugin reports the cursor once its flush has run, and a busy host can delay that past any fixed -- wait (get_latest_selection() is nil until then). So wait until it holds a cursor, then let the debounce and the - -- demotion run for 400 ms, so that a selection sent late would show. + -- demotion run for 400 ms and drain the callbacks, so that a selection sent late would show. local function wait_for_cursor() local held = vim.wait(5000, function() local latest = selection.get_latest_selection() @@ -39,6 +51,7 @@ describe("a visual selection released after the buffer changed, with the real pl end, 5) assert.is_true(held, "the plugin holds a cursor after the operator") wait(400) + drain() end local function start()