Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: test

on:
push:
branches: [main]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install Neovim
uses: rhysd/action-setup-vim@v1
with:
neovim: true
version: stable

- name: Run tests
run: nvim -l tests/run.lua

lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Check formatting
uses: JohnnyMorganz/stylua-action@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
version: latest
args: --check lua/ tests/
12 changes: 12 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.PHONY: test lint format

# Run the unit test suite. Pass PATTERN=<substring> to run a subset:
# make test PATTERN=mount_point
test:
nvim -l tests/run.lua $(PATTERN)

lint:
stylua --check lua/ tests/

format:
stylua lua/ tests/
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,3 +342,13 @@ Auth flow: keys first, then floating terminal for passphrases/passwords/2FA; Con
- **Configure `global_paths`** with common directories (`/var/www`, `/var/log`, `~/.config`) to have them available across all hosts
- **Configure `host_paths`** for frequently-used hosts to skip path selection
- **Set `preferred_picker` for local/remote pickers** to force specific file picker(s) instead of auto-detection

## 🧪 Development

Run the unit test suite with:

```sh
make test
```

Tests run in headless Neovim with no external dependencies and never contact a real SSH server or mount table. See [`tests/README.md`](tests/README.md) for the harness, the available stubs, and how to add coverage. CI runs the suite and `stylua --check` on every pull request.
55 changes: 55 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Tests

Unit tests for sshfs.nvim. They run inside headless Neovim, use no external dependencies, and never contact a real SSH server or mount table.

## Running

```sh
make test # everything
make test PATTERN=mount_point # only spec files whose path contains the pattern
nvim -l tests/run.lua # same as `make test`
```

The runner exits non-zero when any case fails, so it can gate CI. `.github/workflows/test.yml` runs the suite and `stylua --check` on every pull request.

## Layout

| File | Purpose |
| --- | --- |
| `harness.lua` | `describe`/`it` registry, assertions, result reporting |
| `stub.lua` | Replaces the system-facing calls and restores them afterwards |
| `run.lua` | Entry point: discovers `tests/*_spec.lua` and exits with the result |
| `*_spec.lua` | One spec file per module under test |

## Writing a test

Spec files are plain Lua. The runner exposes `describe`, `it`, `expect`, and `stub` as globals, so no requires are needed.

```lua
describe("MountPoint.list_active", function()
it("ignores mounts outside the configured base directory", function()
stub.reload()
require("sshfs.config").setup({ mounts = { base_dir = "/home/tester/mnt" } })
stub.executable({})
stub.system(function()
return "deploy@example.com:/srv/app on /somewhere/else type fuse.sshfs (rw)", 0
end)

expect.eq(require("sshfs.lib.mount_point").list_active(), {})
stub.restore_all()
end)
end)
```

### Assertions

`expect.eq` (deep equality), `expect.truthy`, `expect.falsy`, `expect.is_nil`, `expect.contains` (plain substring), `expect.errors`, `expect.no_error` (which forwards every value the call returned). Each takes an optional trailing context string that is shown on failure.

### Stubs

`stub.system(handler)` replaces `vim.fn.system` and the `v:shell_error` it reports; the handler returns `output, code`. `stub.vim_system(handler)` replaces `vim.system`, supporting both the async-callback and `:wait()` call styles, and returns the list of commands it received. `stub.executable(names)` controls `vim.fn.executable`. `stub.notifications()` captures `vim.notify` calls instead of printing them. `stub.set(path, value)` replaces any other field under `vim`.

Two rules matter:

- Call `stub.restore_all()` at the end of a case that stubbed anything.
- Call `stub.reload()` before requiring a plugin module when the test depends on fresh module state. Several modules memoize (configuration, SSHFS version detection), so a stale load will leak state between cases.
98 changes: 98 additions & 0 deletions tests/config_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
-- tests/config_spec.lua
-- Configuration merging, accessors, and deprecation shims

