Skip to main content

Neovim test infrastructure

We'll start with a high-level overview of the architecture of the Cursorless tests for neovim, and then we'll dive into the details.

Neovim tests

Here is the call path when running Neovim tests locally. Note that -> indicates one file calling another file:

launch.json -> .vscode/tasks.json -> nvim -u init.lua

init.lua
-> CursorlessLoadExtension()
-> TestHarnessRun() -> run() -> runAllTests() -> Mocha -> packages/cursorless-neovim-e2e/src/suite/recorded.neovim.test.ts

And here is the call path when running Neovim tests on CI:

.github/workflows/test.yml -> packages/test-harness/package.json -> my-ts-node src/scripts/runNeovimTestsCI.ts -> packages/test-harness/src/launchNeovimAndRunTests.ts

launchNeovimAndRunTests.ts
-> copies packages/test-harness/src/config/init.lua to default nvim config folder
-> nvim --headless
-> read Cursorless logs to determine success or failure

packages/test-harness/src/config/init.lua
-> CursorlessLoadExtension()
-> TestHarnessRun() -> run() -> runAllTests() -> Mocha + packages/cursorless-neovim-e2e/src/suite/recorded.neovim.test.ts

Running Neovim tests locally

This is supported on Windows, Linux and OSX.

It starts by running the Neovim: Test launch config from .vscode/launch.json. This dictates VSCode to attach to the node process that is spawned by nvim (more on this later). Note that it will only attach when the dependencies have been solved, which is indicated by the "Neovim: Build extension and tests" task:

    {
"name": "Neovim: Test",
"request": "attach",
"continueOnAttach": true,
"skipFiles": ["<node_internals>/**"],
"preLaunchTask": "Neovim: Build extension and tests",
"type": "node"
},

This effectively runs a series of dependency tasks from .vscode/tasks.json:

    {
"label": "Neovim: Build extension and tests",
"dependsOn": [
"Neovim: Launch neovim (test)",
"Neovim: ESBuild",
"Neovim: Populate dist",
"TSBuild",
"Build test harness",
"Neovim: Show logs"
],
"group": "build"
},

Most of the tasks deal with building the Cursorless code except "Neovim: Launch neovim (test)" and "Neovim: Show logs" which are self explanatory.

The Neovim: Launch neovim (test) task effectively starts nvim as a detached process. It is important because it means VSCode won't wait for nvim to exit before considering the task as finished. For example, for Windows it executes the debug-neovim.bat script :

    {
"label": "Neovim: Launch neovim (test)",
"type": "process",
"windows": {
"command": "powershell",
"args": [
"(New-Object -ComObject WScript.Shell).Run(\"\"\"${workspaceFolder}/packages/cursorless-neovim/scripts/debug-neovim.bat\"\"\", 1, $false)"
]
},
...
"options": {
"env": {
"CURSORLESS_REPO_ROOT": "${workspaceFolder}",
"NVIM_NODE_HOST_DEBUG": "1",
"NVIM_NODE_LOG_FILE": "${workspaceFolder}/packages/cursorless-neovim/out/nvim_node.log",
"NVIM_NODE_LOG_LEVEL": "info",
"CURSORLESS_MODE": "test"
}
}

This ends up passing the init.lua script as the default config file (-u):

nvim -u %CURSORLESS_REPO_ROOT%/init.lua

This init.lua adds the local cursorless.nvim relative path to the runtime path and initializes Cursorless:

local repo_root = os.getenv("CURSORLESS_REPO_ROOT")
if not repo_root then
error("CURSORLESS_REPO_ROOT is not set. Run via debug-neovim.sh script.")
end
vim.opt.runtimepath:append(repo_root .. "/cursorless.nvim")
...
require("cursorless").setup()

NOTE: this relies on having symlinks inside cursorless.nvim/node/ to point to the development paths packages/cursorless-neovim and packages/test-harness. This is required in order to have all the symbols loaded for debugging.

This ends up calling setup() from cursorless.nvim/lua/cursorless/init.lua:

local function setup(user_config)
...
register_functions()
load_extensions()

First, it calls register_functions() to expose the node functions CursorlessLoadExtension() and TestHarnessRun() into the vim namespace. A side effect is that the nvim process loads the node process:

local function register_functions()
...
vim.fn["remote#host#RegisterPlugin"](
"node",
path .. "/node/cursorless-neovim/",
{
{
type = "function",
name = "CursorlessLoadExtension",
sync = false,
opts = vim.empty_dict(),
},
}
)
vim.fn["remote#host#RegisterPlugin"]("node", path .. "/node/test-harness/", {
{
type = "function",
name = "TestHarnessRun",
sync = false,
opts = vim.empty_dict(),
},
})

Then, it calls load_extensions(). This calls the vim functions in order to load the Cursorless neovim plugin (CursorlessLoadExtension()) and start the tests (TestHarnessRun()) which ends up calling the previously registered node functions.

local function load_extensions()
vim.fn.CursorlessLoadExtension()

if os.getenv("CURSORLESS_MODE") == "test" then
-- make sure cursorless is loaded before starting the tests
vim.uv.sleep(1000)
vim.fn.TestHarnessRun()

However, because nvim was started with "NVIM_NODE_HOST_DEBUG": "1", when node is spawned, node will hang and wait for a debugger to attach (--inspect-brk). Consequently, nvim won't finish loading yet (i.e. it won't finish loading init.lua).

