feat: add process runner and status indicator
Implement runner module for spawning and managing watchexec child processes via vim.fn.jobstart with stdout/stderr piping and ANSI stripping. Add indicator module for displaying a small non-focusable float that shows the last command outcome (success/failure) when the main window is hidden.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
local indicator = require("watchexec.indicator")
|
||||
local config = require("watchexec.config")
|
||||
local stub = require("luassert.stub")
|
||||
|
||||
describe("watchexec indicator", function()
|
||||
local runner
|
||||
local window
|
||||
|
||||
before_each(function()
|
||||
config.reset()
|
||||
config.setup({ indicator = { enabled = true } })
|
||||
indicator.reset()
|
||||
|
||||
runner = require("watchexec.runner")
|
||||
window = require("watchexec.window")
|
||||
|
||||
stub(runner, "is_running", function()
|
||||
return false
|
||||
end)
|
||||
stub(window, "is_visible", function()
|
||||
return false
|
||||
end)
|
||||
end)
|
||||
|
||||
after_each(function()
|
||||
runner.is_running:revert()
|
||||
window.is_visible:revert()
|
||||
end)
|
||||
|
||||
describe("process_output()", function()
|
||||
it("marks last_outcome as success on success pattern", function()
|
||||
indicator.process_output("[Command was successful]")
|
||||
assert.equals("success", indicator.get_last_outcome())
|
||||
end)
|
||||
|
||||
it("sets waiting_for_outcome on running pattern", function()
|
||||
indicator.process_output("[Running] echo hello")
|
||||
indicator.refresh()
|
||||
assert.is_nil(indicator.get_win())
|
||||
end)
|
||||
|
||||
it("marks failure when next command starts before success", function()
|
||||
indicator.process_output("[Running] echo first")
|
||||
indicator.process_output("[Running] echo second")
|
||||
|
||||
indicator.refresh()
|
||||
|
||||
local win = indicator.get_win()
|
||||
assert.is_true(vim.api.nvim_win_is_valid(win))
|
||||
assert.equals("failure", indicator.get_last_outcome())
|
||||
end)
|
||||
|
||||
it("marks failure on Command exited with code pattern", function()
|
||||
indicator.process_output("[Command exited with code 1]")
|
||||
assert.equals("failure", indicator.get_last_outcome())
|
||||
end)
|
||||
|
||||
it("leaves outcome unchanged for unrelated lines", function()
|
||||
indicator.process_output("some random output")
|
||||
assert.is_nil(indicator.get_last_outcome())
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("process_exit()", function()
|
||||
it("marks failure if a command was in-flight", function()
|
||||
indicator.process_output("[Running] echo hello")
|
||||
indicator.process_exit()
|
||||
assert.equals("failure", indicator.get_last_outcome())
|
||||
end)
|
||||
|
||||
it("does nothing if no command was in-flight", function()
|
||||
indicator.process_exit()
|
||||
assert.is_nil(indicator.get_last_outcome())
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("refresh()", function()
|
||||
it("creates indicator when outcome known and window hidden", function()
|
||||
indicator.process_output("[Command was successful]")
|
||||
|
||||
indicator.refresh()
|
||||
|
||||
local win = indicator.get_win()
|
||||
assert.is_true(vim.api.nvim_win_is_valid(win))
|
||||
end)
|
||||
|
||||
it("does not create indicator when disabled", function()
|
||||
config.reset()
|
||||
config.setup({ indicator = { enabled = false } })
|
||||
|
||||
indicator.process_output("[Command was successful]")
|
||||
|
||||
indicator.refresh()
|
||||
|
||||
assert.is_nil(indicator.get_win())
|
||||
end)
|
||||
|
||||
it("does not create indicator when window is visible", function()
|
||||
indicator.process_output("[Command was successful]")
|
||||
|
||||
window.is_visible:revert()
|
||||
stub(window, "is_visible", function()
|
||||
return true
|
||||
end)
|
||||
|
||||
indicator.refresh()
|
||||
|
||||
assert.is_nil(indicator.get_win())
|
||||
end)
|
||||
|
||||
it("does not create indicator when last_outcome is nil", function()
|
||||
indicator.refresh()
|
||||
|
||||
assert.is_nil(indicator.get_win())
|
||||
end)
|
||||
|
||||
it("closes indicator when window becomes visible and reopens when hidden", function()
|
||||
local visible = false
|
||||
window.is_visible:revert()
|
||||
stub(window, "is_visible", function()
|
||||
return visible
|
||||
end)
|
||||
|
||||
indicator.process_output("[Command was successful]")
|
||||
|
||||
indicator.refresh()
|
||||
|
||||
assert.is_not_nil(indicator.get_win())
|
||||
|
||||
visible = true
|
||||
|
||||
indicator.refresh()
|
||||
|
||||
assert.is_nil(indicator.get_win())
|
||||
assert.equals("success", indicator.get_last_outcome())
|
||||
|
||||
visible = false
|
||||
|
||||
indicator.refresh()
|
||||
|
||||
assert.is_not_nil(indicator.get_win())
|
||||
end)
|
||||
|
||||
it("creates a non-focusable float window", function()
|
||||
indicator.process_output("[Command was successful]")
|
||||
|
||||
indicator.refresh()
|
||||
|
||||
local win = indicator.get_win()
|
||||
local win_config = vim.api.nvim_win_get_config(win)
|
||||
|
||||
assert.is_false(win_config.focusable)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("reset()", function()
|
||||
it("clears last_outcome and waiting_for_outcome", function()
|
||||
indicator.process_output("[Running] echo hello")
|
||||
indicator.reset()
|
||||
assert.is_nil(indicator.get_last_outcome())
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
@@ -0,0 +1,218 @@
|
||||
local config = require("watchexec.config")
|
||||
local window = require("watchexec.window")
|
||||
local runner = require("watchexec.runner")
|
||||
local stub = require("luassert.stub")
|
||||
|
||||
describe("watchexec runner", function()
|
||||
local jobstart_stub
|
||||
local jobpid_stub
|
||||
local jobstop_stub
|
||||
local notify_stub
|
||||
local indicator
|
||||
|
||||
before_each(function()
|
||||
config.reset()
|
||||
config.setup({})
|
||||
window.cleanup()
|
||||
|
||||
jobstart_stub = stub(vim.fn, "jobstart", function()
|
||||
return 42
|
||||
end)
|
||||
|
||||
jobpid_stub = stub(vim.fn, "jobpid", function()
|
||||
return 12345
|
||||
end)
|
||||
|
||||
jobstop_stub = stub(vim.fn, "jobstop")
|
||||
notify_stub = stub(vim, "notify")
|
||||
|
||||
indicator = require("watchexec.indicator")
|
||||
stub(indicator, "process_output")
|
||||
stub(indicator, "process_exit")
|
||||
stub(indicator, "reset")
|
||||
stub(indicator, "refresh")
|
||||
end)
|
||||
|
||||
after_each(function()
|
||||
if runner.is_running() then
|
||||
runner.stop()
|
||||
end
|
||||
|
||||
jobstart_stub:revert()
|
||||
jobpid_stub:revert()
|
||||
jobstop_stub:revert()
|
||||
|
||||
notify_stub:revert()
|
||||
|
||||
indicator.process_output:revert()
|
||||
indicator.process_exit:revert()
|
||||
indicator.reset:revert()
|
||||
indicator.refresh:revert()
|
||||
|
||||
window.cleanup()
|
||||
|
||||
config.reset()
|
||||
end)
|
||||
|
||||
describe("start()", function()
|
||||
it("spawns watchexec with binary and command args", function()
|
||||
window.open()
|
||||
runner.start("pytest")
|
||||
|
||||
assert.stub(jobstart_stub).was_called(1)
|
||||
|
||||
local cmd = jobstart_stub.calls[1].refs[1]
|
||||
|
||||
assert.same({ "watchexec", "pytest" }, cmd)
|
||||
end)
|
||||
|
||||
it("includes watchexec.args before command", function()
|
||||
config.setup({
|
||||
watchexec = { args = { "-e", "py" } },
|
||||
})
|
||||
window.open()
|
||||
runner.start("-- pytest")
|
||||
|
||||
local cmd = jobstart_stub.calls[1].refs[1]
|
||||
|
||||
assert.same({ "watchexec", "-e", "py", "--", "pytest" }, cmd)
|
||||
end)
|
||||
|
||||
it("gets pid via jobpid", function()
|
||||
window.open()
|
||||
runner.start("test")
|
||||
|
||||
assert.stub(jobpid_stub).was_called_with(42)
|
||||
end)
|
||||
|
||||
it("notifies error when binary not found", function()
|
||||
config.get().watchexec.bin = nil
|
||||
window.open()
|
||||
runner.start("test")
|
||||
|
||||
assert.stub(notify_stub).was_called_with("watchexec.nvim: watchexec binary not found", vim.log.levels.ERROR)
|
||||
assert.stub(jobstart_stub).was_called(0)
|
||||
end)
|
||||
|
||||
it("stores is_running state after spawn", function()
|
||||
window.open()
|
||||
runner.start("test")
|
||||
|
||||
assert.is_true(runner.is_running())
|
||||
end)
|
||||
|
||||
it("stores cmd after spawn", function()
|
||||
window.open()
|
||||
runner.start("pytest -x")
|
||||
|
||||
assert.equals("pytest -x", runner.get_cmd())
|
||||
end)
|
||||
|
||||
it("provides callbacks to jobstart", function()
|
||||
window.open()
|
||||
runner.start("test")
|
||||
|
||||
local opts = jobstart_stub.calls[1].refs[2]
|
||||
|
||||
assert.is_function(opts.on_stdout)
|
||||
assert.is_function(opts.on_stderr)
|
||||
assert.is_function(opts.on_exit)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("stop()", function()
|
||||
it("calls jobstop", function()
|
||||
window.open()
|
||||
runner.start("test")
|
||||
runner.stop()
|
||||
|
||||
assert.stub(jobstop_stub).was_called_with(42)
|
||||
end)
|
||||
|
||||
it("calls vim.uv.kill with pid", function()
|
||||
local uv_kill_stub = stub(vim.uv, "kill")
|
||||
|
||||
window.open()
|
||||
runner.start("test")
|
||||
runner.stop()
|
||||
|
||||
assert.stub(uv_kill_stub).was_called_with(12345, "term")
|
||||
|
||||
uv_kill_stub:revert()
|
||||
end)
|
||||
|
||||
it("clears running state", function()
|
||||
window.open()
|
||||
runner.start("test")
|
||||
runner.stop()
|
||||
|
||||
assert.is_false(runner.is_running())
|
||||
end)
|
||||
|
||||
it("does nothing if no process running", function()
|
||||
runner.stop()
|
||||
|
||||
assert.is_false(runner.is_running())
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("is_running()", function()
|
||||
it("returns true after start", function()
|
||||
window.open()
|
||||
runner.start("test")
|
||||
|
||||
assert.is_true(runner.is_running())
|
||||
end)
|
||||
|
||||
it("returns false initially", function()
|
||||
assert.is_false(runner.is_running())
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("get_cmd()", function()
|
||||
it("returns the command passed to start", function()
|
||||
window.open()
|
||||
runner.start("pytest -x")
|
||||
|
||||
assert.equals("pytest -x", runner.get_cmd())
|
||||
end)
|
||||
|
||||
it("returns nil after stop", function()
|
||||
window.open()
|
||||
runner.start("test")
|
||||
runner.stop()
|
||||
|
||||
assert.is_nil(runner.get_cmd())
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("on_exit guard", function()
|
||||
it("ignores stale on_exit from replaced job", function()
|
||||
local call_count = 0
|
||||
|
||||
jobstart_stub:revert()
|
||||
jobstart_stub = stub(vim.fn, "jobstart", function()
|
||||
call_count = call_count + 1
|
||||
return 42 + call_count
|
||||
end)
|
||||
|
||||
jobpid_stub:revert()
|
||||
jobpid_stub = stub(vim.fn, "jobpid", function()
|
||||
return 10000 + call_count
|
||||
end)
|
||||
|
||||
window.open()
|
||||
runner.start("first")
|
||||
|
||||
local first_opts = jobstart_stub.calls[1].refs[2]
|
||||
|
||||
runner.start("second")
|
||||
|
||||
first_opts.on_exit(43, 0, nil)
|
||||
vim.cmd("sleep 1m")
|
||||
|
||||
assert.is_true(runner.is_running())
|
||||
assert.equals("second", runner.get_cmd())
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
Reference in New Issue
Block a user