local function load_config(user_config)
stub.reload()
local Config = require("sshfs.config")
Config.setup(user_config)
return Config
end

describe("Config.setup", function()
it("keeps defaults the user did not override", function()
local Config = load_config({ mounts = { base_dir = "/custom/mnt" } })
local opts = Config.get()

expect.eq(opts.mounts.base_dir, "/custom/mnt")
expect.eq(opts.connections.sshfs_options.reconnect, true)
expect.eq(opts.connections.sshfs_options.ConnectTimeout, 5)
end)

it("merges nested tables instead of replacing them", function()
local Config = load_config({ connections = { sshfs_options = { ConnectTimeout = 30 } } })
local options = Config.get().connections.sshfs_options

expect.eq(options.ConnectTimeout, 30, "the overridden value wins")
expect.eq(options.compression, "yes", "sibling defaults survive the merge")
end)

it("accepts no user configuration at all", function()
local Config = load_config(nil)
expect.truthy(Config.get().mounts.base_dir)
end)
end)

describe("Config accessors", function()
it("returns the configured mount base directory", function()
local Config = load_config({ mounts = { base_dir = "/custom/mnt" } })
expect.eq(Config.get_base_dir(), "/custom/mnt")
end)

it("returns the configured socket directory", function()
local Config = load_config({ connections = { socket_dir = "/custom/sockets" } })
expect.eq(Config.get_socket_dir(), "/custom/sockets")
end)

it("builds ControlMaster options from the socket directory and persist window", function()
local Config = load_config({ connections = { socket_dir = "/custom/sockets", control_persist = "5m" } })

expect.eq(Config.get_control_master_options(), {
"ControlMaster=auto",
"ControlPath=/custom/sockets/%C",
"ControlPersist=5m",
})
end)
end)