This is handy because it allows VSCode to finish all the tasks required for building the Cursorless neovim plugin (cursorless-neovim) and the Tests neovim plugin (test-harness), which will finally trigger VSCode to attach to the node process.

When VSCode attaches to the node process, CursorlessLoadExtension() is called to load the Cursorles neovim plugin and TestHarnessRun() is called to start the tests.

This ends up calling TestHarnessRun() from packages/test-harness/src/index.ts which calls run():

export default function entry(plugin: NvimPlugin) {
plugin.registerFunction("TestHarnessRun", () => run(plugin), {
sync: false,
});
}

export async function run(plugin: NvimPlugin): Promise<void> {
...
await runAllTests(TestType.neovim, TestType.unit);
console.log(`==== TESTS FINISHED: code: ${code}`);

This ends up calling runAllTests() which calls runTestsInDir() from packages/test-harness/src/runAllTests.ts.

This ends up using the Mocha API to execute tests which names end with neovim.test.cjs (Cursorless tests for neovim) and test.cjs (Cursorless unit tests):

async function runTestsInDir(
testRoot: string,
filterFiles: (files: string[]) => string[],
): Promise<void> {
// Create the mocha test
const mocha = new Mocha({
...
});
...
try {
// Run the mocha test
await new Promise<void>((c, e) => {
mocha.run((failures) => {
...

Consequently, the recorded tests from data/fixtures/recorded/ are executed when packages/cursorless-neovim-e2e/src/suite/recorded.neovim.test.ts is invoked.

Running Neovim tests on CI

This is supported on Linux only.

It starts from .github/workflows/test.yml which currently only tests the latest stable neovim version on Linux:

run: xvfb-run -a pnpm -F @cursorless/test-harness test:neovim
if: runner.os == 'Linux' && matrix.app_version == 'stable'

This triggers the script in packages/test-harness/package.json:

"test:neovim": "env CURSORLESS_MODE=test my-ts-node src/scripts/runNeovimTestsCI.ts",

This ends up calling the default function from package/test-harness/src/scripts/runNeovimTestsCI.ts which calls launchNeovimAndRunTests() from packages/test-harness/src/launchNeovimAndRunTests.ts:

(async () => {
// Note that we run all extension tests, including unit tests, in neovim, even though
// unit tests could be run separately.
await launchNeovimAndRunTests();
})();

This ends up copying the packages/test-harness/src/config/init.lua file into the default nvim config folder (A), starting neovim without a GUI (--headless) (B) and reading Cursorless logs in order to determine success or failure (C):

export async function launchNeovimAndRunTests() {
...
copyFile(initLuaFile, `${nvimFolder}/init.lua`, (err: any) => { // (A)
if (err) {
console.error(err);
}
});
...
const subprocess = cp.spawn(cli, [`--headless`], { // (B)
env: {
...process.env,
["NVIM_NODE_LOG_FILE"]: logName,
["NVIM_NODE_LOG_LEVEL"]: "info", // default for testing
["CURSORLESS_MODE"]: "test",
},
});
...
tailTest = new Tail(logName, { // (C)
fromBeginning: true,
});
...
tailTest.on("line", function (data: string) {
console.log(`neovim test: ${data}`);
if (data.includes("==== TESTS FINISHED:")) {
done = true;
console.log(`done: ${done}`);

At this stage, we are in a similar situation to the "Cursorless tests for neovim locally" case where nvim is started with the packages/test-harness/src/config/init.lua config file. Similarly, this init.lua adds the local cursorless.nvim relative path to the runtime path and initializes Cursorless:

local repo_root = os.getenv("CURSORLESS_REPO_ROOT")
...
vim.opt.runtimepath:append(repo_root .. "/dist/cursorless.nvim")
...
require("cursorless").setup()

This ends up calling setup() from dist/cursorless.nvim/lua/cursorless/init.lua, which ends up triggering TestHarnessRun() and finally the recorded tests from recorded.neovim.test.ts using the Mocha API.

NOTE: Because NVIM_NODE_HOST_DEBUG is not set on CI, nvim loads entirely right away and tests are executed.

NOTE: CI uses dist/cursorless.nvim/ (and not cursorless.nvim/), since the symlinks in cursorless.nvim/ are only created locally in order to get symbols loaded, which we don't need on CI.

Lua unit tests

This is supported on Linux only, both locally and on CI.

Here is the call path when running lua unit tests locally. Note that -> indicates one file calling another file:

launch.json -> .vscode/tasks.json -> cd cursorless.nvim && busted --run unit
cursorless.nvim/.busted
-> lua interpreter: cursorless.nvim/test/nvim-shim.sh -> nvim -l <spec_script>
-> test specification files: cursorless.nvim/test/unit/*_spec.lua

And here is the call path when running lua unit tests on CI:

.github/workflows/test.yml -> .github/actions/test-neovim-lua/action.yml -> cd cursorless.nvim && busted --run unit
cursorless.nvim/.busted
-> lua interpreter: cursorless.nvim/test/nvim-shim.sh -> nvim -l <spec_script>
-> test specification files: cursorless.nvim/test/unit/*_spec.lua

Running lua unit tests

Many of the cursorless.nvim lua functions are run in order to complete Cursorless actions and so are already indirectly tested by the tests described in the previous section. Nevertheless, we run more specific unit tests in order to give better visibility into exactly which functions are failing. The busted framework is used to test lua functions defined in cursorless.nvim. This relies on a cursorless.nvim/.busted file which directs busted to use a lua interpreter and test specifications files:

return {
_all = {
lua = './test/nvim-shim.sh'
},
unit = {
ROOT = {'./test/unit/'},
},
}

The .busted file declares the cursorless.nvim/test/nvim-shim.sh shell wrapper as its lua interpreter. This script sets up an enclosed neovim environment by using XDG Base Directory environment variables (Linux only) pointing to a temp directory. This allows loading cursorless.nvim, any helpers and (optional) plugins needed to run the tests. Consequently, the cursorless.nvim lua functions are exposed to the tests. Afterwards, the shim will use nvim -l <spec_script> for each of the lua test specifications scripts. The .busted file declares that test specifications files are in cursorless.nvim/test/unit/. Any file in that folder ending with _spec.lua contains tests and will be executed by neovim's lua interpreter. NOTE: Different tests rely on the same custom test helper functions. These functions are exposed as globals in a file called helpers.lua placed in nvim/plugin/ inside the isolated XDG environment. These helpers themselves also have their own unit tests that will be run by busted. This busted setup was inspired by this blog post, which goes into greater detail.