--- E2e harness (tech-spec ยง9): spin up a real `hephd` in local mode against a --- temp DB + socket, point the plugin at it, and tear it down deterministically. --- Step builders (create doc/task, open, edit, save) are reusable across specs. local rpc = require("heph.rpc") local uv = vim.uv or vim.loop local M = {} local counter = 0 --- Spawn a `local`-mode hephd against `db` listening on `socket` (test infra โ€” --- the plugin itself is connect-only; the daemon is normally an OS service). local function spawn(opts) local args = { "--mode", "local", "--db", opts.db, "--socket", opts.socket } local handle, pid = uv.spawn(opts.bin, { args = args, stdio = { nil, nil, opts.stderr }, }, function(code, signal) if opts.on_exit then opts.on_exit(code, signal) end end) if not handle then error("heph-test: failed to spawn hephd (bin=" .. tostring(opts.bin) .. ")") end return { handle = handle, pid = pid } end --- Wait until `socket` exists and answers `health`. Plain Lua loop โ€” never a --- `vim.wait` predicate (the rpc round-trip uses `vim.wait`; nesting deadlocks). local function wait_ready(socket, timeout) timeout = timeout or 5000 local deadline = uv.hrtime() + timeout * 1e6 while uv.hrtime() < deadline do if uv.fs_stat(socket) ~= nil then local session = rpc.new_session(socket) local ok = pcall(function() session:call("health", vim.empty_dict(), { timeout = 200 }) end) session:close() if ok then return true end end vim.wait(50) end return false, "daemon not ready at " .. socket end M.wait_ready = wait_ready local function repo_root() -- ":p" makes this absolute regardless of how the runner was launched. local here = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p") return vim.fn.fnamemodify(here, ":h:h:h:h") -- .../heph.nvim/tests/e2e -> repo root end --- The hephd binary to drive: `$HEPHD_BIN` or the workspace debug build. function M.hephd_bin() local env = vim.env.HEPHD_BIN if env and #env > 0 then return env end return repo_root() .. "/target/debug/hephd" end -- A short unique temp dir. unix socket paths are capped near 104 bytes -- (`sun_path`), so we stay under a short base, never `tempname()`. local function unique_dir() counter = counter + 1 local base = vim.env.HEPH_TEST_TMP base = (base and #base > 0) and base:gsub("/+$", "") or "/tmp" local dir = string.format("%s/h%d-%d", base, vim.fn.getpid(), counter) vim.fn.mkdir(dir, "p") return dir end --- A fresh temp dir + short socket/db paths, WITHOUT spawning a daemon (for --- tests of the no-daemon-running case). `rm` removes it. function M.tmp() local dir = unique_dir() return { dir = dir, sock = dir .. "/s", db = dir .. "/db", rm = function() pcall(function() vim.fn.delete(dir, "rf") end) end } end --- Start a daemon on explicit paths and bind the plugin's rpc to it. Returns a --- `ctx` with `dir, sock, db, daemon, exited, q` (an isolated assert session). function M.start_on(dir, sock, db) assert(#sock < 104, "socket path too long for sun_path: " .. sock) local bin = M.hephd_bin() assert( vim.fn.executable(bin) == 1, "hephd not built/executable: " .. bin .. " (run: cargo build -p hephd)" ) local exited = { done = false } local d = spawn({ bin = bin, db = db, socket = sock, on_exit = function() exited.done = true end, }) local ok, reason = wait_ready(sock, 5000) assert(ok, "daemon not ready: " .. tostring(reason)) rpc.setup(sock) -- the plugin's default session, used by buffers/commands return { dir = dir, sock = sock, db = db, daemon = d, exited = exited, q = rpc.new_session(sock), -- isolated session for independent assertions } end --- Start a fresh daemon on a new temp dir and bind the plugin's rpc to it. function M.start() local dir = unique_dir() return M.start_on(dir, dir .. "/s", dir .. "/db") end --- Tear down: close sessions, delete heph:// buffers, reap the daemon, rm temp. function M.stop(ctx) if not ctx then return end pcall(function() ctx.q:close() end) pcall(function() rpc.close() end) for _, b in ipairs(vim.api.nvim_list_bufs()) do if vim.api.nvim_buf_get_name(b):match("^heph://") then pcall(vim.api.nvim_buf_delete, b, { force = true }) end end local h = ctx.daemon and ctx.daemon.handle if h then if not ctx.exited.done then pcall(function() h:kill("sigterm") end) vim.wait(2000, function() return ctx.exited.done end, 20) end pcall(function() if not h:is_closing() then h:close() end end) end pcall(function() vim.fn.delete(ctx.dir, "rf") end) end -- --- step builders (drive the plugin's default session) --- function M.create_doc(title, body) return rpc.call("node.create", { kind = "doc", title = title, body = body or "" }) end function M.create_task(opts) return rpc.call("task.create", opts or {}) end --- Open node `id` in a buffer; returns the buffer handle. function M.open(id) require("heph.node").open(id) return vim.api.nvim_get_current_buf() end function M.set_lines(buf, lines) vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) end --- Save a heph:// buffer (fires BufWriteCmd โ†’ node.update). function M.save(buf) vim.api.nvim_buf_call(buf, function() vim.cmd("write") end) end return M