describe("Config deprecations", function()
it("maps ui.file_picker onto ui.local_picker and warns", function()
stub.reload()
local notifications = stub.notifications()
local Config = require("sshfs.config")
Config.setup({ ui = { file_picker = { preferred_picker = "telescope" } } })
stub.restore_all()

expect.eq(Config.get().ui.local_picker.preferred_picker, "telescope")
expect.eq(#notifications, 1)
expect.contains(notifications[1].message, "ui.file_picker")
end)

it("maps mounts.unmount_on_exit onto hooks.on_exit.auto_unmount", function()
stub.reload()
stub.notifications()
local Config = require("sshfs.config")
Config.setup({ mounts = { unmount_on_exit = true } })
stub.restore_all()

expect.eq(Config.get().hooks.on_exit.auto_unmount, true)
end)

it("maps mounts.auto_change_dir_on_mount onto hooks.on_mount.auto_change_to_dir", function()
stub.reload()
stub.notifications()
local Config = require("sshfs.config")
Config.setup({ mounts = { auto_change_dir_on_mount = false } })
stub.restore_all()

expect.eq(Config.get().hooks.on_mount.auto_change_to_dir, false)
end)

it("stays quiet when no deprecated key is used", function()
stub.reload()
local notifications = stub.notifications()
require("sshfs.config").setup({ mounts = { base_dir = "/custom/mnt" } })
stub.restore_all()

expect.eq(#notifications, 0)
end)
end)
167 changes: 167 additions & 0 deletions tests/harness.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
-- tests/harness.lua
-- Minimal dependency-free test harness for sshfs.nvim
--
-- Tests are plain Lua files that call describe/it and the expect helpers. The
-- runner executes them inside headless Neovim so the real vim API is available
-- and only the system-facing calls need stubbing.

local Harness = {}

local suites = {}
local current_suite = nil

--- Register a group of test cases
--- @param name string Suite name
--- @param fn function Function that registers cases with it()
function Harness.describe(name, fn)
local suite = { name = name, cases = {} }
table.insert(suites, suite)

current_suite = suite
local ok, err = pcall(fn)
current_suite = nil

if not ok then
-- A suite that fails to register is reported as a single failing case so
-- the runner still exits non-zero instead of silently skipping it.
table.insert(suite.cases, {
name = "<suite registration>",
fn = function()
error(err, 0)
end,
})
end
end

--- Register a single test case
--- @param name string Case name
--- @param fn function Test body; failures are raised as errors
function Harness.it(name, fn)
if not current_suite then error("it() called outside of describe()", 2) end
table.insert(current_suite.cases, { name = name, fn = fn })
end

local function render(value)
if type(value) == "string" then return string.format("%q", value) end
if type(value) == "table" then return vim.inspect(value) end
return tostring(value)
end

local function fail(message, level)
error(message, (level or 2) + 1)
end

Harness.expect = {}

--- Assert deep equality, comparing tables by value
function Harness.expect.eq(actual, expected, context)
if not vim.deep_equal(actual, expected) then
fail(
string.format("%sexpected %s\n got %s", context and (context .. ": ") or "", render(expected), render(actual))
)
end
end

--- Assert a value is neither nil nor false
function Harness.expect.truthy(value, context)
if not value then
fail(string.format("%sexpected a truthy value, got %s", context and (context .. ": ") or "", render(value)))
end
end

--- Assert a value is nil or false
function Harness.expect.falsy(value, context)
if value then
fail(string.format("%sexpected a falsy value, got %s", context and (context .. ": ") or "", render(value)))
end
end

--- Assert a value is exactly nil (distinct from false)
function Harness.expect.is_nil(value, context)
if value ~= nil then
fail(string.format("%sexpected nil, got %s", context and (context .. ": ") or "", render(value)))
end
end

--- Assert a string contains a plain substring
function Harness.expect.contains(haystack, needle, context)
if type(haystack) ~= "string" or not haystack:find(needle, 1, true) then
fail(
string.format(
"%sexpected %s to contain %s",
context and (context .. ": ") or "",
render(haystack),
render(needle)
)
)
end
end

--- Assert a function raises, optionally matching a plain substring of the error
function Harness.expect.errors(fn, needle, context)
local ok, err = pcall(fn)
if ok then fail(string.format("%sexpected the call to raise an error", context and (context .. ": ") or "")) end
if needle then Harness.expect.contains(tostring(err), needle, context) end
end

--- Assert a function does not raise, returning every result it produced
function Harness.expect.no_error(fn, context)
local results = { pcall(fn) }
if not results[1] then
fail(string.format("%sexpected no error, got %s", context and (context .. ": ") or "", tostring(results[2])))
end
return unpack(results, 2, #results)
end

--- Run every registered suite and report results
--- @return number exit_code 0 when all cases pass
function Harness.run()
-- io.write keeps line breaks deterministic; print() interleaves oddly under `nvim -l`
local function say(text)
io.write((text or "") .. "\n")
end

local passed, failed = 0, 0
local failures = {}

for _, suite in ipairs(suites) do
say(suite.name)
for _, case in ipairs(suite.cases) do
local ok, err = pcall(case.fn)
-- A case that fails before its own restore would leak stubs into later cases.
require("tests.stub").restore_all()
if ok then
passed = passed + 1
say(" ok " .. case.name)
else
failed = failed + 1
say(" FAIL " .. case.name)
table.insert(failures, { name = suite.name .. " :: " .. case.name, err = tostring(err) })
end
end
end

say("")
if failed > 0 then
say("Failures:")
for _, failure in ipairs(failures) do
say("")
say(" " .. failure.name)
for line in failure.err:gmatch("[^\r\n]+") do
say(" " .. line)
end
end
say("")
end

say(string.format("%d passed, %d failed", passed, failed))
return failed == 0 and 0 or 1
end

--- Discard registered suites (used by the runner between files in-process)
function Harness.reset()
suites = {}
current_suite = nil
end

return Harness
Loading
Loading